Merge pull request 'fix(openapi): spec property naming now mirrors the runtime Newtonsoft serializer (#198)' (#201) from fix/198-openapi-casing into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled

This commit was merged in pull request #201.
This commit is contained in:
2026-07-09 18:19:20 +00:00
16 changed files with 405 additions and 66 deletions
@@ -0,0 +1,99 @@
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. A rename colliding with an existing key
// would silently drop a property from the spec — fail loudly instead (near-unreachable,
// but the generator must never emit a lossy document).
var renamedProperties = new Dictionary<string, IOpenApiSchema>(properties.Count, StringComparer.Ordinal);
foreach ((string key, IOpenApiSchema value) in properties)
{
string finalKey = renames.TryGetValue(key, out string newKey) ? newKey : key;
if (!renamedProperties.TryAdd(finalKey, value))
{
throw new InvalidOperationException(
$"OpenAPI schema property rename collision on '{clrType.FullName}': renaming '{key}' to '{finalKey}' would overwrite an existing property.");
}
}
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"
},