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"), [] },
+ });
});
}