diff --git a/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj b/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj index 6a8c8861..b1b180ca 100644 --- a/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj +++ b/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj @@ -9,7 +9,7 @@ - + diff --git a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj index 0c9049df..faf3414b 100644 --- a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj +++ b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj @@ -7,7 +7,6 @@ d3bbf778-c5fe-4240-b2ca-dfa64082cc0e true true - true $(NoWarn);1591 Linux @@ -32,7 +31,7 @@ - + diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs index 93ea1f3e..6cae73e9 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using NSwag.Annotations; namespace CoffeeCard.WebApi.Controllers.v2; @@ -57,7 +56,7 @@ ILogger logger [HttpPost("webhook")] [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] - [OpenApiIgnore] + [ApiExplorerSettings(IgnoreApi = true)] [ProducesDefaultResponseType] public async Task Webhook( [FromBody] WebhookEvent request, diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs index 25758bdc..7161586b 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs @@ -36,6 +36,7 @@ public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsU /// This includes all purchases, swiped tickets, and used vouchers /// /// All users receipts + [HttpGet] public async Task> GetReceipts( [FromQuery] ReceiptsRequest request ) @@ -69,7 +70,7 @@ [FromQuery] ReceiptsRequest request from, request.Type, user.Id, - request.BatchSize + request.BatchSize ?? 20 ); return Ok(receipts); diff --git a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs new file mode 100644 index 00000000..0b1841fa --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace CoffeeCard.WebApi.Helpers.Swagger; + +/// +/// Ensures generated OpenAPI operations only advertise a single, canonical +/// JSON media type for both request and response payloads. +/// +/// Swashbuckle (via ApiExplorer) can include several JSON-like media types +/// such as "text/json" or "application/*+json" when an action uses JSON +/// serializers or returns strings. That causes clients and generated SDKs +/// to think multiple content-types are acceptable. This filter normalizes +/// `requestBody.content` and `response.content` to only contain +/// "application/json", which matches our API contract and simplifies +/// client generation and documentation. +/// +public sealed class JsonOnlyResponseContentTypeFilter : IOperationFilter +{ + private const string JsonMediaType = "application/json"; + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // Swagger should advertise the same JSON content type for both payload directions. + if (operation.RequestBody is not null) + { + NormalizeContentTypes(operation.RequestBody.Content); + } + + foreach (var response in operation.Responses.Values) + { + NormalizeContentTypes(response.Content); + } + } + + private static void NormalizeContentTypes(IDictionary content) + { + if (content.Count == 0 || !content.ContainsKey(JsonMediaType)) + { + return; + } + + var jsonContent = content[JsonMediaType]; + content.Clear(); + content[JsonMediaType] = jsonContent; + } +} diff --git a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonPolymorphicDiscriminatorFilter.cs b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonPolymorphicDiscriminatorFilter.cs new file mode 100644 index 00000000..d50810de --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonPolymorphicDiscriminatorFilter.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace CoffeeCard.WebApi.Helpers.Swagger; + +/// +/// Enriches OpenAPI discriminators with value→schema mappings derived from +/// annotations, and replaces inline +/// oneOf references to derived types with a $ref to the +/// discriminated base type so NSwag generates correct polymorphic clients. +/// +public class JsonPolymorphicDiscriminatorFilter : IDocumentFilter +{ + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var schemas = swaggerDoc.Components?.Schemas; + if (schemas is null) + return; + + // Build lookup from schema name → schema name (case-insensitive) + var schemaNameLookup = schemas.Keys.ToDictionary( + k => k, + k => k, + StringComparer.OrdinalIgnoreCase + ); + + // Collect schemas that have a discriminator and add mappings + var discriminatedBases = new Dictionary>(); + + foreach (var (schemaName, schema) in schemas) + { + if (schema is not OpenApiSchema concreteSchema) + continue; + + if (concreteSchema.Discriminator is null) + continue; + + var clrType = AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(a => + { + try + { + return a.GetTypes(); + } + catch + { + return Array.Empty(); + } + }) + .FirstOrDefault(t => + string.Equals(t.Name, schemaName, StringComparison.OrdinalIgnoreCase) + && t.GetCustomAttributes(typeof(JsonPolymorphicAttribute), false).Length > 0 + ); + + if (clrType is null) + continue; + + var derivedTypes = clrType + .GetCustomAttributes(typeof(JsonDerivedTypeAttribute), inherit: false) + .Cast() + .ToList(); + + if (derivedTypes.Count == 0) + continue; + + concreteSchema.Discriminator.Mapping ??= + new Dictionary(); + + var derivedSchemaNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var derived in derivedTypes) + { + if (derived.TypeDiscriminator is not string discriminatorValue) + continue; + + if ( + schemaNameLookup.TryGetValue( + derived.DerivedType.Name, + out var derivedSchemaName + ) + ) + { + concreteSchema.Discriminator.Mapping[discriminatorValue] = + new OpenApiSchemaReference(derivedSchemaName); + derivedSchemaNames.Add(derivedSchemaName); + } + } + + discriminatedBases[schemaName] = derivedSchemaNames; + } + + // Replace oneOf references to all derived types with a $ref to the base type + if (discriminatedBases.Count == 0) + return; + + foreach (var (_, schema) in schemas) + { + if (schema is not OpenApiSchema s || s.Properties is null) + continue; + + var propsToUpdate = new List<(string key, IOpenApiSchema replacement)>(); + + foreach (var (propName, propSchema) in s.Properties) + { + switch (propSchema) + { + // Array property: check items.oneOf + case OpenApiSchema arraySchema + when arraySchema.Items is OpenApiSchema itemsSchema + && itemsSchema.OneOf is { Count: > 0 }: + { + var baseName = FindMatchingBase(itemsSchema.OneOf, discriminatedBases); + if (baseName is not null) + { + arraySchema.Items = new OpenApiSchemaReference(baseName); + } + + break; + } + // Direct property: check oneOf + case OpenApiSchema directSchema when directSchema.OneOf is { Count: > 0 }: + { + var baseName = FindMatchingBase(directSchema.OneOf, discriminatedBases); + if (baseName is not null) + { + propsToUpdate.Add((propName, new OpenApiSchemaReference(baseName))); + } + + break; + } + } + } + + foreach (var (key, replacement) in propsToUpdate) + { + s.Properties[key] = replacement; + } + } + } + + private static string? FindMatchingBase( + IList oneOfEntries, + Dictionary> discriminatedBases + ) + { + var oneOfRefs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in oneOfEntries) + { + if (entry is OpenApiSchemaReference schemaRef) + { + // In Microsoft.OpenApi v2, the schema name is on Reference.Id, not directly on the schema ref + var refId = schemaRef.Reference?.Id ?? schemaRef.Id; + if (refId is not null) + oneOfRefs.Add(refId); + } + } + + foreach (var (baseName, derivedNames) in discriminatedBases) + { + if (derivedNames.SetEquals(oneOfRefs)) + return baseName; + } + + return null; + } +} diff --git a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/RemoveApiVersionProcessor.cs b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/RemoveApiVersionProcessor.cs deleted file mode 100644 index 6579f4ba..00000000 --- a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/RemoveApiVersionProcessor.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Linq; -using NSwag.Generation.Processors; -using NSwag.Generation.Processors.Contexts; - -namespace CoffeeCard.WebApi.Helpers.Swagger -{ - /// - /// Custom processor for OpenApi documents. Checks for parameters with name 'version' and removes them from the list of parameters. - /// This is to remove the API Version path parameter field from the OpenApi specification - /// Inspired by - /// - public class RemoveApiVersionProcessor : IOperationProcessor - { - /// - /// Process OpenApi document and remove parameters with Parameter Name 'version' - /// - /// true if any matching parameters found, false if not - public bool Process(OperationProcessorContext context) - { - var operationDescription = context.OperationDescription; - var versionParameter = operationDescription - .Operation.Parameters.Where(p => p.Name == "version") - .ToList(); - - if (versionParameter.Any()) - { - foreach (var parameter in versionParameter) - { - context.OperationDescription.Operation.Parameters.Remove(parameter); - } - - return true; - } - else - { - return false; - } - } - } -} diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index a2f5efd2..3602c51d 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -14,6 +16,7 @@ using CoffeeCard.MobilePay.Service.v2; using CoffeeCard.MobilePay.Utils; using CoffeeCard.WebApi.Helpers; +using CoffeeCard.WebApi.Helpers.Swagger; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -26,9 +29,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.FeatureManagement; using Microsoft.IdentityModel.Tokens; -using NJsonSchema.Generation; -using NSwag; -using NSwag.Generation.Processors.Security; +using Microsoft.OpenApi; using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; @@ -37,6 +38,7 @@ using RestSharp; using RestSharp.Authenticators; using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; using AccountService = CoffeeCard.Library.Services.AccountService; using IAccountService = CoffeeCard.Library.Services.IAccountService; using IPurchaseService = CoffeeCard.Library.Services.IPurchaseService; @@ -371,80 +373,119 @@ public void ConfigureServices(IServiceCollection services) } /// - /// Generate Open Api Document for each API Version + /// Configure Swagger/OpenAPI for each API Version /// private static void GenerateOpenApiDocument(IServiceCollection services) { var apiVersions = services .BuildServiceProvider() .GetRequiredService(); - foreach (var apiVersion in apiVersions.ApiVersionDescriptions) + + services.AddSwaggerGen(options => { - // Add an OpenApi document per API version - services.AddOpenApiDocument(config => + foreach (var apiVersion in apiVersions.ApiVersionDescriptions) { - config.DefaultResponseReferenceTypeNullHandling = - ReferenceTypeNullHandling.NotNull; - config.Title = apiVersion.GroupName; - config.Version = apiVersion.ApiVersion.ToString(); - config.DocumentName = apiVersion.GroupName; - config.ApiGroupNames = new[] { apiVersion.GroupName }; - config.Description = "ASP.NET Core WebAPI for Cafe Analog"; - config.PostProcess = document => - { - document.Info.Title = "Cafe Analog CoffeeCard API"; - document.Info.Version = $"v{apiVersion.ApiVersion}"; - document.Info.Contact = new OpenApiContact - { - Name = "AnalogIO", - Email = "support@analogio.dk", - Url = "https://github.com/analogio", - }; - document.Info.License = new OpenApiLicense + options.SwaggerDoc( + apiVersion.GroupName, + new OpenApiInfo { - Name = "Use under MIT", - Url = "https://github.com/AnalogIO/analog-core/blob/master/LICENSE", - }; - }; - - config.DocumentProcessors.Add( - new SecurityDefinitionAppender( - "jwt", - new OpenApiSecurityScheme + Title = "Cafe Analog CoffeeCard API", + Version = $"v{apiVersion.ApiVersion}", + Contact = new OpenApiContact { - Description = "JWT Bearer token", - Name = "Authorization", - Scheme = "bearer", - BearerFormat = "JWT", - In = OpenApiSecurityApiKeyLocation.Header, - Type = OpenApiSecuritySchemeType.Http, - } - ) - ); - config.OperationProcessors.Add( - new AspNetCoreOperationSecurityScopeProcessor("jwt") - ); - - config.DocumentProcessors.Add( - new SecurityDefinitionAppender( - "apikey", - new OpenApiSecurityScheme + Name = "AnalogIO", + Email = "support@analogio.dk", + Url = new Uri("https://github.com/analogio"), + }, + License = new OpenApiLicense { - Description = "Api Key used for health endpoints", - Name = "x-api-key", - In = OpenApiSecurityApiKeyLocation.Header, - Type = OpenApiSecuritySchemeType.ApiKey, - } - ) - ); - config.OperationProcessors.Add( - new AspNetCoreOperationSecurityScopeProcessor("apikey") + Name = "Use under MIT", + Url = new Uri( + "https://github.com/AnalogIO/analog-core/blob/master/LICENSE" + ), + }, + Description = "ASP.NET Core WebAPI for Cafe Analog", + } ); + } + + // Enable System.Text.Json polymorphism support + options.UseOneOfForPolymorphism(); + options.UseAllOfForInheritance(); + + // Read [JsonPolymorphic] to set discriminator property name + options.SelectDiscriminatorNameUsing(type => + type.GetCustomAttribute()?.TypeDiscriminatorPropertyName + ?? "$type" + ); + + // Add discriminator value→schema mappings from [JsonDerivedType] attributes + // so NSwag can generate correct polymorphic deserialization + options.DocumentFilter(); + // Preserve controller/action-based operation IDs so generated test clients keep stable method names. + options.CustomOperationIds(GetStableOperationId); + + // Normalise all response content types to application/json + options.OperationFilter(); + + // Define JWT security scheme + options.AddSecurityDefinition( + "jwt", + new OpenApiSecurityScheme + { + Description = "JWT Bearer token", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + } + ); - // Assume not null as default unless parameter is marked as nullable - // config.DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull; + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference("jwt"), [] }, + }); + + // Define API Key security scheme + options.AddSecurityDefinition( + "apikey", + new OpenApiSecurityScheme + { + Description = "Api Key used for health endpoints", + Name = "x-api-key", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + } + ); + + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference("apikey"), [] }, }); + }); + } + + private static string GetStableOperationId(ApiDescription apiDescription) + { + apiDescription.ActionDescriptor.RouteValues.TryGetValue( + "controller", + out var controller + ); + apiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action); + + if (!string.IsNullOrWhiteSpace(controller) && !string.IsNullOrWhiteSpace(action)) + { + return $"{controller}_{action}"; } + + var relativePath = (apiDescription.RelativePath ?? string.Empty).Split('?')[0]; + var sanitizedPath = relativePath + .Replace("/", "_") + .Replace("{", string.Empty) + .Replace("}", string.Empty); + + return $"{sanitizedPath}_{apiDescription.HttpMethod}"; } /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -464,8 +505,21 @@ IApiVersionDescriptionProvider provider else app.UseHsts(); - app.UseOpenApi(); - app.UseSwaggerUi(); + app.UseSwagger(); + app.UseSwaggerUI(options => + { + foreach ( + var apiVersion in provider.ApiVersionDescriptions.OrderByDescending(x => + x.ApiVersion + ) + ) + { + options.SwaggerEndpoint( + $"/swagger/{apiVersion.GroupName}/swagger.json", + $"CoffeeCard API {apiVersion.GroupName}" + ); + } + }); app.UseHttpsRedirection();