Files
ersatztv/ErsatzTV/Serialization/NewtonsoftSchemaNamingTransformer.cs
T
timothyandClaude Fable 5 e0d30c9b7b
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m59s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
test(api): cover [JsonProperty] path + fail loudly on rename collision (#198, review)
Adversarial-review follow-ups:

- OpenApiSerializerContractTests: add a fifth case, a fully-populated
  ChannelResponseModel — the only DTO with a [JsonProperty("ffmpegProfile")]
  override, i.e. the attribute path of NewtonsoftSchemaNamingTransformer that
  the existing four cases never exercised.
- NewtonsoftSchemaNamingTransformer: a rename colliding with an existing schema
  key now throws InvalidOperationException (naming the type and keys) instead of
  silently overwriting/dropping a property — the generator must never emit a
  lossy spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:06:26 +02:00

100 lines
4.1 KiB
C#

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;
}
}