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");