From 373fad6efe71cfaa708cc532d5cecddf0d99d341 Mon Sep 17 00:00:00 2001 From: Stephan Date: Mon, 21 May 2018 10:05:52 +0200 Subject: [PATCH] Merge ModelsBuilder in, NuGet dies on circ dependencies now --- .../Api/ApiBasicAuthFilter.cs | 86 +++ src/Umbraco.ModelsBuilder/Api/ApiClient.cs | 159 +++++ src/Umbraco.ModelsBuilder/Api/ApiHelper.cs | 29 + src/Umbraco.ModelsBuilder/Api/ApiVersion.cs | 88 +++ .../Api/GetModelsData.cs | 17 + .../Api/ModelsBuilderApiController.cs | 82 +++ src/Umbraco.ModelsBuilder/Api/TokenData.cs | 17 + .../Api/ValidateClientVersionData.cs | 57 ++ src/Umbraco.ModelsBuilder/Building/Builder.cs | 322 ++++++++++ .../Building/CodeDomBuilder.cs | 113 ++++ .../Building/CodeParser.cs | 238 +++++++ .../Building/Compiler.cs | 171 +++++ .../Building/CompilerException.cs | 25 + .../Building/ParseResult.cs | 275 ++++++++ .../Building/PropertyModel.cs | 66 ++ .../Building/TextBuilder.cs | 554 ++++++++++++++++ .../Building/TextHeaderWriter.cs | 26 + .../Building/TypeModel.cs | 208 ++++++ .../Configuration/ClrNameSource.cs | 28 + .../Configuration/Config.cs | 368 +++++++++++ .../Configuration/ModelsMode.cs | 52 ++ .../Configuration/ModelsModeExtensions.cs | 51 ++ .../Configuration/UmbracoConfigExtensions.cs | 34 + .../Dashboard/BuilderDashboardHelper.cs | 91 +++ .../EnumerableExtensions.cs | 33 + .../IgnoreContentTypeAttribute.cs | 46 ++ .../IgnorePropertyTypeAttribute.cs | 19 + .../ImplementContentTypeAttribute.cs | 20 + .../ImplementPropertyTypeAttribute.cs | 23 + .../ModelsBaseClassAttribute.cs | 16 + .../ModelsBuilderAssemblyAttribute.cs | 23 + .../ModelsNamespaceAttribute.cs | 16 + .../ModelsUsingAttribute.cs | 21 + .../Properties/AssemblyInfo.cs | 10 + .../PublishedElementExtensions.cs | 48 ++ .../PublishedPropertyTypeExtensions.cs | 20 + .../PureLiveAssemblyAttribute.cs | 15 + .../ReferencedAssemblies.cs | 137 ++++ .../RenameContentTypeAttribute.cs | 18 + .../RenamePropertyTypeAttribute.cs | 18 + src/Umbraco.ModelsBuilder/TypeExtensions.cs | 22 + .../Umbraco.ModelsBuilder.csproj | 118 ++++ .../Umbraco/Application.cs | 236 +++++++ .../Umbraco/HashCombiner.cs | 38 ++ .../Umbraco/HashHelper.cs | 45 ++ .../Umbraco/LiveModelsProvider.cs | 139 ++++ .../Umbraco/ModelsBuilderApplication.cs | 175 +++++ .../ModelsBuilderBackOfficeController.cs | 181 ++++++ .../Umbraco/ModelsBuilderComponent.cs | 183 ++++++ .../Umbraco/ModelsGenerationError.cs | 60 ++ .../Umbraco/OutOfDateModelsStatus.cs | 61 ++ .../Umbraco/PublishedModelUtility.cs | 67 ++ .../Umbraco/PureLiveModelFactory.cs | 601 ++++++++++++++++++ .../Validation/ContentTypeModelValidator.cs | 95 +++ src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 5 +- src/umbraco.sln | 6 + 56 files changed, 5671 insertions(+), 1 deletion(-) create mode 100644 src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/ApiClient.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/ApiHelper.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/ApiVersion.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/GetModelsData.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/TokenData.cs create mode 100644 src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/Builder.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/CodeParser.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/Compiler.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/CompilerException.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/ParseResult.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/PropertyModel.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/TextBuilder.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs create mode 100644 src/Umbraco.ModelsBuilder/Building/TypeModel.cs create mode 100644 src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs create mode 100644 src/Umbraco.ModelsBuilder/Configuration/Config.cs create mode 100644 src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs create mode 100644 src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs create mode 100644 src/Umbraco.ModelsBuilder/EnumerableExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs create mode 100644 src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs create mode 100644 src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs create mode 100644 src/Umbraco.ModelsBuilder/TypeExtensions.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/Application.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderApplication.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs create mode 100644 src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs create mode 100644 src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs diff --git a/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs b/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs new file mode 100644 index 0000000000..cc862ff207 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Web.Http.Controllers; +using System.Web.Security; +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.Membership; + +namespace Umbraco.ModelsBuilder.Api +{ + + //TODO: This needs to be changed: + // * Authentication cannot happen in a filter, only Authorization + // * The filter must be an AuthorizationFilter, not an ActionFilter + // * Authorization must be done using the Umbraco logic - it is very specific for claim checking for ASP.Net Identity + // * Theoretically this shouldn't be required whatsoever because when we authenticate a request that has Basic Auth (i.e. for + // VS to work, it will add the correct Claims to the Identity and it will automatically be authorized. + // + // we *do* have POC supporting ASP.NET identity, however they require some config on the server + // we'll keep using this quick-and-dirty method for the time being + + public class ApiBasicAuthFilter : System.Web.Http.Filters.ActionFilterAttribute // use the http one, not mvc, with api controllers! + { + private static readonly char[] Separator = ":".ToCharArray(); + private readonly string _section; + + public ApiBasicAuthFilter(string section) + { + _section = section; + } + + public override void OnActionExecuting(HttpActionContext actionContext) + { + try + { + var user = Authenticate(actionContext.Request); + if (user == null || !user.AllowedSections.Contains(_section)) + { + actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + } + //else + //{ + // // note - would that be a proper way to pass data to the controller? + // // see http://stevescodingblog.co.uk/basic-authentication-with-asp-net-webapi/ + // actionContext.ControllerContext.RouteData.Values["umbraco-user"] = user; + //} + + base.OnActionExecuting(actionContext); + } + catch + { + actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + } + } + + private static IUser Authenticate(HttpRequestMessage request) + { + var ah = request.Headers.Authorization; + if (ah == null || ah.Scheme != "Basic") + return null; + + var token = ah.Parameter; + var credentials = Encoding.ASCII + .GetString(Convert.FromBase64String(token)) + .Split(Separator); + if (credentials.Length != 2) + return null; + + var username = ApiClient.DecodeTokenElement(credentials[0]); + var password = ApiClient.DecodeTokenElement(credentials[1]); + + var providerKey = UmbracoConfig.For.UmbracoSettings().Providers.DefaultBackOfficeUserProvider; + var provider = Membership.Providers[providerKey]; + if (provider == null || !provider.ValidateUser(username, password)) + return null; + var user = Current.Services.UserService.GetByUsername(username); + if (!user.IsApproved || user.IsLockedOut) + return null; + return user; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Api/ApiClient.cs b/src/Umbraco.ModelsBuilder/Api/ApiClient.cs new file mode 100644 index 0000000000..dde3641b97 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ApiClient.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Formatting; +using System.Net.Http.Headers; +using System.Text; + +namespace Umbraco.ModelsBuilder.Api +{ + public class ApiClient + { + private readonly string _url; + private readonly string _user; + private readonly string _password; + + private readonly JsonMediaTypeFormatter _formatter; + private readonly MediaTypeFormatter[] _formatters; + + // fixme hardcoded? + // could be options - but we cannot "discover" them as the API client runs outside of the web app + // in addition, anything that references the controller forces API clients to reference Umbraco.Core + private const string ApiControllerUrl = "/Umbraco/BackOffice/ModelsBuilder/ModelsBuilderApi/"; + + public ApiClient(string url, string user, string password) + { + _url = url.TrimEnd('/'); + _user = user; + _password = password; + + _formatter = new JsonMediaTypeFormatter(); + _formatters = new MediaTypeFormatter[] { _formatter }; + } + + private void SetBaseAddress(HttpClient client, string url) + { + try + { + client.BaseAddress = new Uri(url); + } + catch + { + throw new UriFormatException($"Invalid URI: the format of the URI \"{url}\" could not be determined."); + } + } + + public void ValidateClientVersion() + { + // FIXME - add proxys support + + var hch = new HttpClientHandler(); + + using (var client = new HttpClient(hch)) + { + SetBaseAddress(client, _url); + Authorize(client); + + var data = new ValidateClientVersionData + { + ClientVersion = ApiVersion.Current.Version, + MinServerVersionSupportingClient = ApiVersion.Current.MinServerVersionSupportingClient, + }; + + var result = client.PostAsync(_url + ApiControllerUrl + nameof(ModelsBuilderApiController.ValidateClientVersion), + data, _formatter).Result; + + // this is not providing enough details in case of an error - do our own reporting + //result.EnsureSuccessStatusCode(); + EnsureSuccess(result); + } + } + + public IDictionary GetModels(Dictionary ourFiles, string modelsNamespace) + { + // FIXME - add proxys support + + var hch = new HttpClientHandler(); + + //hch.Proxy = new WebProxy("path.to.proxy", 8888); + //hch.UseProxy = true; + + using (var client = new HttpClient(hch)) + { + SetBaseAddress(client, _url); + Authorize(client); + + var data = new GetModelsData + { + Namespace = modelsNamespace, + ClientVersion = ApiVersion.Current.Version, + MinServerVersionSupportingClient = ApiVersion.Current.MinServerVersionSupportingClient, + Files = ourFiles + }; + + var result = client.PostAsync(_url + ApiControllerUrl + nameof(ModelsBuilderApiController.GetModels), + data, _formatter).Result; + + // this is not providing enough details in case of an error - do our own reporting + //result.EnsureSuccessStatusCode(); + EnsureSuccess(result); + + var genFiles = result.Content.ReadAsAsync>(_formatters).Result; + return genFiles; + } + } + + private static void EnsureSuccess(HttpResponseMessage result) + { + if (result.IsSuccessStatusCode) return; + + var text = result.Content.ReadAsStringAsync().Result; + throw new Exception($"Response status code does not indicate success ({result.StatusCode})\n{text}"); + } + + private void Authorize(HttpClient client) + { + AuthorizeBasic(client); + } + + // fixme - for the time being, we don't cache the token and we auth on each API call + // not used at the moment + /* + private void AuthorizeIdentity(HttpClient client) + { + var formData = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", "password"), + new KeyValuePair("userName", _user), + new KeyValuePair("password", _password), + }); + + var result = client.PostAsync(_url + UmbracoOAuthTokenUrl, formData).Result; + + EnsureSuccess(result); + + var token = result.Content.ReadAsAsync(_formatters).Result; + if (token.TokenType != "bearer") + throw new Exception($"Received invalid token type \"{token.TokenType}\", expected \"bearer\"."); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + } + */ + + private void AuthorizeBasic(HttpClient client) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(EncodeTokenElement(_user) + ':' + EncodeTokenElement(_password)))); + } + + public static string EncodeTokenElement(string s) + { + return s.Replace("%", "%a").Replace(":", "%b"); + } + + public static string DecodeTokenElement(string s) + { + return s.Replace("%b", ":").Replace("%a", "%"); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs b/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs new file mode 100644 index 0000000000..3d9f94bbd4 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text; +using Umbraco.ModelsBuilder.Building; +using Umbraco.ModelsBuilder.Umbraco; + +namespace Umbraco.ModelsBuilder.Api +{ + // internal to be used by Umbraco.ModelsBuilder.Api project + internal static class ApiHelper + { + public static Dictionary GetModels(string modelsNamespace, IDictionary files) + { + var umbraco = ModelsBuilderComponent.Umbraco; + var typeModels = umbraco.GetAllTypes(); + + var parseResult = new CodeParser().ParseWithReferencedAssemblies(files); + var builder = new TextBuilder(typeModels, parseResult, modelsNamespace); + + var models = new Dictionary(); + foreach (var typeModel in builder.GetModelsToGenerate()) + { + var sb = new StringBuilder(); + builder.Generate(sb, typeModel); + models[typeModel.ClrName] = sb.ToString(); + } + return models; + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs b/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs new file mode 100644 index 0000000000..2ee64b8c54 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs @@ -0,0 +1,88 @@ +using System; +using System.Reflection; + +namespace Umbraco.ModelsBuilder.Api +{ + /// + /// Manages API version handshake between client and server. + /// + public class ApiVersion + { + #region Configure + + // indicate the minimum version of the client API that is supported by this server's API. + // eg our Version = 4.8 but we support connections from VSIX down to version 3.2 + // => as a server, we accept connections from client down to version ... + private static readonly Version MinClientVersionSupportedByServerConst = new Version(3, 0, 0, 0); + + // indicate the minimum version of the server that can support the client API + // eg our Version = 4.8 and we know we're compatible with website server down to version 3.2 + // => as a client, we tell the server down to version ... that it should accept us + private static readonly Version MinServerVersionSupportingClientConst = new Version(3, 0, 0, 0); + + #endregion + + /// + /// Initializes a new instance of the class. + /// + /// The currently executing version. + /// The min client version supported by the server. + /// An opt min server version supporting the client. + internal ApiVersion(Version executingVersion, Version minClientVersionSupportedByServer, Version minServerVersionSupportingClient = null) + { + if (executingVersion == null) throw new ArgumentNullException(nameof(executingVersion)); + if (minClientVersionSupportedByServer == null) throw new ArgumentNullException(nameof(minClientVersionSupportedByServer)); + + Version = executingVersion; + MinClientVersionSupportedByServer = minClientVersionSupportedByServer; + MinServerVersionSupportingClient = minServerVersionSupportingClient; + } + + /// + /// Gets the currently executing API version. + /// + public static ApiVersion Current { get; } + = new ApiVersion(Assembly.GetExecutingAssembly().GetName().Version, + MinClientVersionSupportedByServerConst, MinServerVersionSupportingClientConst); + + /// + /// Gets the executing version of the API. + /// + public Version Version { get; } + + /// + /// Gets the min client version supported by the server. + /// + public Version MinClientVersionSupportedByServer { get; } + + /// + /// Gets the min server version supporting the client. + /// + public Version MinServerVersionSupportingClient { get; } + + /// + /// Gets a value indicating whether the API server is compatible with a client. + /// + /// The client version. + /// An opt min server version supporting the client. + /// + /// A client is compatible with a server if the client version is greater-or-equal _minClientVersionSupportedByServer + /// (ie client can be older than server, up to a point) AND the client version is lower-or-equal the server version + /// (ie client cannot be more recent than server) UNLESS the server . + /// + public bool IsCompatibleWith(Version clientVersion, Version minServerVersionSupportingClient = null) + { + // client cannot be older than server's min supported version + if (clientVersion < MinClientVersionSupportedByServer) + return false; + + // if we know about this client (client is older than server), it is supported + if (clientVersion <= Version) // if we know about this client (client older than server) + return true; + + // if we don't know about this client (client is newer than server), + // give server a chance to tell client it is, indeed, ok to support it + return minServerVersionSupportingClient != null && minServerVersionSupportingClient <= Version; + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs b/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs new file mode 100644 index 0000000000..9a5c55afc2 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Umbraco.ModelsBuilder.Api +{ + [DataContract] + public class GetModelsData : ValidateClientVersionData + { + [DataMember] + public string Namespace { get; set; } + + [DataMember] + public IDictionary Files { get; set; } + + public override bool IsValid => base.IsValid && Files != null; + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs b/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs new file mode 100644 index 0000000000..d74006c50f --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Building; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.Web.Mvc; +using Umbraco.Web.WebApi; +using Umbraco.Web.WebApi.Filters; +using Constants = Umbraco.Core.Constants; + +namespace Umbraco.ModelsBuilder.Api +{ + // read http://umbraco.com/follow-us/blog-archive/2014/1/17/heads-up,-breaking-change-coming-in-702-and-62.aspx + // read http://our.umbraco.org/forum/developers/api-questions/43025-Web-API-authentication + // UmbracoAuthorizedApiController :: /Umbraco/BackOffice/Zbu/ModelsBuilderApi/GetTypeModels + // UmbracoApiController :: /Umbraco/Zbu/ModelsBuilderApi/GetTypeModels ?? UNLESS marked with isbackoffice + // + // BEWARE! the controller url is hard-coded in ModelsBuilderApi and needs to be in sync! + + [PluginController(ControllerArea)] + [IsBackOffice] + //[UmbracoApplicationAuthorize(Constants.Applications.Developer)] // see ApiBasicAuthFilter - that one would be for ASP.NET identity + public class ModelsBuilderApiController : UmbracoApiController // UmbracoAuthorizedApiController - for ASP.NET identity + { + public const string ControllerArea = "ModelsBuilder"; + + // invoked by the API + [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! + [ApiBasicAuthFilter("developer")] // have to use our own, non-cookie-based, auth + public HttpResponseMessage ValidateClientVersion(ValidateClientVersionData data) + { + if (!UmbracoConfig.For.ModelsBuilder().ApiServer) + return Request.CreateResponse(HttpStatusCode.Forbidden, "API server does not want to talk to you."); + + if (!ModelState.IsValid || data == null || !data.IsValid) + return Request.CreateResponse(HttpStatusCode.BadRequest, "Invalid data."); + + var checkResult = CheckVersion(data.ClientVersion, data.MinServerVersionSupportingClient); + return (checkResult.Success + ? Request.CreateResponse(HttpStatusCode.OK, "OK", Configuration.Formatters.JsonFormatter) + : checkResult.Result); + } + + // invoked by the API + [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! + [ApiBasicAuthFilter("developer")] // have to use our own, non-cookie-based, auth + public HttpResponseMessage GetModels(GetModelsData data) + { + if (!UmbracoConfig.For.ModelsBuilder().ApiServer) + return Request.CreateResponse(HttpStatusCode.Forbidden, "API server does not want to talk to you."); + + if (!ModelState.IsValid || data == null || !data.IsValid) + return Request.CreateResponse(HttpStatusCode.BadRequest, "Invalid data."); + + var checkResult = CheckVersion(data.ClientVersion, data.MinServerVersionSupportingClient); + if (!checkResult.Success) + return checkResult.Result; + + var models = ApiHelper.GetModels(data.Namespace, data.Files); + + return Request.CreateResponse(HttpStatusCode.OK, models, Configuration.Formatters.JsonFormatter); + } + + private Attempt CheckVersion(Version clientVersion, Version minServerVersionSupportingClient) + { + if (clientVersion == null) + return Attempt.Fail(Request.CreateResponse(HttpStatusCode.Forbidden, + $"API version conflict: client version () is not compatible with server version({ApiVersion.Current.Version}).")); + + // minServerVersionSupportingClient can be null + var isOk = ApiVersion.Current.IsCompatibleWith(clientVersion, minServerVersionSupportingClient); + var response = isOk ? null : Request.CreateResponse(HttpStatusCode.Forbidden, + $"API version conflict: client version ({clientVersion}) is not compatible with server version({ApiVersion.Current.Version})."); + + return Attempt.If(isOk, response); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Api/TokenData.cs b/src/Umbraco.ModelsBuilder/Api/TokenData.cs new file mode 100644 index 0000000000..c34a6c75c5 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/TokenData.cs @@ -0,0 +1,17 @@ +using System.Runtime.Serialization; + +namespace Umbraco.ModelsBuilder.Api +{ + [DataContract] + class TokenData + { + [DataMember(Name = "access_token")] + public string AccessToken { get; set; } + + [DataMember(Name = "token_type")] + public string TokenType { get; set; } + + [DataMember(Name = "expires_in")] + public int ExpiresIn { get; set; } + } +} diff --git a/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs b/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs new file mode 100644 index 0000000000..39ef08d816 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs @@ -0,0 +1,57 @@ +using System; +using System.Runtime.Serialization; + +namespace Umbraco.ModelsBuilder.Api +{ + [DataContract] + public class ValidateClientVersionData + { + // issues 32, 34... problems when serializing versions + // + // make sure System.Version objects are transfered as strings + // depending on the JSON serializer version, it looks like versions are causing issues + // see + // http://stackoverflow.com/questions/13170386/why-system-version-in-json-string-does-not-deserialize-correctly + // + // if the class is marked with [DataContract] then only properties marked with [DataMember] + // are serialized and the rest is ignored, see + // http://www.asp.net/web-api/overview/formats-and-model-binding/json-and-xml-serialization + + [DataMember] + public string ClientVersionString + { + get { return VersionToString(ClientVersion); } + set { ClientVersion = ParseVersion(value, false, "client"); } + } + + [DataMember] + public string MinServerVersionSupportingClientString + { + get { return VersionToString(MinServerVersionSupportingClient); } + set { MinServerVersionSupportingClient = ParseVersion(value, true, "minServer"); } + } + + // not serialized + public Version ClientVersion { get; set; } + public Version MinServerVersionSupportingClient { get; set; } + + private static string VersionToString(Version version) + { + return version?.ToString() ?? "0.0.0.0"; + } + + private static Version ParseVersion(string value, bool canBeNull, string name) + { + if (string.IsNullOrWhiteSpace(value) && canBeNull) + return null; + + Version version; + if (Version.TryParse(value, out version)) + return version; + + throw new ArgumentException($"Failed to parse \"{value}\" as {name} version."); + } + + public virtual bool IsValid => ClientVersion != null; + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Building/Builder.cs b/src/Umbraco.ModelsBuilder/Building/Builder.cs new file mode 100644 index 0000000000..acfa402355 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/Builder.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Configuration; + +namespace Umbraco.ModelsBuilder.Building +{ + // NOTE + // The idea was to have different types of builder, because I wanted to experiment with + // building code with CodeDom. Turns out more complicated than I thought and maybe not + // worth it at the moment, to we're using TextBuilder and its Generate method is specific. + // + // Keeping the code as-is for the time being... + + /// + /// Provides a base class for all builders. + /// + internal abstract class Builder + { + private readonly IList _typeModels; + + protected Dictionary ModelsMap { get; } = new Dictionary(); + protected ParseResult ParseResult { get; } + + // the list of assemblies that will be 'using' by default + protected readonly IList TypesUsing = new List + { + "System", + "System.Collections.Generic", + "System.Linq.Expressions", + "System.Web", + "Umbraco.Core.Models", + "Umbraco.Core.Models.PublishedContent", + "Umbraco.Web", + "Umbraco.ModelsBuilder", + "Umbraco.ModelsBuilder.Umbraco", + }; + + /// + /// Gets or sets a value indicating the namespace to use for the models. + /// + /// May be overriden by code attributes. + public string ModelsNamespace { get; set; } + + /// + /// Gets the list of assemblies to add to the set of 'using' assemblies in each model file. + /// + public IList Using => TypesUsing; + + /// + /// Gets the list of models to generate. + /// + /// The models to generate, ie those that are not ignored. + public IEnumerable GetModelsToGenerate() + { + return _typeModels.Where(x => !x.IsContentIgnored); + } + + /// + /// Gets the list of all models. + /// + /// Includes those that are ignored. + internal IList TypeModels => _typeModels; + + /// + /// Initializes a new instance of the class with a list of models to generate + /// and the result of code parsing. + /// + /// The list of models to generate. + /// The result of code parsing. + protected Builder(IList typeModels, ParseResult parseResult) + { + _typeModels = typeModels ?? throw new ArgumentNullException(nameof(typeModels)); + ParseResult = parseResult ?? throw new ArgumentNullException(nameof(parseResult)); + + Prepare(); + } + + /// + /// Initializes a new instance of the class with a list of models to generate, + /// the result of code parsing, and a models namespace. + /// + /// The list of models to generate. + /// The result of code parsing. + /// The models namespace. + protected Builder(IList typeModels, ParseResult parseResult, string modelsNamespace) + : this(typeModels, parseResult) + { + // can be null or empty, we'll manage + ModelsNamespace = modelsNamespace; + } + + // for unit tests only + protected Builder() + { } + + /// + /// Prepares generation by processing the result of code parsing. + /// + /// + /// Preparation includes figuring out from the existing code which models or properties should + /// be ignored or renamed, etc. -- anything that comes from the attributes in the existing code. + /// + private void Prepare() + { + var pureLive = UmbracoConfig.For.ModelsBuilder().ModelsMode == ModelsMode.PureLive; + + // mark IsContentIgnored models that we discovered should be ignored + // then propagate / ignore children of ignored contents + // ignore content = don't generate a class for it, don't generate children + foreach (var typeModel in _typeModels.Where(x => ParseResult.IsIgnored(x.Alias))) + typeModel.IsContentIgnored = true; + foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored && x.EnumerateBaseTypes().Any(xx => xx.IsContentIgnored))) + typeModel.IsContentIgnored = true; + + // handle model renames + foreach (var typeModel in _typeModels.Where(x => ParseResult.IsContentRenamed(x.Alias))) + { + typeModel.ClrName = ParseResult.ContentClrName(typeModel.Alias); + typeModel.IsRenamed = true; + ModelsMap[typeModel.Alias] = typeModel.ClrName; + } + + // handle implement + foreach (var typeModel in _typeModels.Where(x => ParseResult.HasContentImplement(x.Alias))) + { + typeModel.HasImplement = true; + } + + // mark OmitBase models that we discovered already have a base class + foreach (var typeModel in _typeModels.Where(x => ParseResult.HasContentBase(ParseResult.ContentClrName(x.Alias) ?? x.ClrName))) + typeModel.HasBase = true; + + foreach (var typeModel in _typeModels) + { + // mark IsRemoved properties that we discovered should be ignored + // ie is marked as ignored on type, or on any parent type + var tm = typeModel; + foreach (var property in typeModel.Properties + .Where(property => tm.EnumerateBaseTypes(true).Any(x => ParseResult.IsPropertyIgnored(ParseResult.ContentClrName(x.Alias) ?? x.ClrName, property.Alias)))) + { + property.IsIgnored = true; + } + + // handle property renames + foreach (var property in typeModel.Properties) + property.ClrName = ParseResult.PropertyClrName(ParseResult.ContentClrName(typeModel.Alias) ?? typeModel.ClrName, property.Alias) ?? property.ClrName; + } + + // for the first two of these two tests, + // always throw, even in purelive: cannot happen unless ppl start fidling with attributes to rename + // things, and then they should pay attention to the generation error log - there's no magic here + // for the last one, don't throw in purelive, see comment + + // ensure we have no duplicates type names + foreach (var xx in _typeModels.Where(x => !x.IsContentIgnored).GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) + throw new InvalidOperationException($"Type name \"{xx.Key}\" is used" + + $" for types with alias {string.Join(", ", xx.Select(x => x.ItemType + ":\"" + x.Alias + "\""))}. Names have to be unique." + + " Consider using an attribute to assign different names to conflicting types."); + + // ensure we have no duplicates property names + foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored)) + foreach (var xx in typeModel.Properties.Where(x => !x.IsIgnored).GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) + throw new InvalidOperationException($"Property name \"{xx.Key}\" in type {typeModel.ItemType}:\"{typeModel.Alias}\"" + + $" is used for properties with alias {string.Join(", ", xx.Select(x => "\"" + x.Alias + "\""))}. Names have to be unique." + + " Consider using an attribute to assign different names to conflicting properties."); + + // ensure content & property type don't have identical name (csharp hates it) + foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored)) + { + foreach (var xx in typeModel.Properties.Where(x => !x.IsIgnored && x.ClrName == typeModel.ClrName)) + { + if (!pureLive) + throw new InvalidOperationException($"The model class for content type with alias \"{typeModel.Alias}\" is named \"{xx.ClrName}\"." + + $" CSharp does not support using the same name for the property with alias \"{xx.Alias}\"." + + " Consider using an attribute to assign a different name to the property."); + + // for purelive, will we generate a commented out properties with an error message, + // instead of throwing, because then it kills the sites and ppl don't understand why + xx.AddError($"The class {typeModel.ClrName} cannot implement this property, because" + + $" CSharp does not support naming the property with alias \"{xx.Alias}\" with the same name as content type with alias \"{typeModel.Alias}\"." + + " Consider using an attribute to assign a different name to the property."); + + // will not be implemented on interface nor class + // note: we will still create the static getter, and implement the property on other classes... + } + } + + // ensure we have no collision between base types + // NO: we may want to define a base class in a partial, on a model that has a parent + // we are NOT checking that the defined base type does maintain the inheritance chain + //foreach (var xx in _typeModels.Where(x => !x.IsContentIgnored).Where(x => x.BaseType != null && x.HasBase)) + // throw new InvalidOperationException(string.Format("Type alias \"{0}\" has more than one parent class.", + // xx.Alias)); + + // discover interfaces that need to be declared / implemented + foreach (var typeModel in _typeModels) + { + // collect all the (non-removed) types implemented at parent level + // ie the parent content types and the mixins content types, recursively + var parentImplems = new List(); + if (typeModel.BaseType != null && !typeModel.BaseType.IsContentIgnored) + TypeModel.CollectImplems(parentImplems, typeModel.BaseType); + + // interfaces we must declare we implement (initially empty) + // ie this type's mixins, except those that have been removed, + // and except those that are already declared at the parent level + // in other words, DeclaringInterfaces is "local mixins" + var declaring = typeModel.MixinTypes + .Where(x => !x.IsContentIgnored) + .Except(parentImplems); + typeModel.DeclaringInterfaces.AddRange(declaring); + + // interfaces we must actually implement (initially empty) + // if we declare we implement a mixin interface, we must actually implement + // its properties, all recursively (ie if the mixin interface implements...) + // so, starting with local mixins, we collect all the (non-removed) types above them + var mixinImplems = new List(); + foreach (var i in typeModel.DeclaringInterfaces) + TypeModel.CollectImplems(mixinImplems, i); + // and then we remove from that list anything that is already declared at the parent level + typeModel.ImplementingInterfaces.AddRange(mixinImplems.Except(parentImplems)); + } + + // register using types + foreach (var usingNamespace in ParseResult.UsingNamespaces) + { + if (!TypesUsing.Contains(usingNamespace)) + TypesUsing.Add(usingNamespace); + } + + // discover static mixin methods + foreach (var typeModel in _typeModels) + typeModel.StaticMixinMethods.AddRange(ParseResult.StaticMixinMethods(typeModel.ClrName)); + + // handle ctor + foreach (var typeModel in _typeModels.Where(x => ParseResult.HasCtor(x.ClrName))) + typeModel.HasCtor = true; + } + + private SemanticModel _ambiguousSymbolsModel; + private int _ambiguousSymbolsPos; + + // internal for tests + internal void PrepareAmbiguousSymbols() + { + var codeBuilder = new StringBuilder(); + foreach (var t in TypesUsing) + codeBuilder.AppendFormat("using {0};\n", t); + + codeBuilder.AppendFormat("namespace {0}\n{{ }}\n", GetModelsNamespace()); + + var compiler = new Compiler(); + SyntaxTree[] trees; + var compilation = compiler.GetCompilation("MyCompilation", new Dictionary { { "code", codeBuilder.ToString() } }, out trees); + var tree = trees[0]; + _ambiguousSymbolsModel = compilation.GetSemanticModel(tree); + + var namespaceSyntax = tree.GetRoot().DescendantNodes().OfType().First(); + //var namespaceSymbol = model.GetDeclaredSymbol(namespaceSyntax); + _ambiguousSymbolsPos = namespaceSyntax.OpenBraceToken.SpanStart; + } + + // looking for a simple symbol eg 'Umbraco' or 'String' + // expecting to match eg 'Umbraco' or 'System.String' + // returns true if either + // - more than 1 symbol is found (explicitely ambiguous) + // - 1 symbol is found BUT not matching (implicitely ambiguous) + protected bool IsAmbiguousSymbol(string symbol, string match) + { + if (_ambiguousSymbolsModel == null) + PrepareAmbiguousSymbols(); + if (_ambiguousSymbolsModel == null) + throw new Exception("Could not prepare ambiguous symbols."); + var symbols = _ambiguousSymbolsModel.LookupNamespacesAndTypes(_ambiguousSymbolsPos, null, symbol); + + if (symbols.Length > 1) return true; + if (symbols.Length == 0) return false; // what else? + + // only 1 - ensure it matches + var found = symbols[0].ToDisplayString(); + var pos = found.IndexOf('<'); // generic? + if (pos > 0) found = found.Substring(0, pos); // strip + return found != match; // and compare + } + + internal string ModelsNamespaceForTests; + + public string GetModelsNamespace() + { + if (ModelsNamespaceForTests != null) + return ModelsNamespaceForTests; + + // code attribute overrides everything + if (ParseResult.HasModelsNamespace) + return ParseResult.ModelsNamespace; + + // if builder was initialized with a namespace, use that one + if (!string.IsNullOrWhiteSpace(ModelsNamespace)) + return ModelsNamespace; + + // default + // fixme - should NOT reference config here, should make ModelsNamespace mandatory + return UmbracoConfig.For.ModelsBuilder().ModelsNamespace; + } + + protected string GetModelsBaseClassName(TypeModel type) + { + // code attribute overrides everything + if (ParseResult.HasModelsBaseClassName) + return ParseResult.ModelsBaseClassName; + + // default + return type.IsElement ? "PublishedElementModel" : "PublishedContentModel"; + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs b/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs new file mode 100644 index 0000000000..925337bd1e --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs @@ -0,0 +1,113 @@ +using System.CodeDom; +using System.Collections.Generic; + +namespace Umbraco.ModelsBuilder.Building +{ + // NOTE + // See nodes in Builder.cs class - that one does not work, is not complete, + // and was just some sort of experiment... + + /// + /// Implements a builder that works by using CodeDom + /// + internal class CodeDomBuilder : Builder + { + /// + /// Initializes a new instance of the class with a list of models to generate. + /// + /// The list of models to generate. + public CodeDomBuilder(IList typeModels) + : base(typeModels, null) + { } + + /// + /// Outputs a generated model to a code namespace. + /// + /// The code namespace. + /// The model to generate. + public void Generate(CodeNamespace ns, TypeModel typeModel) + { + // what about USING? + // what about references? + + if (typeModel.IsMixin) + { + var i = new CodeTypeDeclaration("I" + typeModel.ClrName) + { + IsInterface = true, + IsPartial = true, + Attributes = MemberAttributes.Public + }; + i.BaseTypes.Add(typeModel.BaseType == null ? "IPublishedContent" : "I" + typeModel.BaseType.ClrName); + + foreach (var mixinType in typeModel.DeclaringInterfaces) + i.BaseTypes.Add(mixinType.ClrName); + + i.Comments.Add(new CodeCommentStatement($"Mixin content Type {typeModel.Id} with alias \"{typeModel.Alias}\"")); + + foreach (var propertyModel in typeModel.Properties) + { + var p = new CodeMemberProperty + { + Name = propertyModel.ClrName, + Type = new CodeTypeReference(propertyModel.ModelClrType), + Attributes = MemberAttributes.Public, + HasGet = true, + HasSet = false + }; + i.Members.Add(p); + } + } + + var c = new CodeTypeDeclaration(typeModel.ClrName) + { + IsClass = true, + IsPartial = true, + Attributes = MemberAttributes.Public + }; + + c.BaseTypes.Add(typeModel.BaseType == null ? "PublishedContentModel" : typeModel.BaseType.ClrName); + + // if it's a missing it implements its own interface + if (typeModel.IsMixin) + c.BaseTypes.Add("I" + typeModel.ClrName); + + // write the mixins, if any, as interfaces + // only if not a mixin because otherwise the interface already has them + if (typeModel.IsMixin == false) + foreach (var mixinType in typeModel.DeclaringInterfaces) + c.BaseTypes.Add("I" + mixinType.ClrName); + + foreach (var mixin in typeModel.MixinTypes) + c.BaseTypes.Add("I" + mixin.ClrName); + + c.Comments.Add(new CodeCommentStatement($"Content Type {typeModel.Id} with alias \"{typeModel.Alias}\"")); + + foreach (var propertyModel in typeModel.Properties) + { + var p = new CodeMemberProperty + { + Name = propertyModel.ClrName, + Type = new CodeTypeReference(propertyModel.ModelClrType), + Attributes = MemberAttributes.Public, + HasGet = true, + HasSet = false + }; + p.GetStatements.Add(new CodeMethodReturnStatement( // return + new CodeMethodInvokeExpression( + new CodeMethodReferenceExpression( + new CodeThisReferenceExpression(), // this + "Value", // .Value + new[] // + { + new CodeTypeReference(propertyModel.ModelClrType) + }), + new CodeExpression[] // ("alias") + { + new CodePrimitiveExpression(propertyModel.Alias) + }))); + c.Members.Add(p); + } + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/CodeParser.cs b/src/Umbraco.ModelsBuilder/Building/CodeParser.cs new file mode 100644 index 0000000000..30fcbf1f91 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/CodeParser.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.ModelsBuilder.Building +{ + /// + /// Implements code parsing. + /// + /// Parses user's code and look for generator's instructions. + internal class CodeParser + { + /// + /// Parses a set of file. + /// + /// A set of (filename,content) representing content to parse. + /// The result of the code parsing. + /// The set of files is a dictionary of name, content. + public ParseResult Parse(IDictionary files) + { + return Parse(files, Enumerable.Empty()); + } + + /// + /// Parses a set of file. + /// + /// A set of (filename,content) representing content to parse. + /// Assemblies to reference in compilations. + /// The result of the code parsing. + /// The set of files is a dictionary of name, content. + public ParseResult Parse(IDictionary files, IEnumerable references) + { + SyntaxTree[] trees; + var compiler = new Compiler { References = references }; + var compilation = compiler.GetCompilation("Umbraco.ModelsBuilder.Generated", files, out trees); + + var disco = new ParseResult(); + foreach (var tree in trees) + Parse(disco, compilation, tree); + + return disco; + } + + public ParseResult ParseWithReferencedAssemblies(IDictionary files) + { + return Parse(files, ReferencedAssemblies.References); + } + + private static void Parse(ParseResult disco, CSharpCompilation compilation, SyntaxTree tree) + { + var model = compilation.GetSemanticModel(tree); + + //we quite probably have errors but that is normal + //var diags = model.GetDiagnostics(); + + var classDecls = tree.GetRoot().DescendantNodes().OfType(); + foreach (var classSymbol in classDecls.Select(x => model.GetDeclaredSymbol(x))) + { + ParseClassSymbols(disco, classSymbol); + + var baseClassSymbol = classSymbol.BaseType; + if (baseClassSymbol != null) + //disco.SetContentBaseClass(SymbolDisplay.ToDisplayString(classSymbol), SymbolDisplay.ToDisplayString(baseClassSymbol)); + disco.SetContentBaseClass(classSymbol.Name, baseClassSymbol.Name); + + var interfaceSymbols = classSymbol.Interfaces; + disco.SetContentInterfaces(classSymbol.Name, //SymbolDisplay.ToDisplayString(classSymbol), + interfaceSymbols.Select(x => x.Name)); //SymbolDisplay.ToDisplayString(x))); + + var hasCtor = classSymbol.Constructors + .Any(x => + { + if (x.IsStatic) return false; + if (x.Parameters.Length != 1) return false; + var type1 = x.Parameters[0].Type; + var type2 = typeof (IPublishedContent); + return type1.ToDisplayString() == type2.FullName; + }); + + if (hasCtor) + disco.SetHasCtor(classSymbol.Name); + + foreach (var propertySymbol in classSymbol.GetMembers().Where(x => x is IPropertySymbol)) + ParsePropertySymbols(disco, classSymbol, propertySymbol); + + foreach (var staticMethodSymbol in classSymbol.GetMembers().Where(x => x is IMethodSymbol)) + ParseMethodSymbol(disco, classSymbol, staticMethodSymbol); + } + + var interfaceDecls = tree.GetRoot().DescendantNodes().OfType(); + foreach (var interfaceSymbol in interfaceDecls.Select(x => model.GetDeclaredSymbol(x))) + { + ParseClassSymbols(disco, interfaceSymbol); + + var interfaceSymbols = interfaceSymbol.Interfaces; + disco.SetContentInterfaces(interfaceSymbol.Name, //SymbolDisplay.ToDisplayString(interfaceSymbol), + interfaceSymbols.Select(x => x.Name)); // SymbolDisplay.ToDisplayString(x))); + } + + ParseAssemblySymbols(disco, compilation.Assembly); + } + + private static void ParseClassSymbols(ParseResult disco, ISymbol symbol) + { + foreach (var attrData in symbol.GetAttributes()) + { + var attrClassSymbol = attrData.AttributeClass; + + // handle errors + if (attrClassSymbol is IErrorTypeSymbol) continue; + if (attrData.AttributeConstructor == null) continue; + + var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); + switch (attrClassName) + { + case "Umbraco.ModelsBuilder.IgnorePropertyTypeAttribute": + var propertyAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; + disco.SetIgnoredProperty(symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/, propertyAliasToIgnore); + break; + case "Umbraco.ModelsBuilder.RenamePropertyTypeAttribute": + var propertyAliasToRename = (string)attrData.ConstructorArguments[0].Value; + var propertyRenamed = (string)attrData.ConstructorArguments[1].Value; + disco.SetRenamedProperty(symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/, propertyAliasToRename, propertyRenamed); + break; + // that one causes all sorts of issues with references to Umbraco.Core in Roslyn + //case "Umbraco.Core.Models.PublishedContent.PublishedContentModelAttribute": + // var contentAliasToRename = (string)attrData.ConstructorArguments[0].Value; + // disco.SetRenamedContent(contentAliasToRename, symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/); + // break; + case "Umbraco.ModelsBuilder.ImplementContentTypeAttribute": + var contentAliasToRename = (string)attrData.ConstructorArguments[0].Value; + disco.SetRenamedContent(contentAliasToRename, symbol.Name, true /*SymbolDisplay.ToDisplayString(symbol)*/); + break; + } + } + } + + private static void ParsePropertySymbols(ParseResult disco, ISymbol classSymbol, ISymbol symbol) + { + foreach (var attrData in symbol.GetAttributes()) + { + var attrClassSymbol = attrData.AttributeClass; + + // handle errors + if (attrClassSymbol is IErrorTypeSymbol) continue; + if (attrData.AttributeConstructor == null) continue; + + var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); + // ReSharper disable once SwitchStatementMissingSomeCases + switch (attrClassName) + { + case "Umbraco.ModelsBuilder.ImplementPropertyTypeAttribute": + var propertyAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; + disco.SetIgnoredProperty(classSymbol.Name /*SymbolDisplay.ToDisplayString(classSymbol)*/, propertyAliasToIgnore); + break; + } + } + } + + private static void ParseAssemblySymbols(ParseResult disco, ISymbol symbol) + { + foreach (var attrData in symbol.GetAttributes()) + { + var attrClassSymbol = attrData.AttributeClass; + + // handle errors + if (attrClassSymbol is IErrorTypeSymbol) continue; + if (attrData.AttributeConstructor == null) continue; + + var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); + switch (attrClassName) + { + case "Umbraco.ModelsBuilder.IgnoreContentTypeAttribute": + var contentAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; + // see notes in IgnoreContentTypeAttribute + //var ignoreContent = (bool)attrData.ConstructorArguments[1].Value; + //var ignoreMixin = (bool)attrData.ConstructorArguments[1].Value; + //var ignoreMixinProperties = (bool)attrData.ConstructorArguments[1].Value; + disco.SetIgnoredContent(contentAliasToIgnore /*, ignoreContent, ignoreMixin, ignoreMixinProperties*/); + break; + + case "Umbraco.ModelsBuilder.RenameContentTypeAttribute": + var contentAliasToRename = (string) attrData.ConstructorArguments[0].Value; + var contentRenamed = (string)attrData.ConstructorArguments[1].Value; + disco.SetRenamedContent(contentAliasToRename, contentRenamed, false); + break; + + case "Umbraco.ModelsBuilder.ModelsBaseClassAttribute": + var modelsBaseClass = (INamedTypeSymbol) attrData.ConstructorArguments[0].Value; + if (modelsBaseClass is IErrorTypeSymbol) + throw new Exception($"Invalid base class type \"{modelsBaseClass.Name}\"."); + disco.SetModelsBaseClassName(SymbolDisplay.ToDisplayString(modelsBaseClass)); + break; + + case "Umbraco.ModelsBuilder.ModelsNamespaceAttribute": + var modelsNamespace= (string) attrData.ConstructorArguments[0].Value; + disco.SetModelsNamespace(modelsNamespace); + break; + + case "Umbraco.ModelsBuilder.ModelsUsingAttribute": + var usingNamespace = (string)attrData.ConstructorArguments[0].Value; + disco.SetUsingNamespace(usingNamespace); + break; + } + } + } + + private static void ParseMethodSymbol(ParseResult disco, ISymbol classSymbol, ISymbol symbol) + { + var methodSymbol = symbol as IMethodSymbol; + + if (methodSymbol == null + || !methodSymbol.IsStatic + || methodSymbol.IsGenericMethod + || methodSymbol.ReturnsVoid + || methodSymbol.IsExtensionMethod + || methodSymbol.Parameters.Length != 1) + return; + + var returnType = methodSymbol.ReturnType; + var paramSymbol = methodSymbol.Parameters[0]; + var paramType = paramSymbol.Type; + + // cannot do this because maybe the param type is ISomething and we don't have + // that type yet - will be generated - so cannot put any condition on it really + //const string iPublishedContent = "Umbraco.Core.Models.IPublishedContent"; + //var implements = paramType.AllInterfaces.Any(x => x.ToDisplayString() == iPublishedContent); + //if (!implements) + // return; + + disco.SetStaticMixinMethod(classSymbol.Name, methodSymbol.Name, returnType.Name, paramType.Name); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/Compiler.cs b/src/Umbraco.ModelsBuilder/Building/Compiler.cs new file mode 100644 index 0000000000..66064bef0b --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/Compiler.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Web; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Configuration; + +namespace Umbraco.ModelsBuilder.Building +{ + // main Roslyn compiler + internal class Compiler + { + private readonly LanguageVersion _languageVersion; + + public Compiler() + : this(UmbracoConfig.For.ModelsBuilder().LanguageVersion) + { } + + public Compiler(LanguageVersion languageVersion) + { + _languageVersion = languageVersion; + References = ReferencedAssemblies.References; + Debug = HttpContext.Current != null && HttpContext.Current.IsDebuggingEnabled; + } + + // gets or sets the references + public IEnumerable References { get; set; } + + public bool Debug { get; set; } + + // gets a compilation + public CSharpCompilation GetCompilation(string assemblyName, IDictionary files) + { + SyntaxTree[] trees; + return GetCompilation(assemblyName, files, out trees); + } + + // gets a compilation + // used by CodeParser to get a "compilation" of the existing files + public CSharpCompilation GetCompilation(string assemblyName, IDictionary files, out SyntaxTree[] trees) + { + var options = new CSharpParseOptions(_languageVersion); + trees = files.Select(x => + { + var text = x.Value; + var tree = CSharpSyntaxTree.ParseText(text, /*options:*/ options); + var diagnostic = tree.GetDiagnostics().FirstOrDefault(y => y.Severity == DiagnosticSeverity.Error); + if (diagnostic != null) + ThrowExceptionFromDiagnostic(x.Key, x.Value, diagnostic); + return tree; + }).ToArray(); + + var refs = References; + + var compilationOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default, + optimizationLevel: Debug ? OptimizationLevel.Debug : OptimizationLevel.Release + ); + var compilation = CSharpCompilation.Create( + assemblyName, + /*syntaxTrees:*/ trees, + /*references:*/ refs, + compilationOptions); + + return compilation; + } + + // compile files into a Dll + // used by ModelsBuilderBackOfficeController in [Live]Dll mode, to compile the models to disk + public void Compile(string assemblyName, IDictionary files, string binPath) + { + var assemblyPath = Path.Combine(binPath, assemblyName + ".dll"); + using (var stream = new FileStream(assemblyPath, FileMode.Create)) + { + Compile(assemblyName, files, stream); + } + + // this is how we'd create the pdb: + /* + var pdbPath = Path.Combine(binPath, assemblyName + ".pdb"); + + // create the compilation + var compilation = GetCompilation(assemblyName, files); + + // check diagnostics for errors (not warnings) + foreach (var diag in compilation.GetDiagnostics().Where(x => x.Severity == DiagnosticSeverity.Error)) + ThrowExceptionFromDiagnostic(files, diag); + + // emit + var result = compilation.Emit(assemblyPath, pdbPath); + if (result.Success) return; + + // deal with errors + var diagnostic = result.Diagnostics.First(x => x.Severity == DiagnosticSeverity.Error); + ThrowExceptionFromDiagnostic(files, diagnostic); + */ + } + + // compile files into an assembly + public Assembly Compile(string assemblyName, IDictionary files) + { + using (var stream = new MemoryStream()) + { + Compile(assemblyName, files, stream); + return Assembly.Load(stream.GetBuffer()); + } + } + + // compile one file into an assembly + public Assembly Compile(string assemblyName, string path, string code) + { + using (var stream = new MemoryStream()) + { + Compile(assemblyName, new Dictionary { { path, code } }, stream); + return Assembly.Load(stream.GetBuffer()); + } + } + + // compiles files into a stream + public void Compile(string assemblyName, IDictionary files, Stream stream) + { + // create the compilation + var compilation = GetCompilation(assemblyName, files); + + // check diagnostics for errors (not warnings) + foreach (var diag in compilation.GetDiagnostics().Where(x => x.Severity == DiagnosticSeverity.Error)) + ThrowExceptionFromDiagnostic(files, diag); + + // emit + var result = compilation.Emit(stream); + if (result.Success) return; + + // deal with errors + var diagnostic = result.Diagnostics.First(x => x.Severity == DiagnosticSeverity.Error); + ThrowExceptionFromDiagnostic(files, diagnostic); + } + + // compiles one file into a stream + public void Compile(string assemblyName, string path, string code, Stream stream) + { + Compile(assemblyName, new Dictionary { { path, code } }, stream); + } + + private static void ThrowExceptionFromDiagnostic(IDictionary files, Diagnostic diagnostic) + { + var message = diagnostic.GetMessage(); + if (diagnostic.Location == Location.None) + throw new CompilerException(message); + + var position = diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1; + var path = diagnostic.Location.SourceTree.FilePath; + var code = files.ContainsKey(path) ? files[path] : string.Empty; + throw new CompilerException(message, path, code, position); + } + + private static void ThrowExceptionFromDiagnostic(string path, string code, Diagnostic diagnostic) + { + var message = diagnostic.GetMessage(); + if (diagnostic.Location == Location.None) + throw new CompilerException(message); + + var position = diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1; + throw new CompilerException(message, path, code, position); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/CompilerException.cs b/src/Umbraco.ModelsBuilder/Building/CompilerException.cs new file mode 100644 index 0000000000..e978f67ae5 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/CompilerException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Umbraco.ModelsBuilder.Building +{ + public class CompilerException : Exception + { + public CompilerException(string message) + : base(message) + { } + + public CompilerException(string message, string path, string sourceCode, int line) + : base(message) + { + Path = path; + SourceCode = sourceCode; + Line = line; + } + + public string Path { get; } = string.Empty; + + public string SourceCode { get; } = string.Empty; + + public int Line { get; } = -1; + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/ParseResult.cs b/src/Umbraco.ModelsBuilder/Building/ParseResult.cs new file mode 100644 index 0000000000..d1f61363ff --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/ParseResult.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Umbraco.ModelsBuilder.Building +{ + /// + /// Contains the result of a code parsing. + /// + internal class ParseResult + { + // "alias" is the umbraco alias + // content "name" is the complete name eg Foo.Bar.Name + // property "name" is just the local name + + // see notes in IgnoreContentTypeAttribute + + private readonly HashSet _ignoredContent + = new HashSet(StringComparer.InvariantCultureIgnoreCase); + //private readonly HashSet _ignoredMixin + // = new HashSet(StringComparer.InvariantCultureIgnoreCase); + //private readonly HashSet _ignoredMixinProperties + // = new HashSet(StringComparer.InvariantCultureIgnoreCase); + private readonly Dictionary _renamedContent + = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + private readonly HashSet _withImplementContent + = new HashSet(StringComparer.InvariantCultureIgnoreCase); + private readonly Dictionary> _ignoredProperty + = new Dictionary>(); + private readonly Dictionary> _renamedProperty + = new Dictionary>(); + private readonly Dictionary _contentBase + = new Dictionary(); + private readonly Dictionary _contentInterfaces + = new Dictionary(); + private readonly List _usingNamespaces + = new List(); + private readonly Dictionary> _staticMixins + = new Dictionary>(); + private readonly HashSet _withCtor + = new HashSet(StringComparer.InvariantCultureIgnoreCase); + + public static readonly ParseResult Empty = new ParseResult(); + + private class StaticMixinMethodInfo + { + public StaticMixinMethodInfo(string contentName, string methodName, string returnType, string paramType) + { + ContentName = contentName; + MethodName = methodName; + //ReturnType = returnType; + //ParamType = paramType; + } + + // short name eg Type1 + public string ContentName { get; private set; } + + // short name eg GetProp1 + public string MethodName { get; private set; } + + // those types cannot be FQ because when parsing, some of them + // might not exist since we're generating them... and so prob. + // that info is worthless - not using it anyway at the moment... + + //public string ReturnType { get; private set; } + //public string ParamType { get; private set; } + } + + #region Declare + + // content with that alias should not be generated + // alias can end with a * (wildcard) + public void SetIgnoredContent(string contentAlias /*, bool ignoreContent, bool ignoreMixin, bool ignoreMixinProperties*/) + { + //if (ignoreContent) + _ignoredContent.Add(contentAlias); + //if (ignoreMixin) + // _ignoredMixin.Add(contentAlias); + //if (ignoreMixinProperties) + // _ignoredMixinProperties.Add(contentAlias); + } + + // content with that alias should be generated with a different name + public void SetRenamedContent(string contentAlias, string contentName, bool withImplement) + { + _renamedContent[contentAlias] = contentName; + if (withImplement) + _withImplementContent.Add(contentAlias); + } + + // property with that alias should not be generated + // applies to content name and any content that implements it + // here, contentName may be an interface + // alias can end with a * (wildcard) + public void SetIgnoredProperty(string contentName, string propertyAlias) + { + HashSet ignores; + if (!_ignoredProperty.TryGetValue(contentName, out ignores)) + ignores = _ignoredProperty[contentName] = new HashSet(StringComparer.InvariantCultureIgnoreCase); + ignores.Add(propertyAlias); + } + + // property with that alias should be generated with a different name + // applies to content name and any content that implements it + // here, contentName may be an interface + public void SetRenamedProperty(string contentName, string propertyAlias, string propertyName) + { + Dictionary renames; + if (!_renamedProperty.TryGetValue(contentName, out renames)) + renames = _renamedProperty[contentName] = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + renames[propertyAlias] = propertyName; + } + + // content with that name has a base class so no need to generate one + public void SetContentBaseClass(string contentName, string baseName) + { + if (baseName.ToLowerInvariant() != "object") + _contentBase[contentName] = baseName; + } + + // content with that name implements the interfaces + public void SetContentInterfaces(string contentName, IEnumerable interfaceNames) + { + _contentInterfaces[contentName] = interfaceNames.ToArray(); + } + + public void SetModelsBaseClassName(string modelsBaseClassName) + { + ModelsBaseClassName = modelsBaseClassName; + } + + public void SetModelsNamespace(string modelsNamespace) + { + ModelsNamespace = modelsNamespace; + } + + public void SetUsingNamespace(string usingNamespace) + { + _usingNamespaces.Add(usingNamespace); + } + + public void SetStaticMixinMethod(string contentName, string methodName, string returnType, string paramType) + { + if (!_staticMixins.ContainsKey(contentName)) + _staticMixins[contentName] = new List(); + + _staticMixins[contentName].Add(new StaticMixinMethodInfo(contentName, methodName, returnType, paramType)); + } + + public void SetHasCtor(string contentName) + { + _withCtor.Add(contentName); + } + + #endregion + + #region Query + + public bool IsIgnored(string contentAlias) + { + return IsContentOrMixinIgnored(contentAlias, _ignoredContent); + } + + //public bool IsMixinIgnored(string contentAlias) + //{ + // return IsContentOrMixinIgnored(contentAlias, _ignoredMixin); + //} + + //public bool IsMixinPropertiesIgnored(string contentAlias) + //{ + // return IsContentOrMixinIgnored(contentAlias, _ignoredMixinProperties); + //} + + private static bool IsContentOrMixinIgnored(string contentAlias, HashSet ignored) + { + if (ignored.Contains(contentAlias)) return true; + return ignored + .Where(x => x.EndsWith("*")) + .Select(x => x.Substring(0, x.Length - 1)) + .Any(x => contentAlias.StartsWith(x, StringComparison.InvariantCultureIgnoreCase)); + } + + public bool HasContentBase(string contentName) + { + return _contentBase.ContainsKey(contentName); + } + + public bool IsContentRenamed(string contentAlias) + { + return _renamedContent.ContainsKey(contentAlias); + } + + public bool HasContentImplement(string contentAlias) + { + return _withImplementContent.Contains(contentAlias); + } + + public string ContentClrName(string contentAlias) + { + string name; + return (_renamedContent.TryGetValue(contentAlias, out name)) ? name : null; + } + + public bool IsPropertyIgnored(string contentName, string propertyAlias) + { + HashSet ignores; + if (_ignoredProperty.TryGetValue(contentName, out ignores)) + { + if (ignores.Contains(propertyAlias)) return true; + if (ignores + .Where(x => x.EndsWith("*")) + .Select(x => x.Substring(0, x.Length - 1)) + .Any(x => propertyAlias.StartsWith(x, StringComparison.InvariantCultureIgnoreCase))) + return true; + } + string baseName; + if (_contentBase.TryGetValue(contentName, out baseName) + && IsPropertyIgnored(baseName, propertyAlias)) return true; + string[] interfaceNames; + if (_contentInterfaces.TryGetValue(contentName, out interfaceNames) + && interfaceNames.Any(interfaceName => IsPropertyIgnored(interfaceName, propertyAlias))) return true; + return false; + } + + public string PropertyClrName(string contentName, string propertyAlias) + { + Dictionary renames; + string name; + if (_renamedProperty.TryGetValue(contentName, out renames) + && renames.TryGetValue(propertyAlias, out name)) return name; + string baseName; + if (_contentBase.TryGetValue(contentName, out baseName) + && null != (name = PropertyClrName(baseName, propertyAlias))) return name; + string[] interfaceNames; + if (_contentInterfaces.TryGetValue(contentName, out interfaceNames) + && null != (name = interfaceNames + .Select(interfaceName => PropertyClrName(interfaceName, propertyAlias)) + .FirstOrDefault(x => x != null))) return name; + return null; + } + + public bool HasModelsBaseClassName + { + get { return !string.IsNullOrWhiteSpace(ModelsBaseClassName); } + } + + public string ModelsBaseClassName { get; private set; } + + public bool HasModelsNamespace + { + get { return !string.IsNullOrWhiteSpace(ModelsNamespace); } + } + + public string ModelsNamespace { get; private set; } + + public IEnumerable UsingNamespaces + { + get { return _usingNamespaces; } + } + + public IEnumerable StaticMixinMethods(string contentName) + { + return _staticMixins.ContainsKey(contentName) + ? _staticMixins[contentName].Select(x => x.MethodName) + : Enumerable.Empty() ; + } + + public bool HasCtor(string contentName) + { + return _withCtor.Contains(contentName); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Building/PropertyModel.cs b/src/Umbraco.ModelsBuilder/Building/PropertyModel.cs new file mode 100644 index 0000000000..1595b3f888 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/PropertyModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.ModelsBuilder.Building +{ + /// + /// Represents a model property. + /// + public class PropertyModel + { + /// + /// Gets the alias of the property. + /// + public string Alias; + + /// + /// Gets the name of the property. + /// + public string Name; + + /// + /// Gets the description of the property. + /// + public string Description; + + /// + /// Gets the clr name of the property. + /// + /// This is just the local name eg "Price". + public string ClrName; + + /// + /// Gets the Model Clr type of the property values. + /// + /// As indicated by the PublishedPropertyType, ie by the IPropertyValueConverter + /// if any, else object. May include some ModelType that will need to be mapped. + public Type ModelClrType; + + /// + /// Gets the CLR type name of the property values. + /// + public string ClrTypeName; + + /// + /// Gets a value indicating whether this property should be excluded from generation. + /// + public bool IsIgnored; + + /// + /// Gets the generation errors for the property. + /// + /// This should be null, unless something prevents the property from being + /// generated, and then the value should explain what. This can be used to generate + /// commented out code eg in PureLive. + public List Errors; + + /// + /// Adds an error. + /// + public void AddError(string error) + { + if (Errors == null) Errors = new List(); + Errors.Add(error); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.ModelsBuilder/Building/TextBuilder.cs new file mode 100644 index 0000000000..85ccd541b7 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/TextBuilder.cs @@ -0,0 +1,554 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.ModelsBuilder.Configuration; + +namespace Umbraco.ModelsBuilder.Building +{ + /// + /// Implements a builder that works by writing text. + /// + internal class TextBuilder : Builder + { + /// + /// Initializes a new instance of the class with a list of models to generate + /// and the result of code parsing. + /// + /// The list of models to generate. + /// The result of code parsing. + public TextBuilder(IList typeModels, ParseResult parseResult) + : base(typeModels, parseResult) + { } + + /// + /// Initializes a new instance of the class with a list of models to generate, + /// the result of code parsing, and a models namespace. + /// + /// The list of models to generate. + /// The result of code parsing. + /// The models namespace. + public TextBuilder(IList typeModels, ParseResult parseResult, string modelsNamespace) + : base(typeModels, parseResult, modelsNamespace) + { } + + // internal for unit tests only + internal TextBuilder() + { } + + /// + /// Outputs a generated model to a string builder. + /// + /// The string builder. + /// The model to generate. + public void Generate(StringBuilder sb, TypeModel typeModel) + { + WriteHeader(sb); + + foreach (var t in TypesUsing) + sb.AppendFormat("using {0};\n", t); + + sb.Append("\n"); + sb.AppendFormat("namespace {0}\n", GetModelsNamespace()); + sb.Append("{\n"); + + WriteContentType(sb, typeModel); + + sb.Append("}\n"); + } + + /// + /// Outputs generated models to a string builder. + /// + /// The string builder. + /// The models to generate. + public void Generate(StringBuilder sb, IEnumerable typeModels) + { + WriteHeader(sb); + + foreach (var t in TypesUsing) + sb.AppendFormat("using {0};\n", t); + + // assembly attributes marker + sb.Append("\n//ASSATTR\n"); + + sb.Append("\n"); + sb.AppendFormat("namespace {0}\n", GetModelsNamespace()); + sb.Append("{\n"); + + foreach (var typeModel in typeModels) + { + WriteContentType(sb, typeModel); + sb.Append("\n"); + } + + sb.Append("}\n"); + } + + /// + /// Outputs an "auto-generated" header to a string builder. + /// + /// The string builder. + public static void WriteHeader(StringBuilder sb) + { + TextHeaderWriter.WriteHeader(sb); + } + + private void WriteContentType(StringBuilder sb, TypeModel type) + { + string sep; + + if (type.IsMixin) + { + // write the interface declaration + sb.AppendFormat("\t// Mixin content Type {0} with alias \"{1}\"\n", type.Id, type.Alias); + if (!string.IsNullOrWhiteSpace(type.Name)) + sb.AppendFormat("\t/// {0}\n", XmlCommentString(type.Name)); + sb.AppendFormat("\tpublic partial interface I{0}", type.ClrName); + var implements = type.BaseType == null || type.BaseType.IsContentIgnored + ? (type.HasBase ? null : (type.IsElement ? "PublishedElement" : "PublishedContent")) + : type.BaseType.ClrName; + if (implements != null) + sb.AppendFormat(" : I{0}", implements); + + // write the mixins + sep = implements == null ? ":" : ","; + foreach (var mixinType in type.DeclaringInterfaces.OrderBy(x => x.ClrName)) + { + sb.AppendFormat("{0} I{1}", sep, mixinType.ClrName); + sep = ","; + } + + sb.Append("\n\t{\n"); + + // write the properties - only the local (non-ignored) ones, we're an interface + var more = false; + foreach (var prop in type.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + { + if (more) sb.Append("\n"); + more = true; + WriteInterfaceProperty(sb, prop); + } + + sb.Append("\t}\n\n"); + } + + // write the class declaration + if (type.IsRenamed) + sb.AppendFormat("\t// Content Type {0} with alias \"{1}\"\n", type.Id, type.Alias); + if (!string.IsNullOrWhiteSpace(type.Name)) + sb.AppendFormat("\t/// {0}\n", XmlCommentString(type.Name)); + // cannot do it now. see note in ImplementContentTypeAttribute + //if (!type.HasImplement) + // sb.AppendFormat("\t[ImplementContentType(\"{0}\")]\n", type.Alias); + sb.AppendFormat("\t[PublishedModel(\"{0}\")]\n", type.Alias); + sb.AppendFormat("\tpublic partial class {0}", type.ClrName); + var inherits = type.HasBase + ? null // has its own base already + : (type.BaseType == null || type.BaseType.IsContentIgnored + ? GetModelsBaseClassName(type) + : type.BaseType.ClrName); + if (inherits != null) + sb.AppendFormat(" : {0}", inherits); + + sep = inherits == null ? ":" : ","; + if (type.IsMixin) + { + // if it's a mixin it implements its own interface + sb.AppendFormat("{0} I{1}", sep, type.ClrName); + } + else + { + // write the mixins, if any, as interfaces + // only if not a mixin because otherwise the interface already has them already + foreach (var mixinType in type.DeclaringInterfaces.OrderBy(x => x.ClrName)) + { + sb.AppendFormat("{0} I{1}", sep, mixinType.ClrName); + sep = ","; + } + } + + // begin class body + sb.Append("\n\t{\n"); + + // write the constants & static methods + // as 'new' since parent has its own - or maybe not - disable warning + sb.Append("\t\t// helpers\n"); + sb.Append("#pragma warning disable 0109 // new is redundant\n"); + sb.AppendFormat("\t\tpublic new const string ModelTypeAlias = \"{0}\";\n", + type.Alias); + var itemType = type.IsElement ? TypeModel.ItemTypes.Content : type.ItemType; // fixme + sb.AppendFormat("\t\tpublic new const PublishedItemType ModelItemType = PublishedItemType.{0};\n", + itemType); + sb.Append("\t\tpublic new static PublishedContentType GetModelContentType()\n"); + sb.Append("\t\t\t=> PublishedModelUtility.GetModelContentType(ModelItemType, ModelTypeAlias);\n"); + sb.AppendFormat("\t\tpublic static PublishedPropertyType GetModelPropertyType(Expression> selector)\n", + type.ClrName); + sb.Append("\t\t\t=> PublishedModelUtility.GetModelPropertyType(GetModelContentType(), selector);\n"); + sb.Append("#pragma warning restore 0109\n\n"); + + // write the ctor + if (!type.HasCtor) + sb.AppendFormat("\t\t// ctor\n\t\tpublic {0}(IPublished{1} content)\n\t\t\t: base(content)\n\t\t{{ }}\n\n", + type.ClrName, type.IsElement ? "Element" : "Content"); + + // write the properties + sb.Append("\t\t// properties\n"); + WriteContentTypeProperties(sb, type); + + // close the class declaration + sb.Append("\t}\n"); + } + + private void WriteContentTypeProperties(StringBuilder sb, TypeModel type) + { + var staticMixinGetters = UmbracoConfig.For.ModelsBuilder().StaticMixinGetters; + + // write the properties + foreach (var prop in type.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + WriteProperty(sb, type, prop, staticMixinGetters && type.IsMixin ? type.ClrName : null); + + // no need to write the parent properties since we inherit from the parent + // and the parent defines its own properties. need to write the mixins properties + // since the mixins are only interfaces and we have to provide an implementation. + + // write the mixins properties + foreach (var mixinType in type.ImplementingInterfaces.OrderBy(x => x.ClrName)) + foreach (var prop in mixinType.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + if (staticMixinGetters) + WriteMixinProperty(sb, prop, mixinType.ClrName); + else + WriteProperty(sb, mixinType, prop); + } + + private void WriteMixinProperty(StringBuilder sb, PropertyModel property, string mixinClrName) + { + sb.Append("\n"); + + // Adds xml summary to each property containing + // property name and property description + if (!string.IsNullOrWhiteSpace(property.Name) || !string.IsNullOrWhiteSpace(property.Description)) + { + sb.Append("\t\t///\n"); + + if (!string.IsNullOrWhiteSpace(property.Description)) + sb.AppendFormat("\t\t/// {0}: {1}\n", XmlCommentString(property.Name), XmlCommentString(property.Description)); + else + sb.AppendFormat("\t\t/// {0}\n", XmlCommentString(property.Name)); + + sb.Append("\t\t///\n"); + } + + sb.AppendFormat("\t\t[ImplementPropertyType(\"{0}\")]\n", property.Alias); + + sb.Append("\t\tpublic "); + WriteClrType(sb, property.ClrTypeName); + + sb.AppendFormat(" {0} => ", + property.ClrName); + WriteNonGenericClrType(sb, GetModelsNamespace() + "." + mixinClrName); + sb.AppendFormat(".{0}(this);\n", + MixinStaticGetterName(property.ClrName)); + } + + private static string MixinStaticGetterName(string clrName) + { + return string.Format(UmbracoConfig.For.ModelsBuilder().StaticMixinGetterPattern, clrName); + } + + private void WriteProperty(StringBuilder sb, TypeModel type, PropertyModel property, string mixinClrName = null) + { + var mixinStatic = mixinClrName != null; + + sb.Append("\n"); + + if (property.Errors != null) + { + sb.Append("\t\t/*\n"); + sb.Append("\t\t * THIS PROPERTY CANNOT BE IMPLEMENTED, BECAUSE:\n"); + sb.Append("\t\t *\n"); + var first = true; + foreach (var error in property.Errors) + { + if (first) first = false; + else sb.Append("\t\t *\n"); + foreach (var s in SplitError(error)) + { + sb.Append("\t\t * "); + sb.Append(s); + sb.Append("\n"); + } + } + sb.Append("\t\t *\n"); + sb.Append("\n"); + } + + // Adds xml summary to each property containing + // property name and property description + if (!string.IsNullOrWhiteSpace(property.Name) || !string.IsNullOrWhiteSpace(property.Description)) + { + sb.Append("\t\t///\n"); + + if (!string.IsNullOrWhiteSpace(property.Description)) + sb.AppendFormat("\t\t/// {0}: {1}\n", XmlCommentString(property.Name), XmlCommentString(property.Description)); + else + sb.AppendFormat("\t\t/// {0}\n", XmlCommentString(property.Name)); + + sb.Append("\t\t///\n"); + } + + sb.AppendFormat("\t\t[ImplementPropertyType(\"{0}\")]\n", property.Alias); + + if (mixinStatic) + { + sb.Append("\t\tpublic "); + WriteClrType(sb, property.ClrTypeName); + sb.AppendFormat(" {0} => {1}(this);\n", + property.ClrName, MixinStaticGetterName(property.ClrName)); + } + else + { + sb.Append("\t\tpublic "); + WriteClrType(sb, property.ClrTypeName); + sb.AppendFormat(" {0} => this.Value", + property.ClrName); + if (property.ModelClrType != typeof(object)) + { + sb.Append("<"); + WriteClrType(sb, property.ClrTypeName); + sb.Append(">"); + } + sb.AppendFormat("(\"{0}\");\n", + property.Alias); + } + + if (property.Errors != null) + { + sb.Append("\n"); + sb.Append("\t\t *\n"); + sb.Append("\t\t */\n"); + } + + if (!mixinStatic) return; + + var mixinStaticGetterName = MixinStaticGetterName(property.ClrName); + + if (type.StaticMixinMethods.Contains(mixinStaticGetterName)) return; + + sb.Append("\n"); + + if (!string.IsNullOrWhiteSpace(property.Name)) + sb.AppendFormat("\t\t/// Static getter for {0}\n", XmlCommentString(property.Name)); + + sb.Append("\t\tpublic static "); + WriteClrType(sb, property.ClrTypeName); + sb.AppendFormat(" {0}(I{1} that) => that.Value", + mixinStaticGetterName, mixinClrName); + if (property.ModelClrType != typeof(object)) + { + sb.Append("<"); + WriteClrType(sb, property.ClrTypeName); + sb.Append(">"); + } + sb.AppendFormat("(\"{0}\");\n", + property.Alias); + } + + private static IEnumerable SplitError(string error) + { + var p = 0; + while (p < error.Length) + { + var n = p + 50; + while (n < error.Length && error[n] != ' ') n++; + if (n >= error.Length) break; + yield return error.Substring(p, n - p); + p = n + 1; + } + if (p < error.Length) + yield return error.Substring(p); + } + + private void WriteInterfaceProperty(StringBuilder sb, PropertyModel property) + { + if (property.Errors != null) + { + sb.Append("\t\t/*\n"); + sb.Append("\t\t * THIS PROPERTY CANNOT BE IMPLEMENTED, BECAUSE:\n"); + sb.Append("\t\t *\n"); + var first = true; + foreach (var error in property.Errors) + { + if (first) first = false; + else sb.Append("\t\t *\n"); + foreach (var s in SplitError(error)) + { + sb.Append("\t\t * "); + sb.Append(s); + sb.Append("\n"); + } + } + sb.Append("\t\t *\n"); + sb.Append("\n"); + } + + if (!string.IsNullOrWhiteSpace(property.Name)) + sb.AppendFormat("\t\t/// {0}\n", XmlCommentString(property.Name)); + sb.Append("\t\t"); + WriteClrType(sb, property.ClrTypeName); + sb.AppendFormat(" {0} {{ get; }}\n", + property.ClrName); + + if (property.Errors != null) + { + sb.Append("\n"); + sb.Append("\t\t *\n"); + sb.Append("\t\t */\n"); + } + } + + // internal for unit tests + internal void WriteClrType(StringBuilder sb, Type type) + { + var s = type.ToString(); + + if (type.IsGenericType) + { + var p = s.IndexOf('`'); + WriteNonGenericClrType(sb, s.Substring(0, p)); + sb.Append("<"); + var args = type.GetGenericArguments(); + for (var i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(", "); + WriteClrType(sb, args[i]); + } + sb.Append(">"); + } + else + { + WriteNonGenericClrType(sb, s); + } + } + + internal void WriteClrType(StringBuilder sb, string type) + { + var p = type.IndexOf('<'); + if (type.Contains('<')) + { + WriteNonGenericClrType(sb, type.Substring(0, p)); + sb.Append("<"); + var args = type.Substring(p + 1).TrimEnd('>').Split(','); // fixme will NOT work with nested generic types + for (var i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(", "); + WriteClrType(sb, args[i]); + } + sb.Append(">"); + } + else + { + WriteNonGenericClrType(sb, type); + } + } + + private void WriteNonGenericClrType(StringBuilder sb, string s) + { + // map model types + s = Regex.Replace(s, @"\{(.*)\}\[\*\]", m => ModelsMap[m.Groups[1].Value + "[]"]); + + // takes care eg of "System.Int32" vs. "int" + if (TypesMap.TryGetValue(s.ToLowerInvariant(), out string typeName)) + { + sb.Append(typeName); + return; + } + + // if full type name matches a using clause, strip + // so if we want Umbraco.Core.Models.IPublishedContent + // and using Umbraco.Core.Models, then we just need IPublishedContent + typeName = s; + string typeUsing = null; + var p = typeName.LastIndexOf('.'); + if (p > 0) + { + var x = typeName.Substring(0, p); + if (Using.Contains(x)) + { + typeName = typeName.Substring(p + 1); + typeUsing = x; + } + } + + // nested types *after* using + typeName = typeName.Replace("+", "."); + + // symbol to test is the first part of the name + // so if type name is Foo.Bar.Nil we want to ensure that Foo is not ambiguous + p = typeName.IndexOf('.'); + var symbol = p > 0 ? typeName.Substring(0, p) : typeName; + + // what we should find - WITHOUT any generic thing - just the type + // no 'using' = the exact symbol + // a 'using' = using.symbol + var match = typeUsing == null ? symbol : (typeUsing + "." + symbol); + + // if not ambiguous, be happy + if (!IsAmbiguousSymbol(symbol, match)) + { + sb.Append(typeName); + return; + } + + // symbol is ambiguous + // if no 'using', must prepend global:: + if (typeUsing == null) + { + sb.Append("global::"); + sb.Append(s.Replace("+", ".")); + return; + } + + // could fullname be non-ambiguous? + // note: all-or-nothing, not trying to segment the using clause + typeName = s.Replace("+", "."); + p = typeName.IndexOf('.'); + symbol = typeName.Substring(0, p); + match = symbol; + + // still ambiguous, must prepend global:: + if (IsAmbiguousSymbol(symbol, match)) + sb.Append("global::"); + + sb.Append(typeName); + } + + private static string XmlCommentString(string s) + { + return s.Replace('<', '{').Replace('>', '}').Replace('\r', ' ').Replace('\n', ' '); + } + + private static readonly IDictionary TypesMap = new Dictionary + { + { "system.int16", "short" }, + { "system.int32", "int" }, + { "system.int64", "long" }, + { "system.string", "string" }, + { "system.object", "object" }, + { "system.boolean", "bool" }, + { "system.void", "void" }, + { "system.char", "char" }, + { "system.byte", "byte" }, + { "system.uint16", "ushort" }, + { "system.uint32", "uint" }, + { "system.uint64", "ulong" }, + { "system.sbyte", "sbyte" }, + { "system.single", "float" }, + { "system.double", "double" }, + { "system.decimal", "decimal" } + }; + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs b/src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs new file mode 100644 index 0000000000..d165f03907 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs @@ -0,0 +1,26 @@ +using System.Text; +using Umbraco.ModelsBuilder.Api; + +namespace Umbraco.ModelsBuilder.Building +{ + public static class TextHeaderWriter + { + /// + /// Outputs an "auto-generated" header to a string builder. + /// + /// The string builder. + public static void WriteHeader(StringBuilder sb) + { + sb.Append("//------------------------------------------------------------------------------\n"); + sb.Append("// \n"); + sb.Append("// This code was generated by a tool.\n"); + sb.Append("//\n"); + sb.AppendFormat("// Umbraco.ModelsBuilder v{0}\n", ApiVersion.Current.Version); + sb.Append("//\n"); + sb.Append("// Changes to this file will be lost if the code is regenerated.\n"); + sb.Append("// \n"); + sb.Append("//------------------------------------------------------------------------------\n"); + sb.Append("\n"); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/TypeModel.cs b/src/Umbraco.ModelsBuilder/Building/TypeModel.cs new file mode 100644 index 0000000000..5ada8e881c --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Building/TypeModel.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Umbraco.ModelsBuilder.Building +{ + /// + /// Represents a model. + /// + public class TypeModel + { + /// + /// Gets the unique identifier of the corresponding content type. + /// + public int Id; + + /// + /// Gets the alias of the model. + /// + public string Alias; + + /// + /// Gets the name of the content type. + /// + public string Name; + + /// + /// Gets the description of the content type. + /// + public string Description; + + /// + /// Gets the clr name of the model. + /// + /// This is the complete name eg "Foo.Bar.MyContent". + public string ClrName; + + /// + /// Gets the unique identifier of the parent. + /// + /// The parent can either be a base content type, or a content types container. If the content + /// type does not have a base content type, then returns -1. + public int ParentId; + + /// + /// Gets the base model. + /// + /// + /// If the content type does not have a base content type, then returns null. + /// The current model inherits from its base model. + /// + public TypeModel BaseType; // the parent type in Umbraco (type inherits its properties) + + /// + /// Gets the list of properties that are defined by this model. + /// + /// These are only those property that are defined locally by this model, + /// and the list does not contain properties inherited from base models or from mixins. + public readonly List Properties = new List(); + + /// + /// Gets the mixin models. + /// + /// The current model implements mixins. + public readonly List MixinTypes = new List(); + + /// + /// Gets the list of interfaces that this model needs to declare it implements. + /// + /// Some of these interfaces may actually be implemented by a base model + /// that this model inherits from. + public readonly List DeclaringInterfaces = new List(); + + /// + /// Gets the list of interfaces that this model needs to actually implement. + /// + public readonly List ImplementingInterfaces = new List(); + + /// + /// Gets the list of existing static mixin method candidates. + /// + public readonly List StaticMixinMethods = new List(); + + /// + /// Gets a value indicating whether this model has a base class. + /// + /// Can be either because the content type has a base content type declared in Umbraco, + /// or because the existing user's code declares a base class for this model. + public bool HasBase; + + /// + /// Gets a value indicating whether this model has been renamed. + /// + public bool IsRenamed; + + /// + /// Gets a value indicating whether this model has [ImplementContentType] already. + /// + public bool HasImplement; + + /// + /// Gets a value indicating whether this model is used as a mixin by another model. + /// + public bool IsMixin; + + /// + /// Gets a value indicating whether this model is the base model of another model. + /// + public bool IsParent; + + /// + /// Gets a value indicating whether this model should be excluded from generation. + /// + public bool IsContentIgnored; + + /// + /// Gets a value indicating whether the ctor is already defined in a partial. + /// + public bool HasCtor; + + /// + /// Gets a value indicating whether the type is an element. + /// + public bool IsElement => ItemType == ItemTypes.Element; + + /// + /// Represents the different model item types. + /// + public enum ItemTypes + { + /// + /// Element. + /// + Element, + + /// + /// Content. + /// + Content, + + /// + /// Media. + /// + Media, + + /// + /// Member. + /// + Member + } + + private ItemTypes _itemType; + + /// + /// Gets or sets the model item type. + /// + public ItemTypes ItemType + { + get { return _itemType; } + set + { + switch (value) + { + case ItemTypes.Element: + case ItemTypes.Content: + case ItemTypes.Media: + case ItemTypes.Member: + _itemType = value; + break; + default: + throw new ArgumentException("value"); + } + } + } + + /// + /// Recursively collects all types inherited, or implemented as interfaces, by a specified type. + /// + /// The collection. + /// The type. + /// Includes the specified type. + internal static void CollectImplems(ICollection types, TypeModel type) + { + if (!type.IsContentIgnored && types.Contains(type) == false) + types.Add(type); + if (type.BaseType != null && !type.BaseType.IsContentIgnored) + CollectImplems(types, type.BaseType); + foreach (var mixin in type.MixinTypes.Where(x => !x.IsContentIgnored)) + CollectImplems(types, mixin); + } + + /// + /// Enumerates the base models starting from the current model up. + /// + /// Indicates whether the enumeration should start with the current model + /// or from its base model. + /// The base models. + public IEnumerable EnumerateBaseTypes(bool andSelf = false) + { + var typeModel = andSelf ? this : BaseType; + while (typeModel != null) + { + yield return typeModel; + typeModel = typeModel.BaseType; + } + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs b/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs new file mode 100644 index 0000000000..d195846411 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs @@ -0,0 +1,28 @@ +namespace Umbraco.ModelsBuilder.Configuration +{ + /// + /// Defines the CLR name sources. + /// + public enum ClrNameSource + { + /// + /// No source. + /// + Nothing = 0, + + /// + /// Use the name as source. + /// + Name, + + /// + /// Use the alias as source. + /// + Alias, + + /// + /// Use the alias directly. + /// + RawAlias + } +} diff --git a/src/Umbraco.ModelsBuilder/Configuration/Config.cs b/src/Umbraco.ModelsBuilder/Configuration/Config.cs new file mode 100644 index 0000000000..8b61de732b --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Configuration/Config.cs @@ -0,0 +1,368 @@ +using System; +using System.Configuration; +using System.IO; +using System.Reflection; +using System.Web.Configuration; +using System.Web.Hosting; +using Microsoft.CodeAnalysis.CSharp; +using Umbraco.Core; + +namespace Umbraco.ModelsBuilder.Configuration +{ + /// + /// Represents the models builder configuration. + /// + public class Config + { + private static Config _value; + + /// + /// Gets the configuration - internal so that the UmbracoConfig extension + /// can get the value to initialize its own value. Either a value has + /// been provided via the Setup method, or a new instance is created, which + /// will load settings from the config file. + /// + internal static Config Value => _value ?? new Config(); + + /// + /// Sets the configuration programmatically. + /// + /// The configuration. + /// + /// Once the configuration has been accessed via the UmbracoConfig extension, + /// it cannot be changed anymore, and using this method will achieve nothing. + /// For tests, see UmbracoConfigExtensions.ResetConfig(). + /// + public static void Setup(Config config) + { + _value = config; + } + + internal const string DefaultStaticMixinGetterPattern = "Get{0}"; + internal const LanguageVersion DefaultLanguageVersion = LanguageVersion.CSharp6; + internal const string DefaultModelsNamespace = "Umbraco.Web.PublishedModels"; + internal const ClrNameSource DefaultClrNameSource = ClrNameSource.Alias; // for legacy reasons + internal const string DefaultModelsDirectory = "~/App_Data/Models"; + + /// + /// Initializes a new instance of the class. + /// + private Config() + { + const string prefix = "Umbraco.ModelsBuilder."; + + // giant kill switch, default: false + // must be explicitely set to true for anything else to happen + Enable = ConfigurationManager.AppSettings[prefix + "Enable"] == "true"; + + // ensure defaults are initialized for tests + StaticMixinGetterPattern = DefaultStaticMixinGetterPattern; + LanguageVersion = DefaultLanguageVersion; + ModelsNamespace = DefaultModelsNamespace; + ClrNameSource = DefaultClrNameSource; + ModelsDirectory = HostingEnvironment.IsHosted + ? HostingEnvironment.MapPath(DefaultModelsDirectory) + : DefaultModelsDirectory.TrimStart("~/"); + DebugLevel = 0; + + // stop here, everything is false + if (!Enable) return; + + // mode + var modelsMode = ConfigurationManager.AppSettings[prefix + "ModelsMode"]; + if (!string.IsNullOrWhiteSpace(modelsMode)) + { + switch (modelsMode) + { + case nameof(ModelsMode.Nothing): + ModelsMode = ModelsMode.Nothing; + break; + case nameof(ModelsMode.PureLive): + ModelsMode = ModelsMode.PureLive; + break; + case nameof(ModelsMode.Dll): + ModelsMode = ModelsMode.Dll; + break; + case nameof(ModelsMode.LiveDll): + ModelsMode = ModelsMode.LiveDll; + break; + case nameof(ModelsMode.AppData): + ModelsMode = ModelsMode.AppData; + break; + case nameof(ModelsMode.LiveAppData): + ModelsMode = ModelsMode.LiveAppData; + break; + default: + throw new ConfigurationErrorsException($"ModelsMode \"{modelsMode}\" is not a valid mode." + + " Note that modes are case-sensitive."); + } + } + + // default: false + EnableApi = ConfigurationManager.AppSettings[prefix + "EnableApi"].InvariantEquals("true"); + AcceptUnsafeModelsDirectory = ConfigurationManager.AppSettings[prefix + "AcceptUnsafeModelsDirectory"].InvariantEquals("true"); + + // default: true + EnableFactory = !ConfigurationManager.AppSettings[prefix + "EnableFactory"].InvariantEquals("false"); + StaticMixinGetters = !ConfigurationManager.AppSettings[prefix + "StaticMixinGetters"].InvariantEquals("false"); + FlagOutOfDateModels = !ConfigurationManager.AppSettings[prefix + "FlagOutOfDateModels"].InvariantEquals("false"); + + // default: initialized above with DefaultModelsNamespace const + var value = ConfigurationManager.AppSettings[prefix + "ModelsNamespace"]; + if (!string.IsNullOrWhiteSpace(value)) + ModelsNamespace = value; + + // default: initialized above with DefaultStaticMixinGetterPattern const + value = ConfigurationManager.AppSettings[prefix + "StaticMixinGetterPattern"]; + if (!string.IsNullOrWhiteSpace(value)) + StaticMixinGetterPattern = value; + + // default: initialized above with DefaultLanguageVersion const + value = ConfigurationManager.AppSettings[prefix + "LanguageVersion"]; + if (!string.IsNullOrWhiteSpace(value)) + { + LanguageVersion lv; + if (!Enum.TryParse(value, true, out lv)) + throw new ConfigurationErrorsException($"Invalid language version \"{value}\"."); + LanguageVersion = lv; + } + + // default: initialized above with DefaultClrNameSource const + value = ConfigurationManager.AppSettings[prefix + "ClrNameSource"]; + if (!string.IsNullOrWhiteSpace(value)) + { + switch (value) + { + case nameof(ClrNameSource.Nothing): + ClrNameSource = ClrNameSource.Nothing; + break; + case nameof(ClrNameSource.Alias): + ClrNameSource = ClrNameSource.Alias; + break; + case nameof(ClrNameSource.RawAlias): + ClrNameSource = ClrNameSource.RawAlias; + break; + case nameof(ClrNameSource.Name): + ClrNameSource = ClrNameSource.Name; + break; + default: + throw new ConfigurationErrorsException($"ClrNameSource \"{value}\" is not a valid source." + + " Note that sources are case-sensitive."); + } + } + + // default: initialized above with DefaultModelsDirectory const + value = ConfigurationManager.AppSettings[prefix + "ModelsDirectory"]; + if (!string.IsNullOrWhiteSpace(value)) + { + var root = HostingEnvironment.IsHosted + ? HostingEnvironment.MapPath("~/") + : Directory.GetCurrentDirectory(); + if (root == null) + throw new ConfigurationErrorsException("Could not determine root directory."); + + // GetModelsDirectory will ensure that the path is safe + ModelsDirectory = GetModelsDirectory(root, value, AcceptUnsafeModelsDirectory); + } + + // default: 0 + value = ConfigurationManager.AppSettings[prefix + "DebugLevel"]; + if (!string.IsNullOrWhiteSpace(value)) + { + int debugLevel; + if (!int.TryParse(value, out debugLevel)) + throw new ConfigurationErrorsException($"Invalid debug level \"{value}\"."); + DebugLevel = debugLevel; + } + + // not flagging if not generating, or live (incl. pure) + if (ModelsMode == ModelsMode.Nothing || ModelsMode.IsLive()) + FlagOutOfDateModels = false; + } + + /// + /// Initializes a new instance of the class. + /// + public Config( + bool enable = false, + ModelsMode modelsMode = ModelsMode.Nothing, + bool enableApi = true, + string modelsNamespace = null, + bool enableFactory = true, + LanguageVersion languageVersion = DefaultLanguageVersion, + bool staticMixinGetters = true, + string staticMixinGetterPattern = null, + bool flagOutOfDateModels = true, + ClrNameSource clrNameSource = DefaultClrNameSource, + string modelsDirectory = null, + bool acceptUnsafeModelsDirectory = false, + int debugLevel = 0) + { + Enable = enable; + ModelsMode = modelsMode; + + EnableApi = enableApi; + ModelsNamespace = string.IsNullOrWhiteSpace(modelsNamespace) ? DefaultModelsNamespace : modelsNamespace; + EnableFactory = enableFactory; + LanguageVersion = languageVersion; + StaticMixinGetters = staticMixinGetters; + StaticMixinGetterPattern = string.IsNullOrWhiteSpace(staticMixinGetterPattern) ? DefaultStaticMixinGetterPattern : staticMixinGetterPattern; + FlagOutOfDateModels = flagOutOfDateModels; + ClrNameSource = clrNameSource; + ModelsDirectory = string.IsNullOrWhiteSpace(modelsDirectory) ? DefaultModelsDirectory : modelsDirectory; + AcceptUnsafeModelsDirectory = acceptUnsafeModelsDirectory; + DebugLevel = debugLevel; + } + + // internal for tests + internal static string GetModelsDirectory(string root, string config, bool acceptUnsafe) + { + // making sure it is safe, ie under the website root, + // unless AcceptUnsafeModelsDirectory and then everything is OK. + + if (!Path.IsPathRooted(root)) + throw new ConfigurationErrorsException($"Root is not rooted \"{root}\"."); + + if (config.StartsWith("~/")) + { + var dir = Path.Combine(root, config.TrimStart("~/")); + + // sanitize - GetFullPath will take care of any relative + // segments in path, eg '../../foo.tmp' - it may throw a SecurityException + // if the combined path reaches illegal parts of the filesystem + dir = Path.GetFullPath(dir); + root = Path.GetFullPath(root); + + if (!dir.StartsWith(root) && !acceptUnsafe) + throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); + + return dir; + } + + if (acceptUnsafe) + return Path.GetFullPath(config); + + throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); + } + + /// + /// Gets a value indicating whether the whole models experience is enabled. + /// + /// + /// If this is false then absolutely nothing happens. + /// Default value is false which means that unless we have this setting, nothing happens. + /// + public bool Enable { get; } + + /// + /// Gets the models mode. + /// + public ModelsMode ModelsMode { get; } + + /// + /// Gets a value indicating whether to serve the API. + /// + public bool ApiServer => EnableApi && ApiInstalled && IsDebug; + + /// + /// Gets a value indicating whether to enable the API. + /// + /// + /// Default value is true. + /// The API is used by the Visual Studio extension and the console tool to talk to Umbraco + /// and retrieve the content types. It needs to be enabled so the extension & tool can work. + /// + public bool EnableApi { get; } + + /// + /// Gets a value indicating whether the API is installed. + /// + public bool ApiInstalled => _apiInstalled.Value; + + private readonly Lazy _apiInstalled = new Lazy(() => + { + try + { + return Assembly.Load("Umbraco.ModelsBuilder.Api") != null; + } + catch (FileNotFoundException) + { + return false; + } + }); + + /// + /// Gets a value indicating whether system.web/compilation/@debug is true. + /// + public bool IsDebug + { + get + { + var section = (CompilationSection) ConfigurationManager.GetSection("system.web/compilation"); + return section != null && section.Debug; + } + } + + /// + /// Gets the models namespace. + /// + /// That value could be overriden by other (attribute in user's code...). Return default if no value was supplied. + public string ModelsNamespace { get; } + + /// + /// Gets a value indicating whether we should enable the models factory. + /// + /// Default value is true because no factory is enabled by default in Umbraco. + public bool EnableFactory { get; } + + /// + /// Gets the Roslyn parser language version. + /// + /// Default value is CSharp6. + public LanguageVersion LanguageVersion { get; } + + /// + /// Gets a value indicating whether to generate static mixin getters. + /// + /// Default value is false for backward compat reaons. + public bool StaticMixinGetters { get; } + + /// + /// Gets the string pattern for mixin properties static getter name. + /// + /// Default value is "GetXxx". Standard string format. + public string StaticMixinGetterPattern { get; } + + /// + /// Gets a value indicating whether we should flag out-of-date models. + /// + /// Models become out-of-date when data types or content types are updated. When this + /// setting is activated the ~/App_Data/Models/ood.txt file is then created. When models are + /// generated through the dashboard, the files is cleared. Default value is false. + public bool FlagOutOfDateModels { get; } + + /// + /// Gets the CLR name source. + /// + public ClrNameSource ClrNameSource { get; } + + /// + /// Gets the models directory. + /// + /// Default is ~/App_Data/Models but that can be changed. + public string ModelsDirectory { get; } + + /// + /// Gets a value indicating whether to accept an unsafe value for ModelsDirectory. + /// + /// An unsafe value is an absolute path, or a relative path pointing outside + /// of the website root. + public bool AcceptUnsafeModelsDirectory { get; } + + /// + /// Gets a value indicating the debug log level. + /// + /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). + public int DebugLevel { get; } + } +} diff --git a/src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs b/src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs new file mode 100644 index 0000000000..e04c4dee90 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs @@ -0,0 +1,52 @@ +namespace Umbraco.ModelsBuilder.Configuration +{ + /// + /// Defines the models generation modes. + /// + public enum ModelsMode + { + /// + /// Do not generate models. + /// + Nothing = 0, // default value + + /// + /// Generate models in memory. + /// When: a content type change occurs. + /// + /// The app does not restart. Models are available in views exclusively. + PureLive, + + /// + /// Generate models in AppData. + /// When: generation is triggered. + /// + /// Generation can be triggered from the dashboard. The app does not restart. + /// Models are not compiled and thus are not available to the project. + AppData, + + /// + /// Generate models in AppData. + /// When: a content type change occurs, or generation is triggered. + /// + /// Generation can be triggered from the dashboard. The app does not restart. + /// Models are not compiled and thus are not available to the project. + LiveAppData, + + /// + /// Generates models in AppData and compiles them into a Dll into ~/bin (the app restarts). + /// When: generation is triggered. + /// + /// Generation can be triggered from the dashboard. The app does restart. Models + /// are available to the entire project. + Dll, + + /// + /// Generates models in AppData and compiles them into a Dll into ~/bin (the app restarts). + /// When: a content type change occurs, or generation is triggered. + /// + /// Generation can be triggered from the dashboard. The app does restart. Models + /// are available to the entire project. + LiveDll + } +} diff --git a/src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs b/src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs new file mode 100644 index 0000000000..be609c0548 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs @@ -0,0 +1,51 @@ +namespace Umbraco.ModelsBuilder.Configuration +{ + /// + /// Provides extensions for the enumeration. + /// + public static class ModelsModeExtensions + { + /// + /// Gets a value indicating whether the mode is LiveAnything or PureLive. + /// + public static bool IsLive(this ModelsMode modelsMode) + { + return + modelsMode == ModelsMode.PureLive + || modelsMode == ModelsMode.LiveDll + || modelsMode == ModelsMode.LiveAppData; + } + + /// + /// Gets a value indicating whether the mode is LiveAnything but not PureLive. + /// + public static bool IsLiveNotPure(this ModelsMode modelsMode) + { + return + modelsMode == ModelsMode.LiveDll + || modelsMode == ModelsMode.LiveAppData; + } + + /// + /// Gets a value indicating whether the mode is [Live]Dll. + /// + public static bool IsAnyDll(this ModelsMode modelsMode) + { + return + modelsMode == ModelsMode.Dll + || modelsMode == ModelsMode.LiveDll; + } + + /// + /// Gets a value indicating whether the mode supports explicit generation (as opposed to pure live). + /// + public static bool SupportsExplicitGeneration(this ModelsMode modelsMode) + { + return + modelsMode == ModelsMode.Dll + || modelsMode == ModelsMode.LiveDll + || modelsMode == ModelsMode.AppData + || modelsMode == ModelsMode.LiveAppData; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs b/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs new file mode 100644 index 0000000000..acc587e779 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs @@ -0,0 +1,34 @@ +using System.Threading; +using Umbraco.Core.Configuration; + +namespace Umbraco.ModelsBuilder.Configuration +{ + /// + /// Provides extension methods for the class. + /// + public static class UmbracoConfigExtensions + { + private static Config _config; + + /// + /// Gets the models builder configuration. + /// + /// The umbraco configuration. + /// The models builder configuration. + /// Getting the models builder configuration freezes its state, + /// and any attempt at modifying the configuration using the Setup method + /// will be ignored. + public static Config ModelsBuilder(this UmbracoConfig umbracoConfig) + { + // capture the current Config2.Default value, cannot change anymore + LazyInitializer.EnsureInitialized(ref _config, () => Config.Value); + return _config; + } + + // internal for tests + internal static void ResetConfig() + { + _config = null; + } + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs b/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs new file mode 100644 index 0000000000..9e5741805e --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs @@ -0,0 +1,91 @@ +using System; +using System.Text; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Umbraco; + +namespace Umbraco.ModelsBuilder.Dashboard +{ + internal static class BuilderDashboardHelper + { + public static bool CanGenerate() + { + return UmbracoConfig.For.ModelsBuilder().ModelsMode.SupportsExplicitGeneration(); + } + + public static bool GenerateCausesRestart() + { + return UmbracoConfig.For.ModelsBuilder().ModelsMode.IsAnyDll(); + } + + public static bool AreModelsOutOfDate() + { + return OutOfDateModelsStatus.IsOutOfDate; + } + + public static string LastError() + { + return ModelsGenerationError.GetLastError(); + } + + public static string Text() + { + var config = UmbracoConfig.For.ModelsBuilder(); + if (!config.Enable) + return "ModelsBuilder is disabled
(the .Enable key is missing, or its value is not 'true')."; + + var sb = new StringBuilder(); + + sb.Append("ModelsBuilder is enabled, with the following configuration:"); + + sb.Append("
    "); + + sb.Append("
  • The models factory is "); + sb.Append(config.EnableFactory || config.ModelsMode == ModelsMode.PureLive + ? "enabled" + : "not enabled. Umbraco will not use models"); + sb.Append(".
  • "); + + sb.Append("
  • The API is "); + if (config.ApiInstalled && config.EnableApi) + { + sb.Append("installed and enabled"); + if (!config.IsDebug) sb.Append(".
    However, the API runs only with debug compilation mode"); + } + else if (config.ApiInstalled || config.EnableApi) + sb.Append(config.ApiInstalled ? "installed but not enabled" : "enabled but not installed"); + else sb.Append("neither installed nor enabled"); + sb.Append(".
    "); + if (!config.ApiServer) + sb.Append("External tools such as Visual Studio cannot use the API"); + else + sb.Append("The API endpoint is open on this server"); + sb.Append(".
  • "); + + sb.Append(config.ModelsMode != ModelsMode.Nothing + ? $"
  • {config.ModelsMode} models are enabled.
  • " + : "
  • No models mode is specified: models will not be generated.
  • "); + + sb.Append($"
  • Models namespace is {config.ModelsNamespace}.
  • "); + + sb.Append("
  • Static mixin getters are "); + sb.Append(config.StaticMixinGetters ? "enabled" : "disabled"); + if (config.StaticMixinGetters) + { + sb.Append(". The pattern for getters is "); + sb.Append(string.IsNullOrWhiteSpace(config.StaticMixinGetterPattern) + ? "not configured (will use default)" + : $"\"{config.StaticMixinGetterPattern}\""); + } + sb.Append(".
  • "); + + sb.Append("
  • Tracking of out-of-date models is "); + sb.Append(config.FlagOutOfDateModels ? "enabled" : "not enabled"); + sb.Append(".
  • "); + + sb.Append("
"); + + return sb.ToString(); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs b/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs new file mode 100644 index 0000000000..da77bfa958 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.ModelsBuilder +{ + public static class EnumerableExtensions + { + public static void RemoveAll(this IList list, Func predicate) + { + for (var i = 0; i < list.Count; i++) + { + if (predicate(list[i])) + { + list.RemoveAt(i--); // i-- is important here! + } + } + } + + public static IEnumerable And(this IEnumerable enumerable, T item) + { + foreach (var x in enumerable) yield return x; + yield return item; + } + + public static IEnumerable AndIfNotNull(this IEnumerable enumerable, T item) + where T : class + { + foreach (var x in enumerable) yield return x; + if (item != null) + yield return item; + } + } +} diff --git a/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs new file mode 100644 index 0000000000..e5ab3a2e35 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Policy; +using System.Text; +using System.Threading.Tasks; +using Umbraco.ModelsBuilder; + +namespace Umbraco.ModelsBuilder +{ + // for the time being it's all-or-nothing + // when the content type is ignored then + // - no class is generated for that content type + // - no class is generated for any child of that class + // - no interface is generated for that content type as a mixin + // - and it is ignored as a mixin ie its properties are not generated + // in the future we may way to do + // [assembly:IgnoreContentType("foo", ContentTypeIgnorable.ContentType|ContentTypeIgnorable.Mixin|ContentTypeIgnorable.MixinProperties)] + // so that we can + // - generate a class for that content type, or not + // - if not generated, generate children or not + // - if generate children, include properties or not + // - generate an interface for that content type as a mixin + // - if not generated, still generate properties in content types implementing the mixin or not + // but... I'm not even sure it makes sense + // if we don't want it... we don't want it. + + // about ignoring + // - content (don't generate the content, use as mixin) + // - mixin (don't generate the interface, use the properties) + // - mixin properties (generate the interface, not the properties) + // - mixin: local only or children too... + + /// + /// Indicates that no model should be generated for a specified content type alias. + /// + /// When a content type is ignored, its descendants are also ignored. + /// Supports trailing wildcard eg "foo*". + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] + public sealed class IgnoreContentTypeAttribute : Attribute + { + public IgnoreContentTypeAttribute(string alias /*, bool ignoreContent = true, bool ignoreMixin = true, bool ignoreMixinProperties = true*/) + {} + } +} + diff --git a/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs new file mode 100644 index 0000000000..4dce0f9b7f --- /dev/null +++ b/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates that no model should be generated for a specified property type alias. + /// + /// Supports trailing wildcard eg "foo*". + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class IgnorePropertyTypeAttribute : Attribute + { + public IgnorePropertyTypeAttribute(string alias) + {} + } +} diff --git a/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs new file mode 100644 index 0000000000..142f115b07 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs @@ -0,0 +1,20 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + // NOTE + // that attribute should inherit from PublishedModelAttribute + // so we do not have different syntaxes + // but... is sealed at the moment. + + /// + /// Indicates that a (partial) class defines the model type for a specific alias. + /// + /// Though a model will be generated - so that is the way to register a rename. + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class ImplementContentTypeAttribute : Attribute + { + public ImplementContentTypeAttribute(string alias) + { } + } +} diff --git a/src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs new file mode 100644 index 0000000000..c5d8f8cad4 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates that a property implements a given property alias. + /// + /// And therefore it should not be generated. + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public sealed class ImplementPropertyTypeAttribute : Attribute + { + public ImplementPropertyTypeAttribute(string alias) + { + Alias = alias; + } + + public string Alias { get; private set; } + } +} diff --git a/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs new file mode 100644 index 0000000000..3c401b7fdb --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates the default base class for models. + /// + /// Otherwise it is PublishedContentModel. Would make sense to inherit from PublishedContentModel. + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] + public sealed class ModelsBaseClassAttribute : Attribute + { + public ModelsBaseClassAttribute(Type type) + {} + } +} + diff --git a/src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs new file mode 100644 index 0000000000..ed956852f8 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs @@ -0,0 +1,23 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates that an Assembly is a Models Builder assembly. + /// + [AttributeUsage(AttributeTargets.Assembly /*, AllowMultiple = false, Inherited = false*/)] + public sealed class ModelsBuilderAssemblyAttribute : Attribute + { + /// + /// Gets or sets a value indicating whether the assembly is a PureLive assembly. + /// + /// A Models Builder assembly can be either PureLive or normal Dll. + public bool PureLive { get; set; } + + /// + /// Gets or sets a hash value representing the state of the custom source code files + /// and the Umbraco content types that were used to generate and compile the assembly. + /// + public string SourceHash { get; set; } + } +} diff --git a/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs new file mode 100644 index 0000000000..1b1d62d9bc --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates the models namespace. + /// + /// Will override anything else that might come from settings. + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] + public sealed class ModelsNamespaceAttribute : Attribute + { + public ModelsNamespaceAttribute(string modelsNamespace) + {} + } +} + diff --git a/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs new file mode 100644 index 0000000000..8fe1335631 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Policy; +using System.Text; +using System.Threading.Tasks; +using Umbraco.ModelsBuilder; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates namespaces that should be used in generated models (in using clauses). + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] + public sealed class ModelsUsingAttribute : Attribute + { + public ModelsUsingAttribute(string usingNamespace) + {} + } +} + diff --git a/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs b/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a0a395a8a8 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Umbraco.ModelsBuilder")] +[assembly: AssemblyDescription("Umbraco ModelsBuilder")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyProduct("Umbraco CMS")] + +[assembly: ComVisible(false)] +[assembly: Guid("7020a059-c0d1-43a0-8efd-23591a0c9af6")] diff --git a/src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs b/src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs new file mode 100644 index 0000000000..f3320b5dfb --- /dev/null +++ b/src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Provides extension methods to models. + /// + public static class PublishedElementExtensions + { + /// + /// Gets the value of a property. + /// + public static TValue Value(this TModel model, Expression> property, string culture = ".", string segment = ".") + where TModel : IPublishedElement + { + var alias = GetAlias(model, property); + return model.Value(alias, culture, segment); + } + + private static string GetAlias(TModel model, Expression> property) + { + if (property.NodeType != ExpressionType.Lambda) + throw new ArgumentException("Not a proper lambda expression (lambda).", nameof(property)); + + var lambda = (LambdaExpression) property; + var lambdaBody = lambda.Body; + + if (lambdaBody.NodeType != ExpressionType.MemberAccess) + throw new ArgumentException("Not a proper lambda expression (body).", nameof(property)); + + var memberExpression = (MemberExpression) lambdaBody; + if (memberExpression.Expression.NodeType != ExpressionType.Parameter) + throw new ArgumentException("Not a proper lambda expression (member).", nameof(property)); + + var member = memberExpression.Member; + + var attribute = member.GetCustomAttribute(); + if (attribute == null) + throw new InvalidOperationException("Property is not marked with ImplementPropertyType attribute."); + + return attribute.Alias; + } + } +} diff --git a/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs b/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs new file mode 100644 index 0000000000..b67ba54432 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.ModelsBuilder +{ + public static class PublishedPropertyTypeExtensions + { + // fixme - need to rewrite that one - we don't have prevalues anymore + //public static KeyValuePair[] PreValues(this PublishedPropertyType propertyType) + //{ + // return ApplicationContext.Current.Services.DataTypeService + // .GetPreValuesCollectionByDataTypeId(propertyType.DataType.Id) + // .PreValuesAsArray + // .Select(x => new KeyValuePair(x.Id, x.Value)) + // .ToArray(); + //} + } +} diff --git a/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs b/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs new file mode 100644 index 0000000000..dfe369dc21 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates that an Assembly is a PureLive models assembly. + /// + /// Though technically not required, ie models will work without it, the attribute + /// can be used by Umbraco view models binder to figure out whether the model type comes + /// from a PureLive Assembly. + [Obsolete("Should use ModelsBuilderAssemblyAttribute but that requires a change in Umbraco Core.")] + [AttributeUsage(AttributeTargets.Assembly /*, AllowMultiple = false, Inherited = false*/)] + public sealed class PureLiveAssemblyAttribute : Attribute + { } +} diff --git a/src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs b/src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs new file mode 100644 index 0000000000..42e8b3b9c9 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Web.Compilation; +using System.Web.Hosting; +using Microsoft.CodeAnalysis; +using Umbraco.Core; + +namespace Umbraco.ModelsBuilder +{ + internal static class ReferencedAssemblies + { + private static readonly Lazy> LazyLocations; + private static readonly Lazy> LazyReferences; + + static ReferencedAssemblies() + { + LazyLocations = new Lazy>(() => HostingEnvironment.IsHosted + ? GetAllReferencedAssembliesLocationFromBuildManager() + : GetAllReferencedAssembliesFromDomain()); + + LazyReferences = new Lazy>(() => Locations + .Select(x => MetadataReference.CreateFromFile(x)) + .ToArray()); + } + + /// + /// Gets the assembly locations of all the referenced assemblies, that + /// are not dynamic, and have a non-null nor empty location. + /// + public static IEnumerable Locations => LazyLocations.Value; + + /// + /// Gets the metadata reference of all the referenced assemblies. + /// + public static IEnumerable References => LazyReferences.Value; + + // hosted, get referenced assemblies from the BuildManader and filter + private static IEnumerable GetAllReferencedAssembliesLocationFromBuildManager() + { + return BuildManager.GetReferencedAssemblies() + .Cast() + .Where(x => !x.IsDynamic && !x.Location.IsNullOrWhiteSpace()) + .Select(x => x.Location) + .And(typeof(ReferencedAssemblies).Assembly.Location) // always include ourselves + .Distinct() + .ToList(); + } + + // non-hosted, do our best + private static IEnumerable GetAllReferencedAssembliesFromDomain() + { + //TODO: This method has bugs since I've been stuck in an infinite loop with it, though this shouldn't + // execute while in the web application anyways. + + var assemblies = new List(); + var tmp1 = new List(); + var failed = new List(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies() + .Where(x => x.IsDynamic == false) + .Where(x => !string.IsNullOrWhiteSpace(x.Location))) // though... IsDynamic should be enough? + { + assemblies.Add(assembly); + tmp1.Add(assembly); + } + + // fixme - AssemblyUtility questions + // - should we also load everything that's in the same directory? + // - do we want to load in the current app domain? + // - if this runs within Umbraco then we have already loaded them all? + + while (tmp1.Count > 0) + { + var tmp2 = tmp1 + .SelectMany(x => x.GetReferencedAssemblies()) + .Distinct() + .Where(x => assemblies.All(xx => x.FullName != xx.FullName)) // we don't have it already + .Where(x => failed.All(xx => x.FullName != xx.FullName)) // it hasn't failed already + .ToArray(); + tmp1.Clear(); + foreach (var assemblyName in tmp2) + { + try + { + var assembly = AppDomain.CurrentDomain.Load(assemblyName); + assemblies.Add(assembly); + tmp1.Add(assembly); + } + catch + { + failed.Add(assemblyName); + } + } + } + return assemblies.Select(x => x.Location).Distinct(); + } + + + // ---- + + private static IEnumerable GetDeepReferencedAssemblies(Assembly assembly) + { + var visiting = new Stack(); + var visited = new HashSet(); + + visiting.Push(assembly); + visited.Add(assembly); + while (visiting.Count > 0) + { + var visAsm = visiting.Pop(); + foreach (var refAsm in visAsm.GetReferencedAssemblies() + .Select(TryLoad) + .Where(x => x != null && visited.Contains(x) == false)) + { + yield return refAsm; + visiting.Push(refAsm); + visited.Add(refAsm); + } + } + } + + private static Assembly TryLoad(AssemblyName name) + { + try + { + return AppDomain.CurrentDomain.Load(name); + } + catch (Exception) + { + //Console.WriteLine(name); + return null; + } + } + + } +} diff --git a/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs new file mode 100644 index 0000000000..0f985e70b3 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates a model name for a specified content alias. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] + public sealed class RenameContentTypeAttribute : Attribute + { + public RenameContentTypeAttribute(string alias, string name) + {} + } +} diff --git a/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs new file mode 100644 index 0000000000..0d8fd31b63 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Umbraco.ModelsBuilder +{ + /// + /// Indicates a model name for a specified property alias. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class RenamePropertyTypeAttribute : Attribute + { + public RenamePropertyTypeAttribute(string alias, string name) + {} + } +} diff --git a/src/Umbraco.ModelsBuilder/TypeExtensions.cs b/src/Umbraco.ModelsBuilder/TypeExtensions.cs new file mode 100644 index 0000000000..d3b3ff6b4e --- /dev/null +++ b/src/Umbraco.ModelsBuilder/TypeExtensions.cs @@ -0,0 +1,22 @@ +using System; + +namespace Umbraco.ModelsBuilder +{ + internal static class TypeExtensions + { + /// + /// Creates a generic instance of a generic type with the proper actual type of an object. + /// + /// A generic type such as Something{} + /// An object whose type is used as generic type param. + /// Arguments for the constructor. + /// A generic instance of the generic type with the proper type. + /// Usage... typeof (Something{}).CreateGenericInstance(object1, object2, object3) will return + /// a Something{Type1} if object1.GetType() is Type1. + public static object CreateGenericInstance(this Type genericType, object typeParmObj, params object[] ctorArgs) + { + var type = genericType.MakeGenericType(typeParmObj.GetType()); + return Activator.CreateInstance(type, ctorArgs); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj b/src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj new file mode 100644 index 0000000000..f9d53eec2f --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj @@ -0,0 +1,118 @@ + + + + + Debug + AnyCPU + {7020A059-C0D1-43A0-8EFD-23591A0C9AF6} + Library + Properties + Umbraco.ModelsBuilder + Umbraco.ModelsBuilder + v4.7.2 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + Properties\SolutionInfo.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2.8.0 + + + + + {31785bc3-256c-4613-b2f5-a1b0bdded8c1} + Umbraco.Core + + + {651e1350-91b6-44b7-bd60-7207006d7003} + Umbraco.Web + + + + \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Umbraco/Application.cs b/src/Umbraco.ModelsBuilder/Umbraco/Application.cs new file mode 100644 index 0000000000..df4549cc5c --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/Application.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Core.Strings; +using Umbraco.ModelsBuilder.Building; +using Umbraco.ModelsBuilder.Configuration; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + public class Application + { + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IMemberTypeService _memberTypeService; + private readonly IPublishedContentTypeFactory _publishedContentTypeFactory; + + public Application(IContentTypeService contentTypeService, IMediaTypeService mediaTypeService, IMemberTypeService memberTypeService, IPublishedContentTypeFactory publishedContentTypeFactory) + { + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _memberTypeService = memberTypeService; + _publishedContentTypeFactory = publishedContentTypeFactory; + } + + #region Services + + public IList GetAllTypes() + { + var types = new List(); + + types.AddRange(GetTypes(PublishedItemType.Content, _contentTypeService.GetAll().Cast().ToArray())); + types.AddRange(GetTypes(PublishedItemType.Media, _mediaTypeService.GetAll().Cast().ToArray())); + types.AddRange(GetTypes(PublishedItemType.Member, _memberTypeService.GetAll().Cast().ToArray())); + + return EnsureDistinctAliases(types); + } + + public IList GetContentTypes() + { + var contentTypes = _contentTypeService.GetAll().Cast().ToArray(); + return GetTypes(PublishedItemType.Content, contentTypes); // aliases have to be unique here + } + + public IList GetMediaTypes() + { + var contentTypes = _mediaTypeService.GetAll().Cast().ToArray(); + return GetTypes(PublishedItemType.Media, contentTypes); // aliases have to be unique here + } + + public IList GetMemberTypes() + { + var memberTypes = _memberTypeService.GetAll().Cast().ToArray(); + return GetTypes(PublishedItemType.Member, memberTypes); // aliases have to be unique here + } + + public static string GetClrName(string name, string alias) + { + // ideally we should just be able to re-use Umbraco's alias, + // just upper-casing the first letter, however in v7 for backward + // compatibility reasons aliases derive from names via ToSafeAlias which is + // PreFilter = ApplyUrlReplaceCharacters, + // IsTerm = (c, leading) => leading + // ? char.IsLetter(c) // only letters + // : (char.IsLetterOrDigit(c) || c == '_'), // letter, digit or underscore + // StringType = CleanStringType.Ascii | CleanStringType.UmbracoCase, + // BreakTermsOnUpper = false + // + // but that is not ideal with acronyms and casing + // however we CANNOT change Umbraco + // so, adding a way to "do it right" deriving from name, here + + switch (UmbracoConfig.For.ModelsBuilder().ClrNameSource) + { + case ClrNameSource.RawAlias: + // use Umbraco's alias + return alias; + + case ClrNameSource.Alias: + // ModelsBuilder's legacy - but not ideal + return alias.ToCleanString(CleanStringType.ConvertCase | CleanStringType.PascalCase); + + case ClrNameSource.Name: + // derive from name + var source = name.TrimStart('_'); // because CleanStringType.ConvertCase accepts them + return source.ToCleanString(CleanStringType.ConvertCase | CleanStringType.PascalCase | CleanStringType.Ascii); + + default: + throw new Exception("Invalid ClrNameSource."); + } + } + + private IList GetTypes(PublishedItemType itemType, IContentTypeComposition[] contentTypes) + { + var typeModels = new List(); + var uniqueTypes = new HashSet(); + + // get the types and the properties + foreach (var contentType in contentTypes) + { + var typeModel = new TypeModel + { + Id = contentType.Id, + Alias = contentType.Alias, + ClrName = GetClrName(contentType.Name, contentType.Alias), + ParentId = contentType.ParentId, + + Name = contentType.Name, + Description = contentType.Description + }; + + // of course this should never happen, but when it happens, better detect it + // else we end up with weird nullrefs everywhere + if (uniqueTypes.Contains(typeModel.ClrName)) + throw new Exception($"Panic: duplicate type ClrName \"{typeModel.ClrName}\"."); + uniqueTypes.Add(typeModel.ClrName); + + // fixme - we need a better way at figuring out what's an element type! + // and then we should not do the alias filtering below + bool IsElement(PublishedContentType x) + { + return x.Alias.InvariantEndsWith("Element"); + } + + var publishedContentType = _publishedContentTypeFactory.CreateContentType(contentType); + switch (itemType) + { + case PublishedItemType.Content: + if (IsElement(publishedContentType)) + { + typeModel.ItemType = TypeModel.ItemTypes.Element; + if (typeModel.ClrName.InvariantEndsWith("Element")) + typeModel.ClrName = typeModel.ClrName.Substring(0, typeModel.ClrName.Length - "Element".Length); + } + else + { + typeModel.ItemType = TypeModel.ItemTypes.Content; + } + break; + case PublishedItemType.Media: + typeModel.ItemType = TypeModel.ItemTypes.Media; + break; + case PublishedItemType.Member: + typeModel.ItemType = TypeModel.ItemTypes.Member; + break; + default: + throw new InvalidOperationException(string.Format("Unsupported PublishedItemType \"{0}\".", itemType)); + } + + typeModels.Add(typeModel); + + foreach (var propertyType in contentType.PropertyTypes) + { + var propertyModel = new PropertyModel + { + Alias = propertyType.Alias, + ClrName = GetClrName(propertyType.Name, propertyType.Alias), + + Name = propertyType.Name, + Description = propertyType.Description + }; + + var publishedPropertyType = publishedContentType.GetPropertyType(propertyType.Alias); + if (publishedPropertyType == null) + throw new Exception($"Panic: could not get published property type {contentType.Alias}.{propertyType.Alias}."); + + propertyModel.ModelClrType = publishedPropertyType.ModelClrType; + + typeModel.Properties.Add(propertyModel); + } + } + + // wire the base types + foreach (var typeModel in typeModels.Where(x => x.ParentId > 0)) + { + typeModel.BaseType = typeModels.SingleOrDefault(x => x.Id == typeModel.ParentId); + // Umbraco 7.4 introduces content types containers, so even though ParentId > 0, the parent might + // not be a content type - here we assume that BaseType being null while ParentId > 0 means that + // the parent is a container (and we don't check). + typeModel.IsParent = typeModel.BaseType != null; + } + + // discover mixins + foreach (var contentType in contentTypes) + { + var typeModel = typeModels.SingleOrDefault(x => x.Id == contentType.Id); + if (typeModel == null) throw new Exception("Panic: no type model matching content type."); + + IEnumerable compositionTypes; + var contentTypeAsMedia = contentType as IMediaType; + var contentTypeAsContent = contentType as IContentType; + var contentTypeAsMember = contentType as IMemberType; + if (contentTypeAsMedia != null) compositionTypes = contentTypeAsMedia.ContentTypeComposition; + else if (contentTypeAsContent != null) compositionTypes = contentTypeAsContent.ContentTypeComposition; + else if (contentTypeAsMember != null) compositionTypes = contentTypeAsMember.ContentTypeComposition; + else throw new Exception(string.Format("Panic: unsupported type \"{0}\".", contentType.GetType().FullName)); + + foreach (var compositionType in compositionTypes) + { + var compositionModel = typeModels.SingleOrDefault(x => x.Id == compositionType.Id); + if (compositionModel == null) throw new Exception("Panic: composition type does not exist."); + + if (compositionType.Id == contentType.ParentId) continue; + + // add to mixins + typeModel.MixinTypes.Add(compositionModel); + + // mark as mixin - as well as parents + compositionModel.IsMixin = true; + while ((compositionModel = compositionModel.BaseType) != null) + compositionModel.IsMixin = true; + } + } + + return typeModels; + } + + internal static IList EnsureDistinctAliases(IList typeModels) + { + var groups = typeModels.GroupBy(x => x.Alias.ToLowerInvariant()); + foreach (var group in groups.Where(x => x.Count() > 1)) + { + throw new NotSupportedException($"Alias \"{group.Key}\" is used by types" + + $" {string.Join(", ", group.Select(x => x.ItemType + ":\"" + x.Alias + "\""))}. Aliases have to be unique." + + " One of the aliases must be modified in order to use the ModelsBuilder."); + } + return typeModels; + } + + #endregion + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs b/src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs new file mode 100644 index 0000000000..e11662eb24 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs @@ -0,0 +1,38 @@ +using System; +using System.Globalization; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + // because, of course, it's internal in Umbraco + // see also System.Web.Util.HashCodeCombiner + class HashCombiner + { + private long _combinedHash = 5381L; + + public void Add(int i) + { + _combinedHash = ((_combinedHash << 5) + _combinedHash) ^ i; + } + + public void Add(object o) + { + Add(o.GetHashCode()); + } + + public void Add(DateTime d) + { + Add(d.GetHashCode()); + } + + public void Add(string s) + { + if (s == null) return; + Add((StringComparer.InvariantCulture).GetHashCode(s)); + } + + public string GetCombinedHashCode() + { + return _combinedHash.ToString("x", CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs b/src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs new file mode 100644 index 0000000000..c530cbbd6b --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.ModelsBuilder.Building; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + class HashHelper + { + public static string Hash(IDictionary ourFiles, IEnumerable typeModels) + { + var hash = new HashCombiner(); + + foreach (var kvp in ourFiles) + hash.Add(kvp.Key + "::" + kvp.Value); + + // see Umbraco.ModelsBuilder.Umbraco.Application for what's important to hash + // ie what comes from Umbraco (not computed by ModelsBuilder) and makes a difference + + foreach (var typeModel in typeModels.OrderBy(x => x.Alias)) + { + hash.Add("--- CONTENT TYPE MODEL ---"); + hash.Add(typeModel.Id); + hash.Add(typeModel.Alias); + hash.Add(typeModel.ClrName); + hash.Add(typeModel.ParentId); + hash.Add(typeModel.Name); + hash.Add(typeModel.Description); + hash.Add(typeModel.ItemType.ToString()); + hash.Add("MIXINS:" + string.Join(",", typeModel.MixinTypes.OrderBy(x => x.Id).Select(x => x.Id))); + + foreach (var prop in typeModel.Properties.OrderBy(x => x.Alias)) + { + hash.Add("--- PROPERTY ---"); + hash.Add(prop.Alias); + hash.Add(prop.ClrName); + hash.Add(prop.Name); + hash.Add(prop.Description); + hash.Add(prop.ModelClrType.ToString()); // see ModelType tests, want ToString() not FullName + } + } + + return hash.GetCombinedHashCode(); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs b/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs new file mode 100644 index 0000000000..1172fee59c --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs @@ -0,0 +1,139 @@ +using System; +using System.Threading; +using System.Web; +using System.Web.Hosting; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Umbraco; +using Umbraco.Web.Cache; + +// will install only if configuration says it needs to be installed +[assembly: PreApplicationStartMethod(typeof(LiveModelsProviderModule), "Install")] + +namespace Umbraco.ModelsBuilder.Umbraco +{ + // supports LiveDll and LiveAppData - but not PureLive + public sealed class LiveModelsProvider + { + private static Mutex _mutex; + private static int _req; + + internal static bool IsEnabled + { + get + { + var config = UmbracoConfig.For.ModelsBuilder(); + return config.ModelsMode.IsLiveNotPure(); + // we do not manage pure live here + } + } + + internal static void Install() + { + // just be sure + if (!IsEnabled) + return; + + // initialize mutex + // ApplicationId will look like "/LM/W3SVC/1/Root/AppName" + // name is system-wide and must be less than 260 chars + var name = HostingEnvironment.ApplicationID + "/UmbracoLiveModelsProvider"; + _mutex = new Mutex(false, name); + + // anything changes, and we want to re-generate models. + ContentTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; + DataTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; + + // at the end of a request since we're restarting the pool + // NOTE - this does NOT trigger - see module below + //umbracoApplication.EndRequest += GenerateModelsIfRequested; + } + + // NOTE + // Using HttpContext Items fails because CacheUpdated triggers within + // some asynchronous backend task where we seem to have no HttpContext. + + // So we use a static (non request-bound) var to register that models + // need to be generated. Could be by another request. Anyway. We could + // have collisions but... you know the risk. + + private static void RequestModelsGeneration(object sender, EventArgs args) + { + //HttpContext.Current.Items[this] = true; + Current.Logger.Debug("Requested to generate models."); + Interlocked.Exchange(ref _req, 1); + } + + public static void GenerateModelsIfRequested(object sender, EventArgs args) + { + //if (HttpContext.Current.Items[this] == null) return; + if (Interlocked.Exchange(ref _req, 0) == 0) return; + + // cannot use a simple lock here because we don't want another AppDomain + // to generate while we do... and there could be 2 AppDomains if the app restarts. + + try + { + Current.Logger.Debug("Generate models..."); + const int timeout = 2*60*1000; // 2 mins + _mutex.WaitOne(timeout); // wait until it is safe, and acquire + Current.Logger.Info("Generate models now."); + GenerateModels(); + ModelsGenerationError.Clear(); + Current.Logger.Info("Generated."); + } + catch (TimeoutException) + { + Current.Logger.Warn("Timeout, models were NOT generated."); + } + catch (Exception e) + { + ModelsGenerationError.Report("Failed to build Live models.", e); + Current.Logger.Error("Failed to generate models.", e); + } + finally + { + _mutex.ReleaseMutex(); // release + } + } + + private static void GenerateModels() + { + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + + var bin = HostingEnvironment.MapPath("~/bin"); + if (bin == null) + throw new Exception("Panic: bin is null."); + + var config = UmbracoConfig.For.ModelsBuilder(); + + // EnableDllModels will recycle the app domain - but this request will end properly + ModelsBuilderBackOfficeController.GenerateModels(modelsDirectory, config.ModelsMode.IsAnyDll() ? bin : null); + } + } + + // have to do this because it's the only way to subscribe to EndRequest + // module is installed by assembly attribute at the top of this file + public class LiveModelsProviderModule : IHttpModule + { + public void Init(HttpApplication app) + { + app.EndRequest += LiveModelsProvider.GenerateModelsIfRequested; + } + + public void Dispose() + { + // nothing + } + + public static void Install() + { + if (!LiveModelsProvider.IsEnabled) + return; + + HttpApplication.RegisterModule(typeof(LiveModelsProviderModule)); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderApplication.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderApplication.cs new file mode 100644 index 0000000000..bf650804c7 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderApplication.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Web; +using System.Web.Mvc; +using System.Web.Routing; +using LightInject; +using Umbraco.Core; +using Umbraco.Core.Components; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; +using Umbraco.Core.IO; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.Web; +using Umbraco.Web.UI.JavaScript; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + /// + /// Installs ModelsBuilder into the Umbraco site. + /// + /// + /// Don't bother installing at all, if not RuntimeLevel.Run. + /// + [RuntimeLevel(MinLevel = RuntimeLevel.Run)] + public class ModelsBuilderApplication : UmbracoComponentBase, IUmbracoCoreComponent + { + private IRuntimeState _runtimeState; + + public override void Compose(Composition composition) + { + var config = UmbracoConfig.For.ModelsBuilder(); + + if (config.ModelsMode == ModelsMode.PureLive) + InstallLiveModels(composition.Container); + else if (config.EnableFactory) + InstallDefaultModelsFactory(composition.Container); + + // always setup the dashboard + InstallServerVars(); + } + + public void Initialize(IRuntimeState runtimeState) + { + _runtimeState = runtimeState; + + var config = UmbracoConfig.For.ModelsBuilder(); + + if (config.Enable) + FileService.SavingTemplate += FileService_SavingTemplate; + + if (config.ModelsMode.IsLiveNotPure()) + LiveModelsProvider.Install(); + + if (config.FlagOutOfDateModels) + OutOfDateModelsStatus.Install(); + } + + private void InstallDefaultModelsFactory(IServiceContainer container) + { + var types = Current.TypeLoader.GetTypes(); + var factory = new PublishedModelFactory(types); + container.RegisterSingleton(_ => factory); + } + + private void InstallLiveModels(IServiceContainer container) + { + container.RegisterSingleton(); + + // the following would add @using statement in every view so user's don't + // have to do it - however, then noone understands where the @using statement + // comes from, and it cannot be avoided / removed --- DISABLED + // + /* + // no need for @using in views + // note: + // we are NOT using the in-code attribute here, config is required + // because that would require parsing the code... and what if it changes? + // we can AddGlobalImport not sure we can remove one anyways + var modelsNamespace = Configuration.Config.ModelsNamespace; + if (string.IsNullOrWhiteSpace(modelsNamespace)) + modelsNamespace = Configuration.Config.DefaultModelsNamespace; + System.Web.WebPages.Razor.WebPageRazorHost.AddGlobalImport(modelsNamespace); + */ + } + + private void InstallServerVars() + { + // register our url - for the backoffice api + ServerVariablesParser.Parsing += (sender, serverVars) => + { + if (!serverVars.ContainsKey("umbracoUrls")) + throw new Exception("Missing umbracoUrls."); + var umbracoUrlsObject = serverVars["umbracoUrls"]; + if (umbracoUrlsObject == null) + throw new Exception("Null umbracoUrls"); + if (!(umbracoUrlsObject is Dictionary umbracoUrls)) + throw new Exception("Invalid umbracoUrls"); + + if (!serverVars.ContainsKey("umbracoPlugins")) + throw new Exception("Missing umbracoPlugins."); + if (!(serverVars["umbracoPlugins"] is Dictionary umbracoPlugins)) + throw new Exception("Invalid umbracoPlugins"); + + if (HttpContext.Current == null) throw new InvalidOperationException("HttpContext is null"); + var urlHelper = new UrlHelper(new RequestContext(new HttpContextWrapper(HttpContext.Current), new RouteData())); + + umbracoUrls["modelsBuilderBaseUrl"] = urlHelper.GetUmbracoApiServiceBaseUrl(controller => controller.BuildModels()); + umbracoPlugins["modelsBuilder"] = GetModelsBuilderSettings(); + }; + } + + private Dictionary GetModelsBuilderSettings() + { + if (_runtimeState.Level != RuntimeLevel.Run) + return null; + + var settings = new Dictionary + { + {"enabled", UmbracoConfig.For.ModelsBuilder().Enable} + }; + + return settings; + } + + /// + /// Used to check if a template is being created based on a document type, in this case we need to + /// ensure the template markup is correct based on the model name of the document type + /// + /// + /// + private void FileService_SavingTemplate(IFileService sender, Core.Events.SaveEventArgs e) + { + // don't do anything if the factory is not enabled + // because, no factory = no models (even if generation is enabled) + if (!UmbracoConfig.For.ModelsBuilder().EnableFactory) return; + + // don't do anything if this special key is not found + if (!e.AdditionalData.ContainsKey("CreateTemplateForContentType")) return; + + // ensure we have the content type alias + if (!e.AdditionalData.ContainsKey("ContentTypeAlias")) + throw new InvalidOperationException("The additionalData key: ContentTypeAlias was not found"); + + foreach (var template in e.SavedEntities) + { + // if it is in fact a new entity (not been saved yet) and the "CreateTemplateForContentType" key + // is found, then it means a new template is being created based on the creation of a document type + if (!template.HasIdentity && template.Content.IsNullOrWhiteSpace()) + { + // ensure is safe and always pascal cased, per razor standard + // + this is how we get the default model name in Umbraco.ModelsBuilder.Umbraco.Application + var alias = e.AdditionalData["ContentTypeAlias"].ToString(); + var name = template.Name; // will be the name of the content type since we are creating + var className = Application.GetClrName(name, alias); + + var modelNamespace = UmbracoConfig.For.ModelsBuilder().ModelsNamespace; + + // we do not support configuring this at the moment, so just let Umbraco use its default value + //var modelNamespaceAlias = ...; + + var markup = ViewHelper.GetDefaultFileContent( + modelClassName: className, + modelNamespace: modelNamespace/*, + modelNamespaceAlias: modelNamespaceAlias*/); + + //set the template content to the new markup + template.Content = markup; + } + } + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs new file mode 100644 index 0000000000..0a586fa16b --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs @@ -0,0 +1,181 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; +using System.Web.Hosting; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Building; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Dashboard; +using Umbraco.Web.Editors; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + /// + /// API controller for use in the Umbraco back office with Angular resources + /// + /// + /// We've created a different controller for the backoffice/angular specifically this is to ensure that the + /// correct CSRF security is adhered to for angular and it also ensures that this controller is not subseptipal to + /// global WebApi formatters being changed since this is always forced to only return Angular JSON Specific formats. + /// + public class ModelsBuilderBackOfficeController : UmbracoAuthorizedJsonController + { + // invoked by the dashboard + // requires that the user is logged into the backoffice and has access to the developer section + // beware! the name of the method appears in modelsbuilder.controller.js + [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! + public HttpResponseMessage BuildModels() + { + try + { + if (!UmbracoConfig.For.ModelsBuilder().ModelsMode.SupportsExplicitGeneration()) + { + var result2 = new BuildResult { Success = false, Message = "Models generation is not enabled." }; + return Request.CreateResponse(HttpStatusCode.OK, result2, Configuration.Formatters.JsonFormatter); + } + + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + + var bin = HostingEnvironment.MapPath("~/bin"); + if (bin == null) + throw new Exception("Panic: bin is null."); + + // EnableDllModels will recycle the app domain - but this request will end properly + GenerateModels(modelsDirectory, UmbracoConfig.For.ModelsBuilder().ModelsMode.IsAnyDll() ? bin : null); + + ModelsGenerationError.Clear(); + } + catch (Exception e) + { + ModelsGenerationError.Report("Failed to build models.", e); + } + + return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); + } + + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the developer section + [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! + public HttpResponseMessage GetModelsOutOfDateStatus() + { + var status = OutOfDateModelsStatus.IsEnabled + ? (OutOfDateModelsStatus.IsOutOfDate + ? new OutOfDateStatus { Status = OutOfDateType.OutOfDate } + : new OutOfDateStatus { Status = OutOfDateType.Current }) + : new OutOfDateStatus { Status = OutOfDateType.Unknown }; + + return Request.CreateResponse(HttpStatusCode.OK, status, Configuration.Formatters.JsonFormatter); + } + + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the developer section + // beware! the name of the method appears in modelsbuilder.controller.js + [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! + public HttpResponseMessage GetDashboard() + { + return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); + } + + private Dashboard GetDashboardResult() + { + return new Dashboard + { + Enable = UmbracoConfig.For.ModelsBuilder().Enable, + Text = BuilderDashboardHelper.Text(), + CanGenerate = BuilderDashboardHelper.CanGenerate(), + GenerateCausesRestart = BuilderDashboardHelper.GenerateCausesRestart(), + OutOfDateModels = BuilderDashboardHelper.AreModelsOutOfDate(), + LastError = BuilderDashboardHelper.LastError(), + }; + } + + internal static void GenerateModels(string modelsDirectory, string bin) + { + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + + foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) + File.Delete(file); + + var umbraco = ModelsBuilderComponent.Umbraco; + var typeModels = umbraco.GetAllTypes(); + + var ourFiles = Directory.GetFiles(modelsDirectory, "*.cs").ToDictionary(x => x, File.ReadAllText); + var parseResult = new CodeParser().ParseWithReferencedAssemblies(ourFiles); + var builder = new TextBuilder(typeModels, parseResult, UmbracoConfig.For.ModelsBuilder().ModelsNamespace); + + foreach (var typeModel in builder.GetModelsToGenerate()) + { + var sb = new StringBuilder(); + builder.Generate(sb, typeModel); + var filename = Path.Combine(modelsDirectory, typeModel.ClrName + ".generated.cs"); + File.WriteAllText(filename, sb.ToString()); + } + + // the idea was to calculate the current hash and to add it as an extra file to the compilation, + // in order to be able to detect whether a DLL is consistent with an environment - however the + // environment *might not* contain the local partial files, and thus it could be impossible to + // calculate the hash. So... maybe that's not a good idea after all? + /* + var currentHash = HashHelper.Hash(ourFiles, typeModels); + ourFiles["models.hash.cs"] = $@"using Umbraco.ModelsBuilder; +[assembly:ModelsBuilderAssembly(SourceHash = ""{currentHash}"")] +"; + */ + + if (bin != null) + { + foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) + ourFiles[file] = File.ReadAllText(file); + var compiler = new Compiler(); + compiler.Compile(builder.GetModelsNamespace(), ourFiles, bin); + } + + OutOfDateModelsStatus.Clear(); + } + + [DataContract] + internal class BuildResult + { + [DataMember(Name = "success")] + public bool Success; + [DataMember(Name = "message")] + public string Message; + } + + [DataContract] + internal class Dashboard + { + [DataMember(Name = "enable")] + public bool Enable; + [DataMember(Name = "text")] + public string Text; + [DataMember(Name = "canGenerate")] + public bool CanGenerate; + [DataMember(Name = "generateCausesRestart")] + public bool GenerateCausesRestart; + [DataMember(Name = "outOfDateModels")] + public bool OutOfDateModels; + [DataMember(Name = "lastError")] + public string LastError; + } + + internal enum OutOfDateType + { + OutOfDate, + Current, + Unknown = 100 + } + + [DataContract] + internal class OutOfDateStatus + { + [DataMember(Name = "status")] + public OutOfDateType Status { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs new file mode 100644 index 0000000000..ba75b59578 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Web; +using System.Web.Mvc; +using System.Web.Routing; +using LightInject; +using Umbraco.Core; +using Umbraco.Core.Components; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; +using Umbraco.Core.IO; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.Web; +using Umbraco.Web.PublishedCache.NuCache; +using Umbraco.Web.UI.JavaScript; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + // fixme + // nucache components wants models so we need to setup models before + // however for some reason, this creates a cyclic dependency? => need better debugging info + // cos nucache is Core so we need to be Core too + // also... should have a generic "modelsbuilder" and "contentcache" components for dependencies! + + [RequiredComponent(typeof(NuCacheComponent))] + public class ModelsBuilderComponent : UmbracoComponentBase, IUmbracoCoreComponent + { + public override void Compose(Composition composition) + { + base.Compose(composition); + composition.Container.Register(new PerContainerLifetime()); + + var config = UmbracoConfig.For.ModelsBuilder(); + + if (config.ModelsMode == ModelsMode.PureLive) + InstallLiveModels(composition.Container); + else if (config.EnableFactory) + InstallDefaultModelsFactory(composition.Container); + + // always setup the dashboard + InstallServerVars(composition.Container.GetInstance().Level); + + // need to do it here 'cos NuCache wants it during compose? + Umbraco = composition.Container.GetInstance(); + } + + public void Initialize(Application application) + { + Umbraco = application; + + var config = UmbracoConfig.For.ModelsBuilder(); + + if (config.Enable) + FileService.SavingTemplate += FileService_SavingTemplate; + + if (config.ModelsMode.IsLiveNotPure()) + LiveModelsProvider.Install(); + + if (config.FlagOutOfDateModels) + OutOfDateModelsStatus.Install(); + } + + public static Application Umbraco { get; private set; } + + private void InstallDefaultModelsFactory(IServiceContainer container) + { + container.RegisterSingleton(factory + => new PublishedModelFactory(factory.GetInstance().GetTypes())); + } + + private void InstallLiveModels(IServiceContainer container) + { + container.RegisterSingleton(); + + // the following would add @using statement in every view so user's don't + // have to do it - however, then noone understands where the @using statement + // comes from, and it cannot be avoided / removed --- DISABLED + // + /* + // no need for @using in views + // note: + // we are NOT using the in-code attribute here, config is required + // because that would require parsing the code... and what if it changes? + // we can AddGlobalImport not sure we can remove one anyways + var modelsNamespace = Configuration.Config.ModelsNamespace; + if (string.IsNullOrWhiteSpace(modelsNamespace)) + modelsNamespace = Configuration.Config.DefaultModelsNamespace; + System.Web.WebPages.Razor.WebPageRazorHost.AddGlobalImport(modelsNamespace); + */ + } + + private void InstallServerVars(RuntimeLevel level) + { + // register our url - for the backoffice api + ServerVariablesParser.Parsing += (sender, serverVars) => + { + if (!serverVars.ContainsKey("umbracoUrls")) + throw new Exception("Missing umbracoUrls."); + var umbracoUrlsObject = serverVars["umbracoUrls"]; + if (umbracoUrlsObject == null) + throw new Exception("Null umbracoUrls"); + var umbracoUrls = umbracoUrlsObject as Dictionary; + if (umbracoUrls == null) + throw new Exception("Invalid umbracoUrls"); + + if (!serverVars.ContainsKey("umbracoPlugins")) + throw new Exception("Missing umbracoPlugins."); + var umbracoPlugins = serverVars["umbracoPlugins"] as Dictionary; + if (umbracoPlugins == null) + throw new Exception("Invalid umbracoPlugins"); + + if (HttpContext.Current == null) throw new InvalidOperationException("HttpContext is null"); + var urlHelper = new UrlHelper(new RequestContext(new HttpContextWrapper(HttpContext.Current), new RouteData())); + + umbracoUrls["modelsBuilderBaseUrl"] = urlHelper.GetUmbracoApiServiceBaseUrl(controller => controller.BuildModels()); + umbracoPlugins["modelsBuilder"] = GetModelsBuilderSettings(level); + }; + } + + private Dictionary GetModelsBuilderSettings(RuntimeLevel level) + { + if (level != RuntimeLevel.Run) + return null; + + var settings = new Dictionary + { + {"enabled", UmbracoConfig.For.ModelsBuilder().Enable} + }; + + return settings; + } + + /// + /// Used to check if a template is being created based on a document type, in this case we need to + /// ensure the template markup is correct based on the model name of the document type + /// + /// + /// + private void FileService_SavingTemplate(IFileService sender, Core.Events.SaveEventArgs e) + { + // don't do anything if the factory is not enabled + // because, no factory = no models (even if generation is enabled) + if (!UmbracoConfig.For.ModelsBuilder().EnableFactory) return; + + // don't do anything if this special key is not found + if (!e.AdditionalData.ContainsKey("CreateTemplateForContentType")) return; + + // ensure we have the content type alias + if (!e.AdditionalData.ContainsKey("ContentTypeAlias")) + throw new InvalidOperationException("The additionalData key: ContentTypeAlias was not found"); + + foreach (var template in e.SavedEntities) + { + // if it is in fact a new entity (not been saved yet) and the "CreateTemplateForContentType" key + // is found, then it means a new template is being created based on the creation of a document type + if (!template.HasIdentity && string.IsNullOrWhiteSpace(template.Content)) + { + // ensure is safe and always pascal cased, per razor standard + // + this is how we get the default model name in Umbraco.ModelsBuilder.Umbraco.Application + var alias = e.AdditionalData["ContentTypeAlias"].ToString(); + var name = template.Name; // will be the name of the content type since we are creating + var className = Application.GetClrName(name, alias); + + var modelNamespace = UmbracoConfig.For.ModelsBuilder().ModelsNamespace; + + // we do not support configuring this at the moment, so just let Umbraco use its default value + //var modelNamespaceAlias = ...; + + var markup = ViewHelper.GetDefaultFileContent( + modelClassName: className, + modelNamespace: modelNamespace/*, + modelNamespaceAlias: modelNamespaceAlias*/); + + //set the template content to the new markup + template.Content = markup; + } + } + } + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs new file mode 100644 index 0000000000..7102190b5e --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Text; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Configuration; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + internal static class ModelsGenerationError + { + public static void Clear() + { + var errFile = GetErrFile(); + if (errFile == null) return; + + // "If the file to be deleted does not exist, no exception is thrown." + File.Delete(errFile); + } + + public static void Report(string message, Exception e) + { + var errFile = GetErrFile(); + if (errFile == null) return; + + var sb = new StringBuilder(); + sb.Append(message); + sb.Append("\r\n"); + sb.Append(e.Message); + sb.Append("\r\n\r\n"); + sb.Append(e.StackTrace); + sb.Append("\r\n"); + + File.WriteAllText(errFile, sb.ToString()); + } + + public static string GetLastError() + { + var errFile = GetErrFile(); + if (errFile == null) return null; + + try + { + return File.ReadAllText(errFile); + } + catch // accepted + { + return null; + } + } + + private static string GetErrFile() + { + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + return null; + + return Path.Combine(modelsDirectory, "models.err"); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs b/src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs new file mode 100644 index 0000000000..a5f21f2b2a --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Web.Hosting; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.Web.Cache; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + public sealed class OutOfDateModelsStatus + { + internal static void Install() + { + // just be sure + if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) + return; + + ContentTypeCacheRefresher.CacheUpdated += (sender, args) => Write(); + DataTypeCacheRefresher.CacheUpdated += (sender, args) => Write(); + } + + private static string GetFlagPath() + { + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + return Path.Combine(modelsDirectory, "ood.flag"); + } + + private static void Write() + { + var path = GetFlagPath(); + if (path == null || File.Exists(path)) return; + File.WriteAllText(path, "THIS FILE INDICATES THAT MODELS ARE OUT-OF-DATE\n\n"); + } + + public static void Clear() + { + if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) return; + var path = GetFlagPath(); + if (path == null || !File.Exists(path)) return; + File.Delete(path); + } + + public static bool IsEnabled + { + get { return UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels; } + } + + public static bool IsOutOfDate + { + get + { + if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) return false; + var path = GetFlagPath(); + return path != null && File.Exists(path); + } + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs b/src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs new file mode 100644 index 0000000000..c70e8a3b65 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Umbraco.Web.Composing; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + public static class PublishedModelUtility + { + // looks safer but probably useless... ppl should not call these methods directly + // and if they do... they have to take care about not doing stupid things + + //public static PublishedPropertyType GetModelPropertyType2(Expression> selector) + // where T : PublishedContentModel + //{ + // var type = typeof (T); + // var s1 = type.GetField("ModelTypeAlias", BindingFlags.Public | BindingFlags.Static); + // var alias = (s1.IsLiteral && s1.IsInitOnly && s1.FieldType == typeof(string)) ? (string)s1.GetValue(null) : null; + // var s2 = type.GetField("ModelItemType", BindingFlags.Public | BindingFlags.Static); + // var itemType = (s2.IsLiteral && s2.IsInitOnly && s2.FieldType == typeof(PublishedItemType)) ? (PublishedItemType)s2.GetValue(null) : 0; + + // var contentType = PublishedContentType.Get(itemType, alias); + // // etc... + //} + + public static PublishedContentType GetModelContentType(PublishedItemType itemType, string alias) + { + var facade = Current.UmbracoContext.PublishedSnapshot; // fixme inject! + switch (itemType) + { + case PublishedItemType.Content: + return facade.Content.GetContentType(alias); + case PublishedItemType.Media: + return facade.Media.GetContentType(alias); + case PublishedItemType.Member: + return facade.Members.GetContentType(alias); + default: + throw new ArgumentOutOfRangeException(nameof(itemType)); + } + } + + public static PublishedPropertyType GetModelPropertyType(PublishedContentType contentType, Expression> selector) + //where TModel : PublishedContentModel // fixme PublishedContentModel _or_ PublishedElementModel + { + // fixme therefore, missing a check on TModel here + + var expr = selector.Body as MemberExpression; + + if (expr == null) + throw new ArgumentException("Not a property expression.", nameof(selector)); + + // there _is_ a risk that contentType and T do not match + // see note above : accepted risk... + + var attr = expr.Member + .GetCustomAttributes(typeof (ImplementPropertyTypeAttribute), false) + .OfType() + .SingleOrDefault(); + + if (string.IsNullOrWhiteSpace(attr?.Alias)) + throw new InvalidOperationException($"Could not figure out property alias for property \"{expr.Member.Name}\"."); + + return contentType.GetPropertyType(attr.Alias); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs b/src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs new file mode 100644 index 0000000000..c216c92b03 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs @@ -0,0 +1,601 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Reflection.Emit; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Web.Compilation; +using System.Web.Hosting; +using System.Web.WebPages.Razor; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Cache; +using Umbraco.ModelsBuilder.Building; +using Umbraco.ModelsBuilder.Configuration; +using File = System.IO.File; + +namespace Umbraco.ModelsBuilder.Umbraco +{ + internal class PureLiveModelFactory : IPublishedModelFactory, IRegisteredObject + { + private Assembly _modelsAssembly; + private Infos _infos = new Infos { ModelInfos = null, ModelTypeMap = new Dictionary() }; + private readonly ReaderWriterLockSlim _locker = new ReaderWriterLockSlim(); + private volatile bool _hasModels; // volatile 'cos reading outside lock + private bool _pendingRebuild; + private readonly ProfilingLogger _logger; + private readonly FileSystemWatcher _watcher; + private int _ver, _skipver; + private readonly int _debugLevel; + private BuildManager _theBuildManager; + + private static readonly Regex AssemblyVersionRegex = new Regex("AssemblyVersion\\(\"[0-9]+.[0-9]+.[0-9]+.[0-9]+\"\\)", RegexOptions.Compiled); + private const string ProjVirt = "~/App_Data/Models/all.generated.cs"; + private static readonly string[] OurFiles = { "models.hash", "models.generated.cs", "all.generated.cs", "all.dll.path", "models.err" }; + + public PureLiveModelFactory(ProfilingLogger logger) + { + _logger = logger; + _ver = 1; // zero is for when we had no version + _skipver = -1; // nothing to skip + ContentTypeCacheRefresher.CacheUpdated += (sender, args) => ResetModels(); + DataTypeCacheRefresher.CacheUpdated += (sender, args) => ResetModels(); + RazorBuildProvider.CodeGenerationStarted += RazorBuildProvider_CodeGenerationStarted; + + if (!HostingEnvironment.IsHosted) return; + + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + + // BEWARE! if the watcher is not properly released then for some reason the + // BuildManager will start confusing types - using a 'registered object' here + // though we should probably plug into Umbraco's MainDom - which is internal + HostingEnvironment.RegisterObject(this); + _watcher = new FileSystemWatcher(modelsDirectory); + _watcher.Changed += WatcherOnChanged; + _watcher.EnableRaisingEvents = true; + + // get it here, this need to be fast + _debugLevel = UmbracoConfig.For.ModelsBuilder().DebugLevel; + } + + #region IPublishedModelFactory + + public IPublishedElement CreateModel(IPublishedElement element) + { + // get models, rebuilding them if needed + var infos = EnsureModels()?.ModelInfos; + if (infos == null) + return element; + + // be case-insensitive + var contentTypeAlias = element.ContentType.Alias; + + // lookup model constructor (else null) + infos.TryGetValue(contentTypeAlias, out ModelInfo info); + + // create model + return info == null ? element : info.Ctor(element); + } + + // this runs only once the factory is ready + // NOT when building models + public Type MapModelType(Type type) + { + var infos = EnsureModels(); + return ModelType.Map(type, infos.ModelTypeMap); + } + + // this runs only once the factory is ready + // NOT when building models + public IList CreateModelList(string alias) + { + var infos = EnsureModels(); + + // fail fast + if (infos == null) + return new List(); + + if (!infos.ModelInfos.TryGetValue(alias, out var modelInfo)) + return new List(); + + var ctor = modelInfo.ListCtor; + if (ctor != null) return ctor(); + + var listType = typeof(List<>).MakeGenericType(modelInfo.ModelType); + ctor = modelInfo.ListCtor = ReflectionUtilities.EmitCtor>(declaring: listType); + return ctor(); + } + + #endregion + + #region Compilation + + // deadlock note + // + // when RazorBuildProvider_CodeGenerationStarted runs, the thread has Monitor.Enter-ed the BuildManager + // singleton instance, through a call to CompilationLock.GetLock in BuildManager.GetVPathBuildResultInternal, + // and now wants to lock _locker. + // when EnsureModels runs, the thread locks _locker and then wants BuildManager to compile, which in turns + // requires that the BuildManager can Monitor.Enter-ed itself. + // so: + // + // T1 - needs to ensure models, locks _locker + // T2 - needs to compile a view, locks BuildManager + // hits RazorBuildProvider_CodeGenerationStarted + // wants to lock _locker, wait + // T1 - needs to compile models, using BuildManager + // wants to lock itself, wait + // + // + // until ASP.NET kills the long-running request (thread abort) + // + // problem is, we *want* to suspend views compilation while the models assembly is being changed else we + // end up with views compiled and cached with the old assembly, while models come from the new assembly, + // which gives more YSOD. so we *have* to lock _locker in RazorBuildProvider_CodeGenerationStarted. + // + // one "easy" solution consists in locking the BuildManager *before* _locker in EnsureModels, thus ensuring + // we always lock in the same order, and getting rid of deadlocks - but that requires having access to the + // current BuildManager instance, which is BuildManager.TheBuildManager, which is an internal property. + // + // well, that's what we are doing in this class' TheBuildManager property, using reflection. + + private void RazorBuildProvider_CodeGenerationStarted(object sender, EventArgs e) + { + try + { + _locker.EnterReadLock(); + + // just be safe - can happen if the first view is not an Umbraco view, + // or if something went wrong and we don't have an assembly at all + if (_modelsAssembly == null) return; + + if (_debugLevel > 0) + _logger.Logger.Debug("RazorBuildProvider.CodeGenerationStarted"); + var provider = sender as RazorBuildProvider; + if (provider == null) return; + + // add the assembly, and add a dependency to a text file that will change on each + // compilation as in some environments (could not figure which/why) the BuildManager + // would not re-compile the views when the models assembly is rebuilt. + provider.AssemblyBuilder.AddAssemblyReference(_modelsAssembly); + provider.AddVirtualPathDependency(ProjVirt); + } + finally + { + if (_locker.IsReadLockHeld) + _locker.ExitReadLock(); + } + } + + // tells the factory that it should build a new generation of models + private void ResetModels() + { + _logger.Logger.Debug("Resetting models."); + + try + { + _locker.EnterWriteLock(); + + _hasModels = false; + _pendingRebuild = true; + } + finally + { + if (_locker.IsWriteLockHeld) + _locker.ExitWriteLock(); + } + } + + // gets "the" build manager + private BuildManager TheBuildManager + { + get + { + if (_theBuildManager != null) return _theBuildManager; + var prop = typeof (BuildManager).GetProperty("TheBuildManager", BindingFlags.NonPublic | BindingFlags.Static); + if (prop == null) + throw new InvalidOperationException("Could not get BuildManager.TheBuildManager property."); + _theBuildManager = (BuildManager) prop.GetValue(null); + return _theBuildManager; + } + } + + // ensure that the factory is running with the lastest generation of models + internal Infos EnsureModels() + { + if (_debugLevel > 0) + _logger.Logger.Debug("Ensuring models."); + + // don't use an upgradeable lock here because only 1 thread at a time could enter it + try + { + _locker.EnterReadLock(); + if (_hasModels) + return _infos; + } + finally + { + if (_locker.IsReadLockHeld) + _locker.ExitReadLock(); + } + + var buildManagerLocked = false; + try + { + // always take the BuildManager lock *before* taking the _locker lock + // to avoid possible deadlock situations (see notes above) + Monitor.Enter(TheBuildManager, ref buildManagerLocked); + + _locker.EnterUpgradeableReadLock(); + + if (_hasModels) return _infos; + + _locker.EnterWriteLock(); + + // we don't have models, + // either they haven't been loaded from the cache yet + // or they have been reseted and are pending a rebuild + + using (_logger.DebugDuration("Get models.", "Got models.")) + { + try + { + var assembly = GetModelsAssembly(_pendingRebuild); + + // the one below can be used to simulate an issue with BuildManager, ie it will register + // the models with the factory but NOT with the BuildManager, which will not recompile views. + // this is for U4-8043 which is an obvious issue but I cannot replicate + //_modelsAssembly = _modelsAssembly ?? assembly; + + // the one below is the normal one + _modelsAssembly = assembly; + + var types = assembly.ExportedTypes.Where(x => x.Inherits() || x.Inherits()); + _infos = RegisterModels(types); + ModelsGenerationError.Clear(); + } + catch (Exception e) + { + try + { + _logger.Logger.Error("Failed to build models.", e); + _logger.Logger.Warn("Running without models."); // be explicit + ModelsGenerationError.Report("Failed to build PureLive models.", e); + } + finally + { + _modelsAssembly = null; + _infos = new Infos { ModelInfos = null, ModelTypeMap = new Dictionary() }; + } + } + + // don't even try again + _hasModels = true; + } + + return _infos; + } + finally + { + if (_locker.IsWriteLockHeld) + _locker.ExitWriteLock(); + if (_locker.IsUpgradeableReadLockHeld) + _locker.ExitUpgradeableReadLock(); + if (buildManagerLocked) + Monitor.Exit(TheBuildManager); + } + } + + private Assembly GetModelsAssembly(bool forceRebuild) + { + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + + // must filter out *.generated.cs because we haven't deleted them yet! + var ourFiles = Directory.Exists(modelsDirectory) + ? Directory.GetFiles(modelsDirectory, "*.cs") + .Where(x => !x.EndsWith(".generated.cs")) + .ToDictionary(x => x, File.ReadAllText) + : new Dictionary(); + + var umbraco = ModelsBuilderComponent.Umbraco; + var typeModels = umbraco.GetAllTypes(); + var currentHash = HashHelper.Hash(ourFiles, typeModels); + var modelsHashFile = Path.Combine(modelsDirectory, "models.hash"); + var modelsSrcFile = Path.Combine(modelsDirectory, "models.generated.cs"); + var projFile = Path.Combine(modelsDirectory, "all.generated.cs"); + var dllPathFile = Path.Combine(modelsDirectory, "all.dll.path"); + + // caching the generated models speeds up booting + // currentHash hashes both the types & the user's partials + + if (!forceRebuild) + { + _logger.Logger.Debug("Looking for cached models."); + if (File.Exists(modelsHashFile) && File.Exists(projFile)) + { + var cachedHash = File.ReadAllText(modelsHashFile); + if (currentHash != cachedHash) + { + _logger.Logger.Debug("Found obsolete cached models."); + forceRebuild = true; + } + } + else + { + _logger.Logger.Debug("Could not find cached models."); + forceRebuild = true; + } + } + + Assembly assembly; + if (forceRebuild == false) + { + // try to load the dll directly (avoid rebuilding) + if (File.Exists(dllPathFile)) + { + var dllPath = File.ReadAllText(dllPathFile); + if (File.Exists(dllPath)) + { + assembly = Assembly.LoadFile(dllPath); + var attr = assembly.GetCustomAttribute(); + if (attr != null && attr.PureLive && attr.SourceHash == currentHash) + { + // if we were to resume at that revision, then _ver would keep increasing + // and that is probably a bad idea - so, we'll always rebuild starting at + // ver 1, but we remember we want to skip that one - so we never end up + // with the "same but different" version of the assembly in memory + _skipver = assembly.GetName().Version.Revision; + + _logger.Logger.Debug("Loading cached models (dll)."); + return assembly; + } + } + } + + // mmust reset the version in the file else it would keep growing + // loading cached modules only happens when the app restarts + var text = File.ReadAllText(projFile); + var match = AssemblyVersionRegex.Match(text); + if (match.Success) + { + text = text.Replace(match.Value, "AssemblyVersion(\"0.0.0." + _ver + "\")"); + File.WriteAllText(projFile, text); + } + + // generate a marker file that will be a dependency + // see note in RazorBuildProvider_CodeGenerationStarted + // NO: using all.generated.cs as a dependency + //File.WriteAllText(Path.Combine(modelsDirectory, "models.dep"), "VER:" + _ver); + + _ver++; + assembly = BuildManager.GetCompiledAssembly(ProjVirt); + File.WriteAllText(dllPathFile, assembly.Location); + + _logger.Logger.Debug("Loading cached models (source)."); + return assembly; + } + + // need to rebuild + _logger.Logger.Debug("Rebuilding models."); + + // generate code, save + var code = GenerateModelsCode(ourFiles, typeModels); + // add extra attributes, + // PureLiveAssembly helps identifying Assemblies that contain PureLive models + // AssemblyVersion is so that we have a different version for each rebuild + var ver = _ver == _skipver ? ++_ver : _ver; + _ver++; + code = code.Replace("//ASSATTR", $@"[assembly: PureLiveAssembly] +[assembly:ModelsBuilderAssembly(PureLive = true, SourceHash = ""{currentHash}"")] +[assembly:System.Reflection.AssemblyVersion(""0.0.0.{ver}"")]"); + File.WriteAllText(modelsSrcFile, code); + + // generate proj, save + ourFiles["models.generated.cs"] = code; + var proj = GenerateModelsProj(ourFiles); + File.WriteAllText(projFile, proj); + + // compile and register + assembly = BuildManager.GetCompiledAssembly(ProjVirt); + File.WriteAllText(dllPathFile, assembly.Location); + + // assuming we can write and it's not going to cause exceptions... + File.WriteAllText(modelsHashFile, currentHash); + + _logger.Logger.Debug("Done rebuilding."); + return assembly; + } + + private static Infos RegisterModels(IEnumerable types) + { + var ctorArgTypes = new[] { typeof (IPublishedElement) }; + var modelInfos = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + var map = new Dictionary(); + + foreach (var type in types) + { + ConstructorInfo constructor = null; + Type parameterType = null; + + foreach (var ctor in type.GetConstructors()) + { + var parms = ctor.GetParameters(); + if (parms.Length == 1 && typeof (IPublishedElement).IsAssignableFrom(parms[0].ParameterType)) + { + if (constructor != null) + throw new InvalidOperationException($"Type {type.FullName} has more than one public constructor with one argument of type, or implementing, IPropertySet."); + constructor = ctor; + parameterType = parms[0].ParameterType; + } + } + + if (constructor == null) + throw new InvalidOperationException($"Type {type.FullName} is missing a public constructor with one argument of type, or implementing, IPropertySet."); + + var attribute = type.GetCustomAttribute(false); + var typeName = attribute == null ? type.Name : attribute.ContentTypeAlias; + + if (modelInfos.TryGetValue(typeName, out ModelInfo modelInfo)) + throw new InvalidOperationException($"Both types {type.FullName} and {modelInfo.ModelType.FullName} want to be a model type for content type with alias \"{typeName}\"."); + + // fixme use Core's ReflectionUtilities.EmitCtor !! + var meth = new DynamicMethod(string.Empty, typeof (IPublishedElement), ctorArgTypes, type.Module, true); + var gen = meth.GetILGenerator(); + gen.Emit(OpCodes.Ldarg_0); + gen.Emit(OpCodes.Newobj, constructor); + gen.Emit(OpCodes.Ret); + var func = (Func) meth.CreateDelegate(typeof (Func)); + + modelInfos[typeName] = new ModelInfo { ParameterType = parameterType, Ctor = func, ModelType = type }; + map[typeName] = type; + } + + return new Infos { ModelInfos = modelInfos.Count > 0 ? modelInfos : null, ModelTypeMap = map }; + } + + private static string GenerateModelsCode(IDictionary ourFiles, IList typeModels) + { + var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + + foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) + File.Delete(file); + + var map = typeModels.ToDictionary(x => x.Alias, x => x.ClrName); + foreach (var typeModel in typeModels) + { + foreach (var propertyModel in typeModel.Properties) + { + propertyModel.ClrTypeName = ModelType.MapToName(propertyModel.ModelClrType, map); + } + } + + var parseResult = new CodeParser().ParseWithReferencedAssemblies(ourFiles); + var builder = new TextBuilder(typeModels, parseResult, UmbracoConfig.For.ModelsBuilder().ModelsNamespace); + + var codeBuilder = new StringBuilder(); + builder.Generate(codeBuilder, builder.GetModelsToGenerate()); + var code = codeBuilder.ToString(); + + return code; + } + + private static readonly Regex UsingRegex = new Regex("^using(.*);", RegexOptions.Compiled | RegexOptions.Multiline); + private static readonly Regex AattrRegex = new Regex("^\\[assembly:(.*)\\]", RegexOptions.Compiled | RegexOptions.Multiline); + + private static string GenerateModelsProj(IDictionary files) + { + // ideally we would generate a CSPROJ file but then we'd need a BuildProvider for csproj + // trying to keep things simple for the time being, just write everything to one big file + + // group all 'using' at the top of the file (else fails) + var usings = new List(); + foreach (var k in files.Keys.ToList()) + files[k] = UsingRegex.Replace(files[k], m => + { + usings.Add(m.Groups[1].Value); + return string.Empty; + }); + + // group all '[assembly:...]' at the top of the file (else fails) + var aattrs = new List(); + foreach (var k in files.Keys.ToList()) + files[k] = AattrRegex.Replace(files[k], m => + { + aattrs.Add(m.Groups[1].Value); + return string.Empty; + }); + + var text = new StringBuilder(); + foreach (var u in usings.Distinct()) + { + text.Append("using "); + text.Append(u); + text.Append(";\r\n"); + } + foreach (var a in aattrs) + { + text.Append("[assembly:"); + text.Append(a); + text.Append("]\r\n"); + } + text.Append("\r\n\r\n"); + foreach (var f in files) + { + text.Append("// FILE: "); + text.Append(f.Key); + text.Append("\r\n\r\n"); + text.Append(f.Value); + text.Append("\r\n\r\n\r\n"); + } + text.Append("// EOF\r\n"); + + return text.ToString(); + } + + internal class Infos + { + public Dictionary ModelTypeMap { get; set; } + public Dictionary ModelInfos { get; set; } + } + + internal class ModelInfo + { + public Type ParameterType { get; set; } + public Func Ctor { get; set; } + public Type ModelType { get; set; } + public Func ListCtor { get; set; } + } + + #endregion + + #region Watching + + private void WatcherOnChanged(object sender, FileSystemEventArgs args) + { + var changed = args.Name; + + // don't reset when our files change because we are building! + // + // comment it out, and always ignore our files, because it seems that some + // race conditions can occur on slow Cloud filesystems and then we keep + // rebuilding + + //if (_building && OurFiles.Contains(changed)) + //{ + // //_logger.Logger.Info("Ignoring files self-changes."); + // return; + //} + + // always ignore our own file changes + if (OurFiles.Contains(changed)) + return; + + _logger.Logger.Info("Detected files changes."); + + ResetModels(); + } + + public void Stop(bool immediate) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + HostingEnvironment.UnregisterObject(this); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs b/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs new file mode 100644 index 0000000000..20f5e94b64 --- /dev/null +++ b/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Configuration; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.ModelsBuilder.Configuration; +using Umbraco.Web.Editors; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.ModelsBuilder.Validation +{ + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + internal class ContentTypeModelValidator : ContentTypeModelValidatorBase + { + } + + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + internal class MediaTypeModelValidator : ContentTypeModelValidatorBase + { + } + + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + internal class MemberTypeModelValidator : ContentTypeModelValidatorBase + { + } + + internal abstract class ContentTypeModelValidatorBase : EditorValidator + where TModel: ContentTypeSave + where TProperty: PropertyTypeBasic + { + protected override IEnumerable Validate(TModel model) + { + //don't do anything if we're not enabled + if (UmbracoConfig.For.ModelsBuilder().Enable) + { + var properties = model.Groups.SelectMany(x => x.Properties) + .Where(x => x.Inherited == false) + .ToArray(); + + foreach (var prop in properties) + { + var propertyGroup = model.Groups.Single(x => x.Properties.Contains(prop)); + + if (model.Alias.ToLowerInvariant() == prop.Alias.ToLowerInvariant()) + yield return new ValidationResult(string.Format("With Models Builder enabled, you can't have a property with a the alias \"{0}\" when the content type alias is also \"{0}\".", prop.Alias), new[] + { + string.Format("Groups[{0}].Properties[{1}].Alias", model.Groups.IndexOf(propertyGroup), propertyGroup.Properties.IndexOf(prop)) + }); + + //we need to return the field name with an index so it's wired up correctly + var groupIndex = model.Groups.IndexOf(propertyGroup); + var propertyIndex = propertyGroup.Properties.IndexOf(prop); + + var validationResult = ValidateProperty(prop, groupIndex, propertyIndex); + if (validationResult != null) + { + yield return validationResult; + } + } + } + } + + private ValidationResult ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) + { + //don't let them match any properties or methods in IPublishedContent + //TODO: There are probably more! + var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); + var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); + + var alias = property.Alias; + + if (reservedProperties.InvariantContains(alias) || reservedMethods.InvariantContains(alias)) + { + return new ValidationResult( + string.Format("The alias {0} is a reserved term and cannot be used", alias), new[] + { + string.Format("Groups[{0}].Properties[{1}].Alias", groupIndex, propertyIndex) + }); + } + + return null; + } + } +} diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 463307b235..89151e65f4 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -109,7 +109,6 @@ - @@ -120,6 +119,10 @@ Umbraco.Examine {07FBC26B-2927-4A22-8D96-D644C667FECC} + + {7020a059-c0d1-43a0-8efd-23591a0c9af6} + Umbraco.ModelsBuilder + {651e1350-91b6-44b7-bd60-7207006d7003} Umbraco.Web diff --git a/src/umbraco.sln b/src/umbraco.sln index f21b257639..0f0ef0e523 100644 --- a/src/umbraco.sln +++ b/src/umbraco.sln @@ -94,6 +94,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "About", "About", "{420D2458 ..\V8_GETTING_STARTED.md = ..\V8_GETTING_STARTED.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.ModelsBuilder", "Umbraco.ModelsBuilder\Umbraco.ModelsBuilder.csproj", "{7020A059-C0D1-43A0-8EFD-23591A0C9AF6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -126,6 +128,10 @@ Global {86DEB346-089F-4106-89C8-D852B9CF2A33}.Debug|Any CPU.Build.0 = Debug|Any CPU {86DEB346-089F-4106-89C8-D852B9CF2A33}.Release|Any CPU.ActiveCfg = Release|Any CPU {86DEB346-089F-4106-89C8-D852B9CF2A33}.Release|Any CPU.Build.0 = Release|Any CPU + {7020A059-C0D1-43A0-8EFD-23591A0C9AF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7020A059-C0D1-43A0-8EFD-23591A0C9AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7020A059-C0D1-43A0-8EFD-23591A0C9AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7020A059-C0D1-43A0-8EFD-23591A0C9AF6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE