using System.Text.RegularExpressions; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace DodoSSH.Api.Setup; /// /// OpenAPI document configuration. /// /// /// The generated document exists for third parties and a future CLI. It is emitted at /// build time to artifacts/openapi/v1.json and diffed in CI so an unintended /// contract change fails the pull request. The desktop client's actual contract is the /// DodoSSH.Contracts assembly, guarded by PublicApiAnalyzers. /// internal static partial class OpenApi { internal const string DocumentName = "v1"; internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services) { services.AddOpenApi( DocumentName, options => options.AddOperationTransformer()); // Added in M1, once there are endpoints to describe: // - a document transformer contributing the OAuth2 authorizationCode + PKCE // security scheme, so the document is usable from a generated client // - a schema transformer mapping byte[] to {type: string, format: byte}, // since every ciphertext field crosses the wire as base64 return services; } } /// /// Declares the path parameters that the route template uses but no handler parameter binds. /// /// /// /// Endpoints that need a route value read it with Route<T>("name") rather than binding it /// onto the request DTO, so nothing in the endpoint's signature mentions it and the generator emits /// /api/v1/vaults/{vaultId}/sync/pull with an empty parameters list. A template expression /// with no matching parameter is invalid OpenAPI, and no client generator can fill it in — which would /// quietly make the document useless for the third parties it exists for. /// /// /// The constraint survives in ApiDescription.RelativePath even though the document's path key /// strips it, which is what makes the type recoverable rather than guessed. /// /// internal sealed partial class RouteParameterTransformer : IOpenApiOperationTransformer { /// public Task TransformAsync( OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(operation); ArgumentNullException.ThrowIfNull(context); var template = context.Description.RelativePath; if (string.IsNullOrEmpty(template)) { return Task.CompletedTask; } foreach (Match match in TemplateParameter().Matches(template)) { var name = match.Groups["name"].Value; var alreadyDeclared = operation.Parameters?.Any( parameter => string.Equals(parameter.Name, name, StringComparison.OrdinalIgnoreCase)); if (alreadyDeclared == true) { continue; } operation.Parameters ??= []; operation.Parameters.Add(new OpenApiParameter { Name = name, In = ParameterLocation.Path, // A path parameter is required by definition; the specification rejects any other value. Required = true, Schema = SchemaFor(match.Groups["constraint"].Value), }); } return Task.CompletedTask; } /// /// Only the constraints this API actually uses are mapped. An unrecognised one becomes a plain /// string, which is weaker than it could be but never wrong — the alternative, guessing, puts a type /// in a published contract that the server does not enforce. /// private static OpenApiSchema SchemaFor(string constraint) => constraint switch { "guid" => new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid" }, "int" => new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32" }, "long" => new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int64" }, _ => new OpenApiSchema { Type = JsonSchemaType.String }, }; /// Matches {name} and {name:constraint}, ignoring catch-all and optional forms. /// /// The timeout is there to satisfy MA0009 rather than because it can fire: the input is this /// server's own route table, read once at document generation, and never anything a caller sent. /// [GeneratedRegex( @"\{(?[A-Za-z_][A-Za-z0-9_]*)(?::(?[^}]+))?\}", RegexOptions.None, matchTimeoutMilliseconds: 1000)] private static partial Regex TemplateParameter(); }