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