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
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>
100 lines
4.1 KiB
C#
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;
|
|
}
|
|
}
|