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