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
Refs #287 #288 #197 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
5.2 KiB
C#
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><base>Get</c>,
|
|
/// <c><base>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();
|
|
}
|
|
}
|