diff --git a/API/Controller/Admin/_ApiController.cs b/API/Controller/Admin/_ApiController.cs index 6bcb2d71..134ce0d5 100644 --- a/API/Controller/Admin/_ApiController.cs +++ b/API/Controller/Admin/_ApiController.cs @@ -9,6 +9,7 @@ namespace OpenShock.API.Controller.Admin; [ApiController] [Tags("Admin")] +[EndpointGroupName("admin")] [Route("/{version:apiVersion}/admin")] [Authorize(AuthenticationSchemes = OpenShockAuthSchemes.UserSessionCookie, Roles = "Admin")] public sealed partial class AdminController : AuthenticatedSessionControllerBase diff --git a/API/Controller/OAuth/Authorize.cs b/API/Controller/OAuth/Authorize.cs index 05de0fdd..60ebf430 100644 --- a/API/Controller/OAuth/Authorize.cs +++ b/API/Controller/OAuth/Authorize.cs @@ -20,7 +20,6 @@ public sealed partial class OAuthController /// Unsupported or misconfigured provider. [EnableRateLimiting("auth")] [HttpPost("{provider}/authorize")] - [ApiExplorerSettings(IgnoreApi = true)] public async Task OAuthAuthorize([FromRoute] string provider, [FromQuery(Name="flow"), Required] OAuthFlow flow) { if (!await _schemeProvider.IsSupportedOAuthScheme(provider)) diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 6d673f85..7c3e20ad 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -21,7 +21,6 @@ public sealed partial class OAuthController /// [EnableRateLimiting("auth")] [HttpGet("{provider}/handoff")] - [ApiExplorerSettings(IgnoreApi = true)] public async Task OAuthHandOff( [FromRoute] string provider, [FromServices] IOAuthConnectionService connectionService, diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 4f0d5ef4..c5dc2127 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -26,7 +26,6 @@ public sealed partial class OAuthController /// [EnableRateLimiting("auth")] [HttpPost("{provider}/signup-finalize")] - [ApiExplorerSettings(IgnoreApi = true)] public async Task OAuthSignupFinalize( [FromRoute] string provider, [FromBody] OAuthFinalizeRequest body, diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs index 0bfc3b47..3abfcb70 100644 --- a/API/Controller/OAuth/SignupGetData.cs +++ b/API/Controller/OAuth/SignupGetData.cs @@ -22,7 +22,6 @@ public sealed partial class OAuthController [ResponseCache(NoStore = true)] [EnableRateLimiting("auth")] [HttpGet("{provider}/signup-data")] - [ApiExplorerSettings(IgnoreApi = true)] public async Task OAuthSignupGetData([FromRoute] string provider) { if (User.HasOpenShockUserIdentity()) diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 05486d31..4251a97b 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -13,9 +13,8 @@ namespace OpenShock.API.Controller.OAuth; /// OAuth management endpoints (provider listing, authorize, data handoff). /// [ApiController] -[Tags("OAuth")] -[ApiVersion("1")] -[Route("/{version:apiVersion}/oauth")] +[EndpointGroupName("oauth")] +[Route("/{version:apiVersion}/oauth"), ApiVersion("1")] public sealed partial class OAuthController : OpenShockControllerBase { private readonly IAccountService _accountService; diff --git a/API/Program.cs b/API/Program.cs index 6001ef7a..561dc667 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -12,10 +12,10 @@ using OpenShock.Common; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; +using OpenShock.Common.OpenAPI; using OpenShock.Common.Services; using OpenShock.Common.Services.Device; using OpenShock.Common.Services.Ota; -using OpenShock.Common.Swagger; using Serilog; using OAuthConstants = OpenShock.API.OAuth.OAuthConstants; @@ -103,7 +103,7 @@ static void DefaultOptions(RemoteAuthenticationOptions options, string provider) builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.AddSwaggerExt(); +builder.AddOpenApiExt(); builder.AddCloudflareTurnstileService(); builder.AddEmailService(); diff --git a/Common/Common.csproj b/Common/Common.csproj index 8c6c3174..accb64d8 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -13,6 +13,7 @@ + @@ -31,7 +32,6 @@ - diff --git a/Common/DataAnnotations/EmailAddressAttribute.cs b/Common/DataAnnotations/EmailAddressAttribute.cs index cf7a3ec0..475a813a 100644 --- a/Common/DataAnnotations/EmailAddressAttribute.cs +++ b/Common/DataAnnotations/EmailAddressAttribute.cs @@ -1,9 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Net.Mail; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; using OpenShock.Common.Constants; -using OpenShock.Common.DataAnnotations.Interfaces; namespace OpenShock.Common.DataAnnotations; @@ -13,8 +10,8 @@ namespace OpenShock.Common.DataAnnotations; /// /// Inherits from . /// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] -public sealed class EmailAddressAttribute : ValidationAttribute, IParameterAttribute +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class EmailAddressAttribute : ValidationAttribute { /// /// Example value used to generate OpenApi documentation. @@ -55,15 +52,4 @@ public sealed class EmailAddressAttribute : ValidationAttribute, IParameterAttri return ValidationResult.Success; } - - /// - public void Apply(OpenApiSchema schema) - { - //if (ShouldValidate) schema.Pattern = ???; - - schema.Example = new OpenApiString(ExampleValue); - } - - /// - public void Apply(OpenApiParameter parameter) => Apply(parameter.Schema); } \ No newline at end of file diff --git a/Common/DataAnnotations/Interfaces/IOperationAttribute.cs b/Common/DataAnnotations/Interfaces/IOperationAttribute.cs deleted file mode 100644 index d47bd156..00000000 --- a/Common/DataAnnotations/Interfaces/IOperationAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.OpenApi.Models; - -namespace OpenShock.Common.DataAnnotations.Interfaces; - -/// -/// Represents an interface for operation attributes that can be applied to an OpenApiOperation instance. -/// -public interface IOperationAttribute -{ - /// - /// Applies the operation attribute to the given OpenApiOperation instance. - /// - /// The OpenApiOperation instance to apply the attribute to. - void Apply(OpenApiOperation operation); -} \ No newline at end of file diff --git a/Common/DataAnnotations/Interfaces/IParameterAttribute.cs b/Common/DataAnnotations/Interfaces/IParameterAttribute.cs deleted file mode 100644 index 295f4d5e..00000000 --- a/Common/DataAnnotations/Interfaces/IParameterAttribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.OpenApi.Models; - -namespace OpenShock.Common.DataAnnotations.Interfaces; - -/// -/// Represents an interface for parameter attributes that can be applied to an OpenApiSchema or OpenApiParameter instance. -/// -public interface IParameterAttribute -{ - /// - /// Applies the parameter attribute to the given OpenApiSchema instance. - /// - /// The OpenApiSchema instance to apply the attribute to. - void Apply(OpenApiSchema schema); - - /// - /// Applies the parameter attribute to the given OpenApiParameter instance. - /// - /// The OpenApiParameter instance to apply the attribute to. - void Apply(OpenApiParameter parameter); -} \ No newline at end of file diff --git a/Common/DataAnnotations/OpenApiSchemas.cs b/Common/DataAnnotations/OpenApiSchemas.cs deleted file mode 100644 index 69651381..00000000 --- a/Common/DataAnnotations/OpenApiSchemas.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; -using OpenShock.Common.Models; - -namespace OpenShock.Common.DataAnnotations; - -public static class OpenApiSchemas -{ - public static OpenApiSchema SemVerSchema => new OpenApiSchema { - Title = "SemVer", - Type = "string", - Pattern = /* lang=regex */ "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", - Example = new OpenApiString("1.0.0-dev+a16f2") - }; - - public static OpenApiSchema PauseReasonEnumSchema => new OpenApiSchema { - Title = nameof(PauseReason), - Type = "integer", - Description = """ - An integer representing the reason(s) for the shocker being paused, expressed as a bitfield where reasons are OR'd together. - - Each bit corresponds to: - - 1: Shocker - - 2: UserShare - - 4: PublicShare - - For example, a value of 6 (2 | 4) indicates both 'UserShare' and 'PublicShare' reasons. - """, - Example = new OpenApiInteger(6) - }; -} diff --git a/Common/DataAnnotations/PasswordAttribute.cs b/Common/DataAnnotations/PasswordAttribute.cs index ca42b60a..b6dca5bd 100644 --- a/Common/DataAnnotations/PasswordAttribute.cs +++ b/Common/DataAnnotations/PasswordAttribute.cs @@ -1,8 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; using OpenShock.Common.Constants; -using OpenShock.Common.DataAnnotations.Interfaces; namespace OpenShock.Common.DataAnnotations; @@ -12,8 +9,8 @@ namespace OpenShock.Common.DataAnnotations; /// /// Inherits from . /// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] -public sealed class PasswordAttribute : ValidationAttribute, IParameterAttribute +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class PasswordAttribute : ValidationAttribute { /// /// Example value used to generate OpenApi documentation. @@ -54,15 +51,4 @@ public sealed class PasswordAttribute : ValidationAttribute, IParameterAttribute return ValidationResult.Success; } - - /// - public void Apply(OpenApiSchema schema) - { - //if (ShouldValidate) schema.Pattern = ???; - - schema.Example = new OpenApiString(ExampleValue); - } - - /// - public void Apply(OpenApiParameter parameter) => Apply(parameter.Schema); } \ No newline at end of file diff --git a/Common/DataAnnotations/StringCollectionItemMaxLengthAttribute.cs b/Common/DataAnnotations/StringCollectionItemMaxLengthAttribute.cs index 08cb6eea..55fd7113 100644 --- a/Common/DataAnnotations/StringCollectionItemMaxLengthAttribute.cs +++ b/Common/DataAnnotations/StringCollectionItemMaxLengthAttribute.cs @@ -2,7 +2,7 @@ namespace OpenShock.Common.DataAnnotations; -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false, Inherited = true)] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] public sealed class StringCollectionItemMaxLengthAttribute : ValidationAttribute { public StringCollectionItemMaxLengthAttribute(int maxLength) diff --git a/Common/DataAnnotations/UsernameAttribute.cs b/Common/DataAnnotations/UsernameAttribute.cs index c0b366d7..03572ed2 100644 --- a/Common/DataAnnotations/UsernameAttribute.cs +++ b/Common/DataAnnotations/UsernameAttribute.cs @@ -1,7 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; -using OpenShock.Common.DataAnnotations.Interfaces; using OpenShock.Common.Validation; namespace OpenShock.Common.DataAnnotations; @@ -12,8 +9,8 @@ namespace OpenShock.Common.DataAnnotations; /// /// Inherits from . /// -[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] -public sealed class UsernameAttribute : ValidationAttribute, IParameterAttribute +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public sealed class UsernameAttribute : ValidationAttribute { /// /// Example value used to generate OpenApi documentation. @@ -50,15 +47,4 @@ public sealed class UsernameAttribute : ValidationAttribute, IParameterAttribute error => new ValidationResult($"{error.Type} - {error.Message}") ); } - - /// - public void Apply(OpenApiSchema schema) - { - //if (ShouldValidate) schema.Pattern = ???; - - schema.Example = new OpenApiString(ExampleValue); - } - - /// - public void Apply(OpenApiParameter parameter) => Apply(parameter.Schema); } \ No newline at end of file diff --git a/Common/OpenAPI/DocumentDefaults.cs b/Common/OpenAPI/DocumentDefaults.cs new file mode 100644 index 00000000..ada54816 --- /dev/null +++ b/Common/OpenAPI/DocumentDefaults.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace OpenShock.Common.OpenAPI; + +public static class DocumentDefaults +{ + public static Func GetDocumentTransformer(string version) + { + return (document, context, _) => + { + var env = context.ApplicationServices.GetRequiredService(); + + document.Info = new OpenApiInfo + { + Title = "OpenShock.API", + // Summary = ... + // Description = ... + Version = version, + // TermsOfService = ... + // Contact = ... + // License = ... + }; + + document.Servers = + [ + new OpenApiServer { Url = "https://api.openshock.app" }, + new OpenApiServer { Url = "https://api.openshock.dev" } + ]; + if (env.IsDevelopment()) + { + document.Servers.Add(new OpenApiServer { Url = "https://localhost" }); + } + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = new Dictionary + { + { + "ApiToken", + new OpenApiSecurityScheme + { + Name = "OpenShockToken", + Description = "Enter API Token", + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header + } + }, + { + "HubToken", + new OpenApiSecurityScheme + { + Name = "DeviceToken", + Description = "Enter hub token", + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header + } + }, + { + "UserSessionCookie", + new OpenApiSecurityScheme + { + Name = "openShockSession", + Description = "Enter user session cookie", + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie + } + } + }; + + return Task.CompletedTask; + }; + } +} \ No newline at end of file diff --git a/Common/OpenAPI/OpenApiExtensions.cs b/Common/OpenAPI/OpenApiExtensions.cs new file mode 100644 index 00000000..93077af9 --- /dev/null +++ b/Common/OpenAPI/OpenApiExtensions.cs @@ -0,0 +1,158 @@ +using System.Text.Json.Serialization.Metadata; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using OpenShock.Common.Models; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.OpenAPI; + +public static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApiExt(this WebApplicationBuilder builder) where TProgram : class + { + builder.Services.AddOutputCache(options => + { + options.AddPolicy("OpenAPI", policy => policy.Expire(TimeSpan.FromMinutes(10))); + }); + + builder.Services.AddOpenApi("v1", options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.AddDocumentTransformer(DocumentDefaults.GetDocumentTransformer(version: "1")); + options.CreateSchemaReferenceId = GetCleanName; + options.AddOperationTransformer(OperationTransformer); + }); + + builder.Services.AddOpenApi("v2", options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.AddDocumentTransformer(DocumentDefaults.GetDocumentTransformer(version: "2")); + options.CreateSchemaReferenceId = GetCleanName; + options.AddOperationTransformer(OperationTransformer); + }); + + builder.Services.AddOpenApi("oauth", options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.ShouldInclude = apiDescription => apiDescription.GroupName is "oauth"; + options.AddDocumentTransformer(DocumentDefaults.GetDocumentTransformer(version: "1")); + options.CreateSchemaReferenceId = GetCleanName; + options.AddOperationTransformer(OperationTransformer); + }); + builder.Services.AddOpenApi("admin", options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + options.ShouldInclude = apiDescription => apiDescription.GroupName is "admin"; + options.AddDocumentTransformer(DocumentDefaults.GetDocumentTransformer(version: "1")); + options.CreateSchemaReferenceId = GetCleanName; + options.AddOperationTransformer(OperationTransformer); + }); + + return builder.Services; + } + private static string RemoveResponseSuffix(string name) + { + string? value; + if (StringUtils.TryRemoveSuffix(name, "LegacyResponse", out value)) return value; + if (StringUtils.TryRemoveSuffix(name, "Response", out value)) return value; + return name; + } + + private static string? GetCleanName(JsonTypeInfo type) + { + if (!type.Type.IsEnum && Type.GetTypeCode(type.Type) is TypeCode.Char or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 + or TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single + or TypeCode.Double or TypeCode.Decimal) + { + return null; + } + + return GetCleanNameRecursive(type.Type); + } + + private static string GetCleanNameRecursive(Type type) + { + if (Nullable.GetUnderlyingType(type) is { } underlying) + { + type = underlying; + } + + if (type.IsEnum || Type.GetTypeCode(type) is not TypeCode.Object) + { + return type.Name; + } + + // Handle arrays + if (type.IsArray) + { + return RemoveResponseSuffix(GetCleanNameRecursive(type.GetElementType()!)) + "Array"; + } + + var isGeneric = type.IsGenericType; + if (!isGeneric) + { + return type.Name; + } + + var genericTypeDef = type.GetGenericTypeDefinition(); + if (genericTypeDef == typeof(List<>) || + genericTypeDef == typeof(IList<>) || + genericTypeDef == typeof(HashSet<>) || + genericTypeDef == typeof(IEnumerable<>) || + genericTypeDef == typeof(IAsyncEnumerable<>)) + { + return RemoveResponseSuffix(GetCleanNameRecursive(type.GetGenericArguments()[0])) + "Array"; + } + + if (genericTypeDef == typeof(Dictionary<,>)) + { + return $"DictionaryOf{GetCleanNameRecursive(type.GetGenericArguments()[0])}And{GetCleanNameRecursive(type.GetGenericArguments()[1])}"; + } + + + if (genericTypeDef == typeof(LegacyDataResponse<>)) + { + return RemoveResponseSuffix(GetCleanNameRecursive(type.GetGenericArguments()[0])) + "LegacyResponse"; + } + + // Handle generic types + var genericArgs = string.Join("And", type.GetGenericArguments().Select(GetCleanNameRecursive)); + + var name = type.Name.AsSpan(); + var backtickIndex = name.IndexOf('`'); + if (backtickIndex > 0) + { + name = name[..backtickIndex]; + } + + return $"{name}Of{genericArgs}"; + } + + private static Task OperationTransformer(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var actionDescriptor = context.Description.ActionDescriptor; + + // Use endpoint name if available + var endpointName = actionDescriptor.EndpointMetadata.OfType() + .FirstOrDefault() + ?.EndpointName; + + if (!string.IsNullOrEmpty(endpointName)) + { + operation.OperationId = endpointName; + return Task.CompletedTask; + } + + // For controllers + var controller = actionDescriptor.RouteValues.TryGetValue("controller", out var ctrl) ? ctrl : null; + var action = actionDescriptor.RouteValues.TryGetValue("action", out var act) ? act : null; + + if (!string.IsNullOrEmpty(controller) && !string.IsNullOrEmpty(action)) + { + operation.OperationId = $"{controller}{action}"; + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 038304e0..034b1a56 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -79,13 +79,14 @@ public static async Task UseCommonOpenShockMiddleware(this return remoteIp is not null && metricsAllowedIpNetworks.Any(x => x.Contains(remoteIp)); }); - app.UseSwagger(); + app.MapOpenApi() + .CacheOutput("OpenAPI"); app.MapScalarApiReference("/scalar/viewer", options => options - .WithOpenApiRoutePattern("/swagger/{documentName}/swagger.json") - .AddDocument("1", "Version 1") - .AddDocument("2", "Version 2") + .WithOpenApiRoutePattern("/openapi/{documentName}.json") + .AddDocument("v1", "Version 1") + .AddDocument("v2", "Version 2") ); app.MapControllers(); diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 7e91e8b8..a3ef8f1f 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -143,11 +143,13 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se { options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; - }); - - apiVersioningBuilder.AddApiExplorer(setup => + options.ReportApiVersions = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + }) + .AddMvc() // mvc required for ApiExplorer + .AddApiExplorer(setup => { - setup.GroupNameFormat = "VVV"; + setup.GroupNameFormat = "'v'V"; setup.SubstituteApiVersionInUrl = true; setup.DefaultApiVersion = new ApiVersion(1, 0); setup.AssumeDefaultVersionWhenUnspecified = true; diff --git a/Common/Swagger/AttributeFilter.cs b/Common/Swagger/AttributeFilter.cs deleted file mode 100644 index 8634be86..00000000 --- a/Common/Swagger/AttributeFilter.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.OpenApi.Models; -using OpenShock.Common.Authentication; -using OpenShock.Common.DataAnnotations.Interfaces; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace OpenShock.Common.Swagger; - -public sealed class AttributeFilter : ISchemaFilter, IParameterFilter, IOperationFilter -{ - public void Apply(OpenApiParameter parameter, ParameterFilterContext context) - { - // Apply OpenShock Parameter Attributes - foreach (var attribute in context.ParameterInfo?.GetCustomAttributes(true).OfType() ?? []) - { - attribute.Apply(parameter); - } - - // Apply OpenShock Parameter Attributes - foreach (var attribute in context.PropertyInfo?.GetCustomAttributes(true).OfType() ?? []) - { - attribute.Apply(parameter); - } - } - - public void Apply(OpenApiSchema schema, SchemaFilterContext context) - { - // Apply OpenShock Parameter Attributes - foreach (var attribute in context.MemberInfo?.GetCustomAttributes(true).OfType() ?? []) - { - attribute.Apply(schema); - } - } - - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - // Apply OpenShock Parameter Attributes - foreach (var attribute in context.MethodInfo?.GetCustomAttributes(true).OfType() ?? []) - { - attribute.Apply(operation); - } - - // Get Authorize attribute - var attributes = context.MethodInfo?.DeclaringType?.GetCustomAttributes(true) - .Union(context.MethodInfo.GetCustomAttributes(true)) - .OfType() - .ToArray() ?? []; - - if (attributes.Length != 0) - { - if (attributes.Count(attr => !string.IsNullOrEmpty(attr.AuthenticationSchemes)) > 1) throw new Exception("Dunno what to apply to this method (multiple authentication attributes with schemes set)"); - - var scheme = attributes.Select(attr => attr.AuthenticationSchemes).SingleOrDefault(scheme => !string.IsNullOrEmpty(scheme)); - var roles = attributes.Select(attr => attr.Roles).Where(roles => !string.IsNullOrEmpty(roles)).SelectMany(roles => roles!.Split(',')).Select(role => role.Trim()).ToArray(); - var policies = attributes.Select(attr => attr.Policy).Where(policies => !string.IsNullOrEmpty(policies)).SelectMany(policies => policies!.Split(',')).Select(policy => policy.Trim()).ToArray(); - - // Add what should be show inside the security section - List securityInfos = []; - if (!string.IsNullOrEmpty(scheme)) securityInfos.Add($"{nameof(AuthorizeAttribute.AuthenticationSchemes)}:{scheme}"); - if (roles.Length > 0) securityInfos.Add($"{nameof(AuthorizeAttribute.Roles)}:{string.Join(',', roles)}"); - if (policies.Length > 0) securityInfos.Add($"{nameof(AuthorizeAttribute.Policy)}:{string.Join(',', policies)}"); - - List securityRequirements = []; - foreach (var authenticationScheme in scheme?.Split(',').Select(s => s.Trim()) ?? []) - { - securityRequirements.AddRange(authenticationScheme switch - { - OpenShockAuthSchemes.UserSessionCookie => [ - new OpenApiSecurityRequirement {{ - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.UserSessionCookie, - Type = ReferenceType.SecurityScheme, - } - }, - securityInfos - }} - ], - OpenShockAuthSchemes.ApiToken => [ - new OpenApiSecurityRequirement {{ - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.ApiToken, - Type = ReferenceType.SecurityScheme, - } - }, - securityInfos - }} - ], - OpenShockAuthSchemes.HubToken => [ - new OpenApiSecurityRequirement {{ - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.HubToken, - Type = ReferenceType.SecurityScheme - } - }, - securityInfos - }} - ], - _ => [], - }); - } - - operation.Security = securityRequirements; - } - else - { - operation.Security.Clear(); - } - } -} diff --git a/Common/Swagger/SwaggerGenExtensions.cs b/Common/Swagger/SwaggerGenExtensions.cs deleted file mode 100644 index 9eabfbc9..00000000 --- a/Common/Swagger/SwaggerGenExtensions.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.OpenApi.Models; -using OpenShock.Common.Constants; -using OpenShock.Common.DataAnnotations; -using OpenShock.Common.Models; -using OpenShock.Common.Utils; -using Asp.Versioning; -using OpenShock.Common.Extensions; -using OpenShock.Common.Authentication; - -namespace OpenShock.Common.Swagger; - -public static class SwaggerGenExtensions -{ - public static IServiceCollection AddSwaggerExt(this WebApplicationBuilder builder) where TProgram : class - { - var assembly = typeof(TProgram).Assembly; - - string assemblyName = assembly - .GetName() - .Name ?? throw new NullReferenceException("Assembly name"); - - var versions = assembly.GetAllControllerEndpointAttributes() - .SelectMany(type => type.Versions) - .Select(v => v.ToString()) - .ToHashSet() - .OrderBy(v => v) - .ToArray(); - - if (versions.Any(v => !int.TryParse(v, out _))) - { - throw new InvalidDataException($"Found invalid API versions: [{string.Join(", ", versions.Where(v => !int.TryParse(v, out _)))}]"); - } - - return builder.Services - .AddSwaggerGen(options => - { - options.CustomOperationIds(e => - $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.AttributeRouteInfo?.Name ?? e.ActionDescriptor.RouteValues["action"]}"); - options.SchemaFilter(); - options.ParameterFilter(); - options.OperationFilter(); - options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, assemblyName + ".xml"), true); - options.AddSecurityDefinition(OpenShockAuthSchemes.UserSessionCookie, new OpenApiSecurityScheme - { - Name = AuthConstants.UserSessionCookieName, - Description = "Enter user session cookie", - In = ParameterLocation.Cookie, - Type = SecuritySchemeType.ApiKey, - Scheme = OpenShockAuthSchemes.UserSessionCookie, - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.UserSessionCookie, - Type = ReferenceType.SecurityScheme, - } - }); - options.AddSecurityDefinition(OpenShockAuthSchemes.ApiToken, new OpenApiSecurityScheme - { - Name = AuthConstants.ApiTokenHeaderName, - Description = "Enter API Token", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey, - Scheme = OpenShockAuthSchemes.ApiToken, - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.ApiToken, - Type = ReferenceType.SecurityScheme, - } - }); - options.AddSecurityDefinition(OpenShockAuthSchemes.HubToken, new OpenApiSecurityScheme - { - Name = AuthConstants.HubTokenHeaderName, - Description = "Enter hub token", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey, - Scheme = OpenShockAuthSchemes.HubToken, - Reference = new OpenApiReference - { - Id = OpenShockAuthSchemes.HubToken, - Type = ReferenceType.SecurityScheme, - } - }); - options.AddServer(new OpenApiServer { Url = "https://api.openshock.app" }); - options.AddServer(new OpenApiServer { Url = "https://api.openshock.dev" }); - if (builder.Environment.IsDevelopment()) - { - options.AddServer(new OpenApiServer { Url = "https://localhost" }); - } - - foreach (var version in versions) - { - options.SwaggerDoc("v" + version, new OpenApiInfo { Title = "OpenShock", Version = version }); - } - options.MapType(() => OpenApiSchemas.SemVerSchema); - options.MapType(() => OpenApiSchemas.PauseReasonEnumSchema); - - // Avoid nullable strings everywhere - options.SupportNonNullableReferenceTypes(); - }) - .ConfigureOptions(); - } -} diff --git a/Common/Utils/ConfigureSwaggerOptions.cs b/Common/Utils/ConfigureSwaggerOptions.cs deleted file mode 100644 index 2dbc2694..00000000 --- a/Common/Utils/ConfigureSwaggerOptions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Asp.Versioning.ApiExplorer; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace OpenShock.Common.Utils; - -public sealed class ConfigureSwaggerOptions : IConfigureNamedOptions -{ - private readonly IApiVersionDescriptionProvider _provider; - - public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) - { - _provider = provider; - } - - public void Configure(SwaggerGenOptions options) - { - // add swagger document for every API version discovered - foreach (var description in _provider.ApiVersionDescriptions) - options.SwaggerDoc( - description.GroupName, - CreateVersionInfo(description)); - } - - public void Configure(string? name, SwaggerGenOptions options) => Configure(options); - - private static OpenApiInfo CreateVersionInfo( - ApiVersionDescription description) - { - var info = new OpenApiInfo - { - Title = "OpenShock.API", - Version = description.ApiVersion.ToString() - }; - if (description.IsDeprecated) info.Description += " This API version has been deprecated."; - return info; - } -} \ No newline at end of file diff --git a/Common/Utils/StringUtils.cs b/Common/Utils/StringUtils.cs index b58823c5..461325eb 100644 --- a/Common/Utils/StringUtils.cs +++ b/Common/Utils/StringUtils.cs @@ -1,4 +1,6 @@ -namespace OpenShock.Common.Utils; +using System.Diagnostics.CodeAnalysis; + +namespace OpenShock.Common.Utils; public static class StringUtils { @@ -6,4 +8,15 @@ public static string Truncate(this string input, int maxLength) { return input.Length <= maxLength ? input : input[..maxLength]; } + public static bool TryRemoveSuffix(string str, string suffix, [NotNullWhen(true)] out string? value) + { + if (!str.EndsWith(suffix)) + { + value = null; + return false; + } + + value = str[..^suffix.Length]; + return true; + } } \ No newline at end of file diff --git a/Cron/Program.cs b/Cron/Program.cs index 43b5cbb2..011ffaab 100644 --- a/Cron/Program.cs +++ b/Cron/Program.cs @@ -2,9 +2,9 @@ using Hangfire.PostgreSql; using OpenShock.Common; using OpenShock.Common.Extensions; +using OpenShock.Common.OpenAPI; using OpenShock.Cron; using OpenShock.Cron.Utils; -using OpenShock.Common.Swagger; var builder = OpenShockApplication.CreateDefaultBuilder(args); @@ -21,7 +21,7 @@ c.UseNpgsqlConnection(databaseOptions.Conn))); builder.Services.AddHangfireServer(); -builder.AddSwaggerExt(); +builder.AddOpenApiExt(); var app = builder.Build(); diff --git a/Directory.Build.props b/Directory.Build.props index 0514d49a..a197bfa9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,6 +35,7 @@ $(Version) a2109c1e-fb11-44d7-8127-346ef60cb9a5 true + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated diff --git a/Directory.Packages.props b/Directory.Packages.props index ffffd63e..4f9204fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + @@ -37,7 +38,6 @@ - diff --git a/LiveControlGateway/Program.cs b/LiveControlGateway/Program.cs index 8de00eef..b68d399f 100644 --- a/LiveControlGateway/Program.cs +++ b/LiveControlGateway/Program.cs @@ -2,10 +2,10 @@ using Microsoft.Extensions.Options; using OpenShock.Common; using OpenShock.Common.Extensions; +using OpenShock.Common.OpenAPI; using OpenShock.Common.Services; using OpenShock.Common.Services.Device; using OpenShock.Common.Services.Ota; -using OpenShock.Common.Swagger; using OpenShock.LiveControlGateway; using OpenShock.LiveControlGateway.LifetimeManager; using OpenShock.LiveControlGateway.Options; @@ -34,7 +34,7 @@ builder.Services.AddScoped(); builder.Services.AddKeyedSingleton("OpenShock.Gateway.Meter", new Meter("OpenShock.Gateway", "1.0.0", [new KeyValuePair("gateway_fqdn", lcgOptions.Fqdn)])); -builder.AddSwaggerExt(); +builder.AddOpenApiExt(); //services.AddHealthChecks().AddCheck("database");