From 00135d18ae321029d26d2c1a43e00e22186b1a59 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 15:23:28 +0200 Subject: [PATCH 1/7] replace NSwag with Swashbuckle --- .../CoffeeCard.Models.csproj | 2 +- .../CoffeeCard.WebApi.csproj | 4 +- .../Controllers/v2/MobilePayController.cs | 14 +- .../Controllers/v2/ReceiptController.cs | 1 + .../Swagger/RemoveApiVersionProcessor.cs | 40 --- coffeecard/CoffeeCard.WebApi/Startup.cs | 230 +++++++++++------- 6 files changed, 147 insertions(+), 144 deletions(-) delete mode 100644 coffeecard/CoffeeCard.WebApi/Helpers/Swagger/RemoveApiVersionProcessor.cs 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 @@ - + 5562C898-513F-493A-A335-417C39476714 d3bbf778-c5fe-4240-b2ca-dfa64082cc0e true - true - true $(NoWarn);1591 Linux @@ -32,7 +30,7 @@ - + diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs index 93ea1f3e..c874e83e 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; @@ -21,11 +20,12 @@ namespace CoffeeCard.WebApi.Controllers.v2; [Route("api/v{version:apiVersion}/mobilepay")] public class MobilePayController : ControllerBase { - private static readonly JsonSerializerSettings JsonSerializerSettings = new() - { - DateFormatString = "yyyy-MM-ddTHH:mm:ss.fffZ", - DateTimeZoneHandling = DateTimeZoneHandling.Utc, - }; + private static readonly JsonSerializerSettings JsonSerializerSettings = + new() + { + DateFormatString = "yyyy-MM-ddTHH:mm:ss.fffZ", + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + }; private readonly ILogger _logger; private readonly IPurchaseService _purchaseService; @@ -57,7 +57,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..b29e5310 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 ) 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..e4d7feff 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -26,9 +27,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.Models; using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; @@ -37,6 +36,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; @@ -72,15 +72,16 @@ public void ConfigureServices(IServiceCollection services) var databaseSettings = _configuration .GetSection(nameof(DatabaseSettings)) .Get(); - services.AddDbContext(opt => - opt.UseSqlServer( - databaseSettings.ConnectionString, - c => - c.MigrationsHistoryTable( - "__EFMigrationsHistory", - databaseSettings.SchemaName - ) - ) + services.AddDbContext( + opt => + opt.UseSqlServer( + databaseSettings.ConnectionString, + c => + c.MigrationsHistoryTable( + "__EFMigrationsHistory", + databaseSettings.SchemaName + ) + ) ); // Setup cache @@ -185,8 +186,8 @@ public void ConfigureServices(IServiceCollection services) .AddMeter("Microsoft.AspNetCore.Server.Kestrel"); if (applicationInsightsConnectionString is null or "") return; - metrics.AddAzureMonitorMetricExporter(options => - options.ConnectionString = applicationInsightsConnectionString + metrics.AddAzureMonitorMetricExporter( + options => options.ConnectionString = applicationInsightsConnectionString ); }) .WithTracing(traces => @@ -199,8 +200,8 @@ public void ConfigureServices(IServiceCollection services) if (applicationInsightsConnectionString is null or "") return; - builder.AddAzureMonitorTraceExporter(options => - options.ConnectionString = applicationInsightsConnectionString + builder.AddAzureMonitorTraceExporter( + options => options.ConnectionString = applicationInsightsConnectionString ); }); @@ -219,12 +220,14 @@ public void ConfigureServices(IServiceCollection services) ); openTelemetryBuilder.ConfigureResource(resource => { - resource.AddAttributes([ - new KeyValuePair( - "Env", - environment.EnvironmentType.ToString() ?? "Env not set" - ), - ]); + resource.AddAttributes( + [ + new KeyValuePair( + "Env", + environment.EnvironmentType.ToString() ?? "Env not set" + ), + ] + ); resource.AddAzureAppServiceDetector(); resource.AddService( $"analog-core-{environment.EnvironmentType}", @@ -272,10 +275,11 @@ public void ConfigureServices(IServiceCollection services) options.JsonSerializerOptions.Converters.Add(new DateTimeConverter()); }); - services.AddCors(options => - options.AddDefaultPolicy(builder => - builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() - ) + services.AddCors( + options => + options.AddDefaultPolicy( + builder => builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() + ) ); services.AddApiVersioning(config => @@ -371,80 +375,106 @@ 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 + options.SwaggerDoc( + apiVersion.GroupName, + new OpenApiInfo { - Name = "AnalogIO", - Email = "support@analogio.dk", - Url = "https://github.com/analogio", - }; - document.Info.License = new OpenApiLicense - { - 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") + Name = "AnalogIO", + Email = "support@analogio.dk", + Url = new Uri("https://github.com/analogio"), + }, + License = new OpenApiLicense + { + 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(); - config.DocumentProcessors.Add( - new SecurityDefinitionAppender( - "apikey", + // 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", + } + ); + + options.AddSecurityRequirement( + new OpenApiSecurityRequirement + { + { new OpenApiSecurityScheme { - Description = "Api Key used for health endpoints", - Name = "x-api-key", - In = OpenApiSecurityApiKeyLocation.Header, - Type = OpenApiSecuritySchemeType.ApiKey, - } - ) - ); - config.OperationProcessors.Add( - new AspNetCoreOperationSecurityScopeProcessor("apikey") - ); + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "jwt", + }, + }, + new string[] { } + } + } + ); - // Assume not null as default unless parameter is marked as nullable - // config.DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull; - }); - } + // 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 OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "apikey", + }, + }, + new string[] { } + } + } + ); + }); } /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -464,8 +494,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(); @@ -496,12 +539,13 @@ IApiVersionDescriptionProvider provider }); // Enable Request Buffering so that a raw request body can be read after aspnet model binding - app.Use(next => - context => - { - context.Request.EnableBuffering(); - return next(context); - } + app.Use( + next => + context => + { + context.Request.EnableBuffering(); + return next(context); + } ); } } From 8c21b1c2ec30b96b1f4e4c8ae075db64c3800827 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 16:04:02 +0200 Subject: [PATCH 2/7] Normalise all response content types to application/json --- .../JsonOnlyResponseContentTypeFilter.cs | 48 +++++++++++++++++++ coffeecard/CoffeeCard.WebApi/Startup.cs | 4 ++ 2 files changed, 52 insertions(+) create mode 100644 coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs diff --git a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs new file mode 100644 index 00000000..82674e83 --- /dev/null +++ b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.OpenApi.Models; +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/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index e4d7feff..2a996e4e 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -15,6 +15,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; @@ -415,6 +416,9 @@ private static void GenerateOpenApiDocument(IServiceCollection services) options.UseOneOfForPolymorphism(); options.UseAllOfForInheritance(); + // Normalise all response content types to application/json + options.OperationFilter(); + // Define JWT security scheme options.AddSecurityDefinition( "jwt", From 164d54ba72bc2a40e6cc5ac12ba45b5ee97a7825 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 22:19:14 +0200 Subject: [PATCH 3/7] run csharpier --- .../Controllers/v2/MobilePayController.cs | 11 ++- coffeecard/CoffeeCard.WebApi/Startup.cs | 71 +++++++++---------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs index c874e83e..6cae73e9 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/MobilePayController.cs @@ -20,12 +20,11 @@ namespace CoffeeCard.WebApi.Controllers.v2; [Route("api/v{version:apiVersion}/mobilepay")] public class MobilePayController : ControllerBase { - private static readonly JsonSerializerSettings JsonSerializerSettings = - new() - { - DateFormatString = "yyyy-MM-ddTHH:mm:ss.fffZ", - DateTimeZoneHandling = DateTimeZoneHandling.Utc, - }; + private static readonly JsonSerializerSettings JsonSerializerSettings = new() + { + DateFormatString = "yyyy-MM-ddTHH:mm:ss.fffZ", + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + }; private readonly ILogger _logger; private readonly IPurchaseService _purchaseService; diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 2a996e4e..4fd91f38 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -73,16 +73,15 @@ public void ConfigureServices(IServiceCollection services) var databaseSettings = _configuration .GetSection(nameof(DatabaseSettings)) .Get(); - services.AddDbContext( - opt => - opt.UseSqlServer( - databaseSettings.ConnectionString, - c => - c.MigrationsHistoryTable( - "__EFMigrationsHistory", - databaseSettings.SchemaName - ) - ) + services.AddDbContext(opt => + opt.UseSqlServer( + databaseSettings.ConnectionString, + c => + c.MigrationsHistoryTable( + "__EFMigrationsHistory", + databaseSettings.SchemaName + ) + ) ); // Setup cache @@ -187,8 +186,8 @@ public void ConfigureServices(IServiceCollection services) .AddMeter("Microsoft.AspNetCore.Server.Kestrel"); if (applicationInsightsConnectionString is null or "") return; - metrics.AddAzureMonitorMetricExporter( - options => options.ConnectionString = applicationInsightsConnectionString + metrics.AddAzureMonitorMetricExporter(options => + options.ConnectionString = applicationInsightsConnectionString ); }) .WithTracing(traces => @@ -201,8 +200,8 @@ public void ConfigureServices(IServiceCollection services) if (applicationInsightsConnectionString is null or "") return; - builder.AddAzureMonitorTraceExporter( - options => options.ConnectionString = applicationInsightsConnectionString + builder.AddAzureMonitorTraceExporter(options => + options.ConnectionString = applicationInsightsConnectionString ); }); @@ -221,14 +220,12 @@ public void ConfigureServices(IServiceCollection services) ); openTelemetryBuilder.ConfigureResource(resource => { - resource.AddAttributes( - [ - new KeyValuePair( - "Env", - environment.EnvironmentType.ToString() ?? "Env not set" - ), - ] - ); + resource.AddAttributes([ + new KeyValuePair( + "Env", + environment.EnvironmentType.ToString() ?? "Env not set" + ), + ]); resource.AddAzureAppServiceDetector(); resource.AddService( $"analog-core-{environment.EnvironmentType}", @@ -276,11 +273,10 @@ public void ConfigureServices(IServiceCollection services) options.JsonSerializerOptions.Converters.Add(new DateTimeConverter()); }); - services.AddCors( - options => - options.AddDefaultPolicy( - builder => builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() - ) + services.AddCors(options => + options.AddDefaultPolicy(builder => + builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() + ) ); services.AddApiVersioning(config => @@ -446,7 +442,7 @@ private static void GenerateOpenApiDocument(IServiceCollection services) }, }, new string[] { } - } + }, } ); @@ -475,7 +471,7 @@ private static void GenerateOpenApiDocument(IServiceCollection services) }, }, new string[] { } - } + }, } ); }); @@ -502,8 +498,8 @@ IApiVersionDescriptionProvider provider app.UseSwaggerUI(options => { foreach ( - var apiVersion in provider.ApiVersionDescriptions.OrderByDescending( - x => x.ApiVersion + var apiVersion in provider.ApiVersionDescriptions.OrderByDescending(x => + x.ApiVersion ) ) { @@ -543,13 +539,12 @@ var apiVersion in provider.ApiVersionDescriptions.OrderByDescending( }); // Enable Request Buffering so that a raw request body can be read after aspnet model binding - app.Use( - next => - context => - { - context.Request.EnableBuffering(); - return next(context); - } + app.Use(next => + context => + { + context.Request.EnableBuffering(); + return next(context); + } ); } } From 910b09bf064dc13ca1b63321989598f7e9f4c150 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 22:28:41 +0200 Subject: [PATCH 4/7] enable OpenApiGenerateDocumentsOnBuild --- coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj index f0b63552..953d48a5 100644 --- a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj +++ b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj @@ -6,6 +6,7 @@ 5562C898-513F-493A-A335-417C39476714 d3bbf778-c5fe-4240-b2ca-dfa64082cc0e true + true $(NoWarn);1591 Linux From 61540c2899bee9d2cf1141dfabb5096b47d61e0c Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 22:50:36 +0200 Subject: [PATCH 5/7] attempt to fix build errors --- .../v2/Receipts/ReceiptsRequest.cs | 3 +-- .../Controllers/v2/ReceiptController.cs | 2 +- coffeecard/CoffeeCard.WebApi/Startup.cs | 22 +++++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs index d579fee9..f14e1dba 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs @@ -11,8 +11,7 @@ public class ReceiptsRequest [Required] public required ReceiptType Type { get; set; } - [Required] - public int BatchSize { get; set; } = 20; + public int? BatchSize { get; set; } = 20; public required string? ContinuationToken { get; set; } } diff --git a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs index b29e5310..7161586b 100644 --- a/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs +++ b/coffeecard/CoffeeCard.WebApi/Controllers/v2/ReceiptController.cs @@ -70,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/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 4fd91f38..05850eaa 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -412,6 +412,9 @@ private static void GenerateOpenApiDocument(IServiceCollection services) options.UseOneOfForPolymorphism(); options.UseAllOfForInheritance(); + // 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(); @@ -477,6 +480,25 @@ private static void GenerateOpenApiDocument(IServiceCollection services) }); } + 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. public void Configure( IApplicationBuilder app, From 49268b39763bf32d08d2a725e341e6e437702934 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Fri, 1 May 2026 22:52:00 +0200 Subject: [PATCH 6/7] format --- coffeecard/CoffeeCard.WebApi/Startup.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 05850eaa..9f9d026b 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -482,7 +482,10 @@ private static void GenerateOpenApiDocument(IServiceCollection services) private static string GetStableOperationId(ApiDescription apiDescription) { - apiDescription.ActionDescriptor.RouteValues.TryGetValue("controller", out var controller); + apiDescription.ActionDescriptor.RouteValues.TryGetValue( + "controller", + out var controller + ); apiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action); if (!string.IsNullOrWhiteSpace(controller) && !string.IsNullOrWhiteSpace(action)) From 8658e3ccfdbbd146a46431d42628e94a06946f33 Mon Sep 17 00:00:00 2001 From: Thomas Andersen Date: Mon, 4 May 2026 20:46:38 +0200 Subject: [PATCH 7/7] Upgrade to swashbuckle 10 and add custom filter for polymophic stuff --- .../v2/Receipts/ReceiptsRequest.cs | 2 +- .../CoffeeCard.WebApi.csproj | 2 +- .../JsonOnlyResponseContentTypeFilter.cs | 2 +- .../JsonPolymorphicDiscriminatorFilter.cs | 172 ++++++++++++++++++ coffeecard/CoffeeCard.WebApi/Startup.cs | 52 ++---- 5 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonPolymorphicDiscriminatorFilter.cs diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs index f14e1dba..068e41d9 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Receipts/ReceiptsRequest.cs @@ -13,7 +13,7 @@ public class ReceiptsRequest public int? BatchSize { get; set; } = 20; - public required string? ContinuationToken { get; set; } + public string? ContinuationToken { get; set; } } /// diff --git a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj index 953d48a5..faf3414b 100644 --- a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj +++ b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj @@ -31,7 +31,7 @@ - + diff --git a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs index 82674e83..0b1841fa 100644 --- a/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs +++ b/coffeecard/CoffeeCard.WebApi/Helpers/Swagger/JsonOnlyResponseContentTypeFilter.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace CoffeeCard.WebApi.Helpers.Swagger; 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/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 9f9d026b..3602c51d 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -1,6 +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; @@ -28,7 +29,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.FeatureManagement; using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; @@ -412,6 +413,15 @@ private static void GenerateOpenApiDocument(IServiceCollection services) 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); @@ -432,22 +442,10 @@ private static void GenerateOpenApiDocument(IServiceCollection services) } ); - options.AddSecurityRequirement( - new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "jwt", - }, - }, - new string[] { } - }, - } - ); + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference("jwt"), [] }, + }); // Define API Key security scheme options.AddSecurityDefinition( @@ -461,22 +459,10 @@ private static void GenerateOpenApiDocument(IServiceCollection services) } ); - options.AddSecurityRequirement( - new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "apikey", - }, - }, - new string[] { } - }, - } - ); + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference("apikey"), [] }, + }); }); }