using System.Reflection;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Newtonsoft.Json.Serialization;
namespace ErsatzTV.Serialization;
///
/// OpenAPI schema transformer that renames each schema property to the exact JSON key the runtime
/// Newtonsoft serializer (configured with ) 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 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.
///
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(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(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(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(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;
}
}