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