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
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:
@@ -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
@@ -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, _, _) =>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user