Files
ersatztv/ErsatzTV/Serialization/OperationIdOpenApiTransformer.cs
T
timothyandClaude Opus 4.8 c40e78d840
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m21s
fix(api): #197 Bundle C review nits — order-independent operationIds + nullable MediaSources fields
Refs #287 #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:08:50 +02:00

121 lines
5.2 KiB
C#

#nullable enable
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using HashSet = System.Collections.Generic.HashSet<string>;
namespace ErsatzTV.Serialization;
/// <summary>
/// OpenAPI operation transformer that guarantees every operation carries a stable, non-empty
/// <c>operationId</c>. Only actions with <c>Name = "..."</c> on their HTTP-method attribute get an
/// id from the framework; the rest (~90 of ~240) would ship without one, which breaks generated
/// clients that key methods off <c>operationId</c>. Here we synthesize one from the
/// <see cref="ControllerActionDescriptor" /> (<c>ControllerName</c> + <c>ActionName</c>) whenever the
/// id is null/empty, and never rename an id the developer set explicitly.
/// <para>
/// Disambiguation is <b>structural, not visitation-order-dependent</b>: the colliding cases are the
/// HEAD/GET pairs that share a controller+action but differ only by verb. We precompute (from the
/// full <see cref="IApiDescriptionGroupCollectionProvider" />, filtered to the same
/// <c>general</c> group the "v1" document includes) the set of synthesized base ids that 2+ operations
/// share, and suffix <b>each</b> such operation by its HTTP verb (<c>&lt;base&gt;Get</c>,
/// <c>&lt;base&gt;Head</c>). A base id that is unique across the document stays unsuffixed. The result
/// is a pure function of (controller, action, verb): an SDK/framework change to discovery order can
/// no longer rename a generated client method. Explicit ids are recorded so a synthesized id can't
/// shadow one; a defensive counter remains a last-resort backstop for an impossible residual clash.
/// </para>
/// </summary>
public sealed class OperationIdOpenApiTransformer : IOpenApiOperationTransformer
{
private const string GeneralGroupName = "general";
private readonly HashSet _used = new(StringComparer.Ordinal);
private readonly HashSet _collidingBaseIds;
public OperationIdOpenApiTransformer(IApiDescriptionGroupCollectionProvider descriptionProvider)
=> _collidingBaseIds = ComputeCollidingBaseIds(descriptionProvider);
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(operation.OperationId))
{
// Keep developer-assigned ids verbatim; just reserve them so nothing synthesized collides.
_used.Add(operation.OperationId);
return Task.CompletedTask;
}
if (context.Description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
{
return Task.CompletedTask;
}
string baseId = controllerAction.ControllerName + controllerAction.ActionName;
// Structural, order-independent: if 2+ synthesized operations share this base id, suffix EACH
// by its verb; otherwise keep the bare base. Pure function of (controller, action, verb).
string candidate = _collidingBaseIds.Contains(baseId)
? baseId + PascalCaseVerb(context.Description.HttpMethod)
: baseId;
// Defensive backstop only — with verb suffixing this cannot fire for the known document, but
// guarantees uniqueness if some future shape produces an unexpected clash.
int suffix = 2;
string finalId = candidate;
while (!_used.Add(finalId))
{
finalId = candidate + suffix;
suffix++;
}
operation.OperationId = finalId;
return Task.CompletedTask;
}
private static HashSet ComputeCollidingBaseIds(IApiDescriptionGroupCollectionProvider provider)
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (ApiDescriptionGroup group in provider.ApiDescriptionGroups.Items)
{
foreach (ApiDescription description in group.Items)
{
// Mirror the "v1" document's ShouldInclude predicate.
if (description.GroupName != GeneralGroupName)
{
continue;
}
if (description.ActionDescriptor is not ControllerActionDescriptor controllerAction)
{
continue;
}
// Only operations that will actually be *synthesized* participate — an explicit
// Name= sets the route name (the framework's operationId), so it is excluded.
if (!string.IsNullOrEmpty(controllerAction.AttributeRouteInfo?.Name))
{
continue;
}
string baseId = controllerAction.ControllerName + controllerAction.ActionName;
counts[baseId] = counts.TryGetValue(baseId, out int c) ? c + 1 : 1;
}
}
return counts.Where(kvp => kvp.Value >= 2).Select(kvp => kvp.Key).ToHashSet(StringComparer.Ordinal);
}
private static string PascalCaseVerb(string? httpMethod)
{
if (string.IsNullOrEmpty(httpMethod))
{
return string.Empty;
}
return char.ToUpperInvariant(httpMethod[0]) + httpMethod[1..].ToLowerInvariant();
}
}