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:
@@ -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
@@ -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, _, _) =>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user