Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<ProjectReference Include="..\CoffeeCard.Common\CoffeeCard.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NJsonSchema" Version="11.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference
Include="Microsoft.NETCore.Targets"
Version="5.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ 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; }
public string? ContinuationToken { get; set; }
}

/// <summary>
Expand Down
3 changes: 1 addition & 2 deletions coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<UserSecretsId>d3bbf778-c5fe-4240-b2ca-dfa64082cc0e</UserSecretsId>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers>
<NoWarn>$(NoWarn);1591</NoWarn>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
Expand All @@ -32,7 +31,7 @@
<PackageReference Include="NetEscapades.Configuration.Validation" Version="2.0.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="NSwag.AspNetCore" Version="14.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using NSwag.Annotations;

namespace CoffeeCard.WebApi.Controllers.v2;

Expand Down Expand Up @@ -57,7 +56,7 @@ ILogger<MobilePayController> logger
[HttpPost("webhook")]
[ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[OpenApiIgnore]
[ApiExplorerSettings(IgnoreApi = true)]
[ProducesDefaultResponseType]
public async Task<ActionResult> Webhook(
[FromBody] WebhookEvent request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public ReceiptController(IReceiptService receiptService, ClaimsUtilities claimsU
/// This includes all purchases, swiped tickets, and used vouchers
/// </summary>
/// <returns>All users receipts</returns>
[HttpGet]
public async Task<ActionResult<ReceiptResponse>> GetReceipts(
[FromQuery] ReceiptsRequest request
)
Expand Down Expand Up @@ -69,7 +70,7 @@ [FromQuery] ReceiptsRequest request
from,
request.Type,
user.Id,
request.BatchSize
request.BatchSize ?? 20
);

return Ok(receipts);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace CoffeeCard.WebApi.Helpers.Swagger;

/// <summary>
/// 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.
/// </summary>
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<string, OpenApiMediaType> content)
{
if (content.Count == 0 || !content.ContainsKey(JsonMediaType))
{
return;
}

var jsonContent = content[JsonMediaType];
content.Clear();
content[JsonMediaType] = jsonContent;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Enriches OpenAPI discriminators with value→schema mappings derived from
/// <see cref="JsonDerivedTypeAttribute"/> annotations, and replaces inline
/// <c>oneOf</c> references to derived types with a <c>$ref</c> to the
/// discriminated base type so NSwag generates correct polymorphic clients.
/// </summary>
public class JsonPolymorphicDiscriminatorFilter : IDocumentFilter
{
/// <inheritdoc />
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<string, HashSet<string>>();

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<Type>();
}
})
.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<JsonDerivedTypeAttribute>()
.ToList();

if (derivedTypes.Count == 0)
continue;

concreteSchema.Discriminator.Mapping ??=
new Dictionary<string, OpenApiSchemaReference>();

var derivedSchemaNames = new HashSet<string>(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<IOpenApiSchema> oneOfEntries,
Dictionary<string, HashSet<string>> discriminatedBases
)
{
var oneOfRefs = new HashSet<string>(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;
}
}

This file was deleted.

Loading
Loading