fix(api): mirror runtime Newtonsoft JSON casing in OpenAPI spec (#198)

The OpenAPI document is generated from System.Text.Json metadata, whose
camelCase drifted from the runtime MVC serializer (Newtonsoft via
CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId"
and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy
special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty]
override). That fed the SPA the wrong keys.

Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered
on all three documents that renames each object schema's Properties (and
Required) keys through the SAME Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. Regenerate v1.json.

Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs
through the runtime Newtonsoft settings and pins the v1.json schema property
sets to the emitted keys, failing if generation drifts again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:00:23 +02:00
co-authored by Claude Fable 5
parent 415da214ec
commit f26790aa35
4 changed files with 283 additions and 16 deletions
@@ -0,0 +1,92 @@
using System.Reflection;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Newtonsoft.Json.Serialization;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI schema transformer that renames each schema property to the exact JSON key the runtime
/// Newtonsoft serializer (configured with <see cref="CustomContractResolver" />) would actually emit.
/// The OpenAPI document is generated from System.Text.Json metadata, whose camelCase naming can drift
/// from Newtonsoft's — e.g. STJ emits "fFmpegProfileId" while the MVC pipeline emits "ffmpegProfileId"
/// (the <see cref="CustomNamingStrategy" /> special case). Mirroring the real contract resolver keeps
/// the spec in lockstep with the wire format by construction, so future [JsonProperty] renames or
/// naming-strategy special cases cannot drift. See issue #198.
/// </summary>
public static class NewtonsoftSchemaNamingTransformer
{
private static readonly CustomContractResolver ContractResolver = new();
public static Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
if (schema.Properties is not { Count: > 0 } properties)
{
return Task.CompletedTask;
}
Type clrType = context.JsonTypeInfo.Type;
if (ContractResolver.ResolveContract(clrType) is not JsonObjectContract contract)
{
return Task.CompletedTask;
}
// CLR member name -> Newtonsoft-emitted JSON property name.
var memberToNewtonsoftName = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (JsonProperty jsonProperty in contract.Properties)
{
if (jsonProperty.UnderlyingName is { } underlyingName && jsonProperty.PropertyName is { } propertyName)
{
memberToNewtonsoftName[underlyingName] = propertyName;
}
}
// Current schema key (STJ name) -> desired key (Newtonsoft name), only where they differ.
var renames = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var jsonPropertyInfo in context.JsonTypeInfo.Properties)
{
string schemaKey = jsonPropertyInfo.Name;
if (!properties.ContainsKey(schemaKey))
{
continue;
}
if (jsonPropertyInfo.AttributeProvider is MemberInfo member &&
memberToNewtonsoftName.TryGetValue(member.Name, out string newtonsoftName) &&
!string.Equals(newtonsoftName, schemaKey, StringComparison.Ordinal))
{
renames[schemaKey] = newtonsoftName;
}
}
if (renames.Count == 0)
{
return Task.CompletedTask;
}
// Rebuild Properties preserving insertion order.
var renamedProperties = new Dictionary<string, IOpenApiSchema>(properties.Count, StringComparer.Ordinal);
foreach ((string key, IOpenApiSchema value) in properties)
{
renamedProperties[renames.TryGetValue(key, out string newKey) ? newKey : key] = value;
}
schema.Properties = renamedProperties;
if (schema.Required is { Count: > 0 } required)
{
var renamedRequired = new System.Collections.Generic.HashSet<string>(required.Count, StringComparer.Ordinal);
foreach (string key in required)
{
renamedRequired.Add(renames.TryGetValue(key, out string newKey) ? newKey : key);
}
schema.Required = renamedRequired;
}
return Task.CompletedTask;
}
}
+7 -1
View File
@@ -180,6 +180,7 @@ public class Startup
options =>
{
options.ShouldInclude += a => a.GroupName == "general";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
options.AddDocumentTransformer((document, _, _) =>
{
UseStringEnumSchemas(document);
@@ -189,13 +190,18 @@ public class Startup
services.AddOpenApi(
"scripted-schedule-tagged",
options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; });
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
});
services.AddOpenApi(
"scripted-schedule",
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
var tag = new OpenApiTag { Name = "ScriptedSchedule" };
var tagReference = new OpenApiTagReference("ScriptedSchedule");
options.AddOperationTransformer((operation, _, _) =>
+15 -15
View File
@@ -14225,7 +14225,7 @@
"name",
"group",
"categories",
"fFmpegProfile",
"ffmpegProfile",
"language",
"streamingMode",
"isEnabled",
@@ -14253,7 +14253,7 @@
"categories": {
"type": "string"
},
"fFmpegProfile": {
"ffmpegProfile": {
"type": "string"
},
"language": {
@@ -14332,7 +14332,7 @@
"description",
"isSystem",
"isDefault",
"fFmpegProfileId",
"ffmpegProfileId",
"watermarkId",
"fallbackFillerId",
"preRollFillerId",
@@ -14374,7 +14374,7 @@
"isDefault": {
"type": "boolean"
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},
@@ -14494,7 +14494,7 @@
"name",
"group",
"categories",
"fFmpegProfileId",
"ffmpegProfileId",
"slugSeconds",
"logo",
"streamSelectorMode",
@@ -14549,7 +14549,7 @@
"string"
]
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},
@@ -14810,7 +14810,7 @@
}
]
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": [
"null",
"integer"
@@ -15153,7 +15153,7 @@
"number",
"group",
"categories",
"fFmpegProfileId",
"ffmpegProfileId",
"slugSeconds",
"logo",
"streamSelectorMode",
@@ -15203,7 +15203,7 @@
"string"
]
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},
@@ -15314,7 +15314,7 @@
"required": [
"name",
"description",
"fFmpegProfileId",
"ffmpegProfileId",
"watermarkId",
"fallbackFillerId",
"preRollFillerId",
@@ -15346,7 +15346,7 @@
"description": {
"type": "string"
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},
@@ -21094,7 +21094,7 @@
"number",
"group",
"categories",
"fFmpegProfileId",
"ffmpegProfileId",
"slugSeconds",
"logo",
"streamSelectorMode",
@@ -21144,7 +21144,7 @@
"string"
]
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},
@@ -21255,7 +21255,7 @@
"required": [
"name",
"description",
"fFmpegProfileId",
"ffmpegProfileId",
"watermarkId",
"fallbackFillerId",
"preRollFillerId",
@@ -21287,7 +21287,7 @@
"description": {
"type": "string"
},
"fFmpegProfileId": {
"ffmpegProfileId": {
"type": "integer",
"format": "int32"
},