diff --git a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureUmbracoSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureUmbracoSwaggerGenOptions.cs index f485c5ed73..572a747179 100644 --- a/src/Umbraco.Cms.Api.Common/Configuration/ConfigureUmbracoSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Common/Configuration/ConfigureUmbracoSwaggerGenOptions.cs @@ -12,16 +12,22 @@ namespace Umbraco.Cms.Api.Common.Configuration; public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions { - private readonly IOptions _apiVersioningOptions; private readonly IOperationIdSelector _operationIdSelector; private readonly ISchemaIdSelector _schemaIdSelector; + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 15.")] public ConfigureUmbracoSwaggerGenOptions( IOptions apiVersioningOptions, IOperationIdSelector operationIdSelector, ISchemaIdSelector schemaIdSelector) + : this(operationIdSelector, schemaIdSelector) + { + } + + public ConfigureUmbracoSwaggerGenOptions( + IOperationIdSelector operationIdSelector, + ISchemaIdSelector schemaIdSelector) { - _apiVersioningOptions = apiVersioningOptions; _operationIdSelector = operationIdSelector; _schemaIdSelector = schemaIdSelector; } @@ -34,10 +40,10 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions _operationIdSelector.OperationId(description, _apiVersioningOptions.Value)); + swaggerGenOptions.CustomOperationIds(description => _operationIdSelector.OperationId(description)); swaggerGenOptions.DocInclusionPredicate((name, api) => { if (string.IsNullOrWhiteSpace(api.GroupName)) diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs index 4826da71e3..6aa0af430b 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderApiExtensions.cs @@ -15,7 +15,9 @@ public static class UmbracoBuilderApiExtensions builder.Services.ConfigureOptions(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.Configure(options => options.AddFilter(new SwaggerRouteTemplatePipelineFilter("UmbracoApiCommon"))); return builder; diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdHandler.cs b/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdHandler.cs new file mode 100644 index 0000000000..e966c3b2e2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdHandler.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Umbraco.Cms.Api.Common.OpenApi; + +public interface IOperationIdHandler +{ + bool CanHandle(ApiDescription apiDescription); + + string Handle(ApiDescription apiDescription); +} diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdSelector.cs b/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdSelector.cs index ff517b7b5c..3f97e78165 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdSelector.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/IOperationIdSelector.cs @@ -5,5 +5,8 @@ namespace Umbraco.Cms.Api.Common.OpenApi; public interface IOperationIdSelector { + [Obsolete("Use overload that only takes ApiDescription instead. This will be removed in Umbraco 15.")] string? OperationId(ApiDescription apiDescription, ApiVersioningOptions apiVersioningOptions); + + string? OperationId(ApiDescription apiDescription); } diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/ISchemaIdHandler.cs b/src/Umbraco.Cms.Api.Common/OpenApi/ISchemaIdHandler.cs new file mode 100644 index 0000000000..81881f121a --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/OpenApi/ISchemaIdHandler.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Common.OpenApi; + +public interface ISchemaIdHandler +{ + bool CanHandle(Type type); + + string Handle(Type type); +} diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdHandler.cs b/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdHandler.cs new file mode 100644 index 0000000000..8fe6c7dff4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdHandler.cs @@ -0,0 +1,94 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.Options; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Common.OpenApi; + +// NOTE: Left unsealed on purpose, so it is extendable. +public class OperationIdHandler : IOperationIdHandler +{ + private readonly ApiVersioningOptions _apiVersioningOptions; + + public OperationIdHandler(IOptions apiVersioningOptions) + => _apiVersioningOptions = apiVersioningOptions.Value; + + public bool CanHandle(ApiDescription apiDescription) + { + if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) + { + return false; + } + + return CanHandle(apiDescription, controllerActionDescriptor); + } + + protected virtual bool CanHandle(ApiDescription apiDescription, ControllerActionDescriptor controllerActionDescriptor) + => controllerActionDescriptor.ControllerTypeInfo.Namespace?.StartsWith("Umbraco.Cms.Api") is true; + + public virtual string Handle(ApiDescription apiDescription) + => UmbracoOperationId(apiDescription); + + /// + /// Generates a unique operation identifier for a given API following Umbraco's operation id naming conventions. + /// + protected string UmbracoOperationId(ApiDescription apiDescription) + { + if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) + { + throw new ArgumentException($"This handler operates only on {nameof(ControllerActionDescriptor)}."); + } + + ApiVersion defaultVersion = _apiVersioningOptions.DefaultApiVersion; + var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get"; + + // if the route info "Name" is supplied we'll use this explicitly as the operation ID + // - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")] + if (string.IsNullOrWhiteSpace(apiDescription.ActionDescriptor.AttributeRouteInfo?.Name) == false) + { + var explicitOperationId = apiDescription.ActionDescriptor.AttributeRouteInfo!.Name; + return explicitOperationId.InvariantStartsWith(httpMethod) + ? explicitOperationId + : $"{httpMethod}{explicitOperationId}"; + } + + var relativePath = apiDescription.RelativePath; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException( + $"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}"); + } + + // Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id} + var unprefixedRelativePath = OperationIdRegexes + .VersionPrefixRegex() + .Replace(relativePath, string.Empty); + + // Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id + var formattedOperationId = OperationIdRegexes + .TemplatePlaceholdersRegex() + .Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}"); + + // Remove dashes (-) and slashes (/) and convert the following letter to uppercase with + // the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById + formattedOperationId = OperationIdRegexes + .ToCamelCaseRegex() + .Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper()); + + // Get map to version attribute + string? version = null; + + var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue(); + + // We only want to add a version, if it is not the default one. + if (string.Equals(versionAttributeValue, defaultVersion.ToString()) == false) + { + version = versionAttributeValue; + } + + // Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById + return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}"; + } +} diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdSelector.cs b/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdSelector.cs index 908c8c8a92..3c00a126be 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdSelector.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/OperationIdSelector.cs @@ -1,99 +1,27 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Common.OpenApi; public class OperationIdSelector : IOperationIdSelector { - private readonly UmbracoOperationIdSettings _umbracoOperationIdSettings; + private readonly IEnumerable _operationIdHandlers; - [Obsolete("Use non obsolete constructor")] - public OperationIdSelector() : this(StaticServiceProvider.Instance.GetRequiredService>()) + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 15.")] + public OperationIdSelector() + : this(Enumerable.Empty()) { } - public OperationIdSelector(IOptions umbracoOperationIdSettings) + public OperationIdSelector(IEnumerable operationIdHandlers) + => _operationIdHandlers = operationIdHandlers; + + [Obsolete("Use overload that only takes ApiDescription instead. This will be removed in Umbraco 15.")] + public virtual string? OperationId(ApiDescription apiDescription, ApiVersioningOptions apiVersioningOptions) => OperationId(apiDescription); + + public virtual string? OperationId(ApiDescription apiDescription) { - _umbracoOperationIdSettings = umbracoOperationIdSettings.Value; - } - - public virtual string? OperationId(ApiDescription apiDescription, ApiVersioningOptions apiVersioningOptions) - { - if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) - { - return null; - } - - var controllerTypeInfoNamespace = controllerActionDescriptor.ControllerTypeInfo.Namespace; - if (controllerTypeInfoNamespace is not null && _umbracoOperationIdSettings.NameSpacePrefixes.Any(prefix => controllerTypeInfoNamespace.StartsWith(prefix)) is false) - { - return null; - } - - return UmbracoOperationId(apiDescription, apiVersioningOptions); - } - - protected string? UmbracoOperationId(ApiDescription apiDescription, ApiVersioningOptions apiVersioningOptions) - { - if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) - { - return null; - } - - ApiVersion defaultVersion = apiVersioningOptions.DefaultApiVersion; - var httpMethod = apiDescription.HttpMethod?.ToLower().ToFirstUpper() ?? "Get"; - - // if the route info "Name" is supplied we'll use this explicitly as the operation ID - // - usage example: [HttpGet("my-api/route}", Name = "MyCustomRoute")] - if (string.IsNullOrWhiteSpace(apiDescription.ActionDescriptor.AttributeRouteInfo?.Name) == false) - { - var explicitOperationId = apiDescription.ActionDescriptor.AttributeRouteInfo!.Name; - return explicitOperationId.InvariantStartsWith(httpMethod) - ? explicitOperationId - : $"{httpMethod}{explicitOperationId}"; - } - - var relativePath = apiDescription.RelativePath; - - if (string.IsNullOrWhiteSpace(relativePath)) - { - throw new Exception( - $"There is no relative path for controller action {apiDescription.ActionDescriptor.RouteValues["controller"]}"); - } - - // Remove the prefixed base path with version, e.g. /umbraco/management/api/v1/tracked-reference/{id} => tracked-reference/{id} - var unprefixedRelativePath = OperationIdRegexes - .VersionPrefixRegex() - .Replace(relativePath, string.Empty); - - // Remove template placeholders, e.g. tracked-reference/{id} => tracked-reference/Id - var formattedOperationId = OperationIdRegexes - .TemplatePlaceholdersRegex() - .Replace(unprefixedRelativePath, m => $"By{m.Groups[1].Value.ToFirstUpper()}"); - - // Remove dashes (-) and slashes (/) and convert the following letter to uppercase with - // the word "By" in front, e.g. tracked-reference/Id => TrackedReferenceById - formattedOperationId = OperationIdRegexes - .ToCamelCaseRegex() - .Replace(formattedOperationId, m => m.Groups[1].Value.ToUpper()); - - //Get map to version attribute - string? version = null; - - var versionAttributeValue = controllerActionDescriptor.MethodInfo.GetMapToApiVersionAttributeValue(); - - // We only wanna add a version, if it is not the default one. - if (string.Equals(versionAttributeValue, defaultVersion.ToString()) == false) - { - version = versionAttributeValue; - } - - // Return the operation ID with the formatted http method verb in front, e.g. GetTrackedReferenceById - return $"{httpMethod}{formattedOperationId.ToFirstUpper()}{version}"; + IOperationIdHandler? handler = _operationIdHandlers.FirstOrDefault(h => h.CanHandle(apiDescription)); + return handler?.Handle(apiDescription); } } diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs new file mode 100644 index 0000000000..c08e0be19a --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdHandler.cs @@ -0,0 +1,51 @@ +using System.Text.RegularExpressions; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Common.OpenApi; + +// NOTE: Left unsealed on purpose, so it is extendable. +public class SchemaIdHandler : ISchemaIdHandler +{ + public virtual bool CanHandle(Type type) + => type.Namespace?.StartsWith("Umbraco.Cms") is true; + + public virtual string Handle(Type type) + => UmbracoSchemaId(type); + + /// + /// Generates a sanitized and consistent schema identifier for a given type following Umbraco's schema id naming conventions. + /// + protected string UmbracoSchemaId(Type type) + { + var name = SanitizedTypeName(type); + + name = HandleGenerics(name, type); + + if (name.EndsWith("Model") == false) + { + // because some models names clash with common classes in TypeScript (i.e. Document), + // we need to add a "Model" postfix to all models + name = $"{name}Model"; + } + + // make absolutely sure we don't pass any invalid named by removing all non-word chars + return Regex.Replace(name, @"[^\w]", string.Empty); + } + + private string SanitizedTypeName(Type t) => t.Name + // first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel") + .Split('`').First() + // then remove the "ViewModel" postfix from type names + .TrimEnd("ViewModel"); + + private string HandleGenerics(string name, Type type) + { + if (!type.IsGenericType) + { + return name; + } + + // use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel" into "PagedRelationItem" + return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}"; + } +} diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdSelector.cs b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdSelector.cs index a97d192697..4ff28c7959 100644 --- a/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdSelector.cs +++ b/src/Umbraco.Cms.Api.Common/OpenApi/SchemaIdSelector.cs @@ -1,45 +1,15 @@ -using System.Text.RegularExpressions; -using Umbraco.Cms.Api.Common.Attributes; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Api.Common.OpenApi; +namespace Umbraco.Cms.Api.Common.OpenApi; public class SchemaIdSelector : ISchemaIdSelector { + private readonly IEnumerable _schemaIdHandlers; + + public SchemaIdSelector(IEnumerable schemaIdHandlers) + => _schemaIdHandlers = schemaIdHandlers; + public virtual string SchemaId(Type type) - => type.Namespace?.StartsWith("Umbraco.Cms") is true ? UmbracoSchemaId(type) : type.Name; - - protected string UmbracoSchemaId(Type type) { - var name = SanitizedTypeName(type); - - name = HandleGenerics(name, type); - - if (name.EndsWith("Model") == false) - { - // because some models names clash with common classes in TypeScript (i.e. Document), - // we need to add a "Model" postfix to all models - name = $"{name}Model"; - } - - // make absolutely sure we don't pass any invalid named by removing all non-word chars - return Regex.Replace(name, @"[^\w]", string.Empty); - } - - private string SanitizedTypeName(Type t) => t.Name - // first grab the "non generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel") - .Split('`').First() - // then remove the "ViewModel" postfix from type names - .TrimEnd("ViewModel"); - - private string HandleGenerics(string name, Type type) - { - if (!type.IsGenericType) - { - return name; - } - - // use attribute custom name or append the generic type names, ultimately turning i.e. "PagedViewModel" into "PagedRelationItem" - return $"{name}{string.Join(string.Empty, type.GenericTypeArguments.Select(SanitizedTypeName))}"; + ISchemaIdHandler? handler = _schemaIdHandlers.FirstOrDefault(h => h.CanHandle(type)); + return handler?.Handle(type) ?? type.Name; } } diff --git a/src/Umbraco.Cms.Api.Common/OpenApi/UmbracoOperationIdSettings.cs b/src/Umbraco.Cms.Api.Common/OpenApi/UmbracoOperationIdSettings.cs deleted file mode 100644 index 6b7ee622a0..0000000000 --- a/src/Umbraco.Cms.Api.Common/OpenApi/UmbracoOperationIdSettings.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Umbraco.Cms.Api.Common.OpenApi; - -public class UmbracoOperationIdSettings -{ - private HashSet _nameSpacePrefixes = new HashSet() - { - "Umbraco.Cms.Api" - }; - - public IReadOnlySet NameSpacePrefixes - { - get => _nameSpacePrefixes; - } - - public bool AddNameSpacePrefix(string prefix) => _nameSpacePrefixes.Add(prefix); -}