Move the API onto FastEndpoints, without moving the wire

Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.

Nothing about the wire moves, and the evidence is that the 94 existing HTTP
tests pass with zero edits to any of them. Same routes, verbs, route
constraints, status codes, operation ids, and the same RFC 9457 bodies with the
same code values. Every place the idiomatic FastEndpoints answer would have
changed one of those, it was refused:

Endpoints are registered from an explicit List<Type>, not found by scanning.
ADR 0002 rejected reflection discovery by name, and the reason it gave is
sharper here than in general — under WebApplicationFactory the scan reaches the
test assembly, so an endpoint written in a test would be registered into the
host under test. The cost is a line per endpoint that can be forgotten, which is
what the endpoint-inventory test is for. That test is the one ADR 0002 promised
and never got.

Handlers still return Results<Ok<T>, NotFound, ProblemHttpResult> from
ExecuteAsync. The union executes as an ordinary IResult, which is what keeps
problem bodies going through the host's serialiser and IProblemDetailsService,
and what keeps the compile-time record of which statuses an endpoint can
produce. No Send.* call appears anywhere; the moment one does, a response has
left the host's serialiser.

Validation stays in the feature services. A Validator<T> short-circuits before
the handler and answers with FastEndpoints' own envelope, which carries no code
— and the code is the only part of an error the client branches on. Twenty-odd
tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather
than merely avoided, because the framework's documentation leads straight to it
and it looks like an improvement.

Three defects arrived with the framework and were caught in review. All three
were green at the time, which is the part worth remembering. FastEndpoints maps
GET /_test_url_cache_ unconditionally, in every environment, with no policy and
no way to opt out; it answers with the whole endpoint-name-to-route table. It is
short-circuited to 404 — by asking routing which endpoint it selected, after the
first attempt compared the request path with Ordinal and was therefore bypassable
at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The
default request binder writes query-string values over the deserialised body,
which would have let ?identityProviderToken=... put an ID token in a URL and from
there into every proxy log on the path; every endpoint now binds from the body
alone. And a route value read with Route<T>() is invisible to ApiExplorer, so the
generated document named {vaultId} in a path template with nothing declaring it —
invalid OpenAPI, and unusable by the client generators the document exists for.

Two changes to the surface, both deliberate. A body that cannot be deserialised
now answers with a problem document carrying malformed-request, rather than an
empty 400: FastEndpoints' default announces application/problem+json while
sending something else, and names the failing .NET type on the wire, in a
codebase that sets IncludeErrorDetails = false to prevent exactly that. And the
route table above returns 404 where it would otherwise have answered any
authenticated caller.

Each of the three fixes has a regression test that was checked by reverting the
fix and watching it fail — four failures for the route table and the binder, four
for the document. That check is the whole reason to trust them, since all three
defects passed a full green suite on the way in.

950 tests green across 16 projects, 14 of them new and no existing test edited.
Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues
and Messaging are in the graph now and none is used.

Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
This commit is contained in:
2026-07-31 08:39:06 +02:00
parent d162271a45
commit 9bc28f1c0f
21 changed files with 1287 additions and 201 deletions
+28 -17
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
@@ -54,24 +55,34 @@ internal static class Auth
options.MapInboundClaims = false;
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
// "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
// tell a client whether the problem is theirs to fix.
options.AddPolicy(
EnrolledPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
});
services.AddAuthorization(AddDodoPolicies);
return services;
}
private static void AddDodoPolicies(AuthorizationOptions options)
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
// "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
// tell a client whether the problem is theirs to fix.
options.AddPolicy(
EnrolledPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
// The same policy again, as the default rather than the fallback. FastEndpoints attaches
// authorization metadata to every endpoint that is not AllowAnonymous, and an endpoint that
// carries metadata is covered by the default policy rather than the fallback — so the
// fallback is no longer what secures the API. Setting both to the same policy keeps them
// from drifting, because tightening one and not the other would protect half the surface
// and look like it had protected all of it.
options.DefaultPolicy = options.GetPolicy(AuthenticatedPolicy)!;
}
}
+125 -14
View File
@@ -1,32 +1,143 @@
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Contracts;
using FastEndpoints;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The single, explicit list of every endpoint module.
/// The single, explicit list of every endpoint.
/// </summary>
/// <remarks>
/// Deliberately not reflection-based discovery. Explicit registration gives predictable startup,
/// survives trimming, and makes every route greppable — and a route that silently disappears
/// because an assembly was not scanned is a genuinely nasty failure. The cost is one line per
/// module.
/// FastEndpoints can find endpoints by scanning assemblies. It is deliberately not asked to. Explicit
/// registration gives predictable startup, survives trimming, and makes every route greppable from one
/// file — and a route that silently disappears because an assembly was not scanned is a genuinely nasty
/// failure. Scanning is worse here than in general: under <c>WebApplicationFactory</c> the scan reaches
/// the test assembly too, so an endpoint written in a test would be registered into the host under test.
/// The cost is one line per endpoint, and a line forgotten is a route missing with no compile error —
/// which is what <c>EndpointInventoryTests</c> exists to catch.
/// </remarks>
internal static class EndpointRegistration
{
/// <summary>
/// The route template FastEndpoints maps whether or not it is wanted.
/// </summary>
/// <remarks>
/// The registered template, not a request path: it is matched against what routing selected rather
/// than against anything a caller typed. FastEndpoints registers it without a leading slash.
/// </remarks>
private const string RouteTableTemplate = "_test_url_cache_";
internal static IServiceCollection AddDodoEndpoints(this IServiceCollection services) =>
services.AddFastEndpoints(new List<Type>
{
typeof(GetMetaEndpoint),
typeof(GetDodoSshConfigurationEndpoint),
typeof(GetMeEndpoint),
typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
});
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
/// <remarks>
/// <para>
/// <c>UseFastEndpoints</c> maps that route unconditionally, in every environment, carrying neither a
/// policy nor <c>AllowAnonymous</c>. It answers with every endpoint class name and route template the
/// server knows, to back a test helper this repository does not use, and there is no switch to turn
/// it off. The deny-by-default policy means a caller has to be authenticated to read it, which is not
/// the same as it being nobody's business.
/// </para>
/// <para>
/// This asks routing which endpoint it selected rather than comparing the request path, because the
/// path cannot be compared correctly by hand: routing matches literal segments case-insensitively
/// and tolerates a trailing slash, so <c>/_TEST_URL_CACHE_</c> and <c>/_test_url_cache_/</c> reach
/// the same endpoint as the canonical spelling. A hand-written comparison that agrees with the
/// matcher on Monday is a bypass on Tuesday. <c>WebApplication</c> inserts <c>UseRouting</c> ahead of
/// every middleware registered here, so the selected endpoint is already available; short-circuiting
/// before <c>UseEndpoints</c> is what keeps the answer independent of registration order.
/// </para>
/// </remarks>
internal static WebApplication BlockFastEndpointsRouteTable(this WebApplication app)
{
app.Use(static async (context, next) =>
{
if (context.GetEndpoint() is RouteEndpoint selected
&& string.Equals(
selected.RoutePattern.RawText?.Trim('/'),
RouteTableTemplate,
StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context).ConfigureAwait(false);
});
return app;
}
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.MapMetaEndpoints();
app.MapIdentityEndpoints();
app.MapSyncEndpoints();
app.UseFastEndpoints(config =>
{
// Every route is written out in full in its own Configure(). A global prefix would rewrite
// all of them at once, and /.well-known/ is not under /api at all.
config.Endpoints.RoutePrefix = null;
// FastEndpoints serialises through its own copy of the host's JsonOptions, taken implicitly
// at this point. Applied again explicitly because that copy is undocumented, and Setup/Json.cs
// records what silent JSON drift on this exact surface already cost once.
DodoSshJsonContext.ApplyTo(config.Serializer.Options);
// A body that will not deserialise is answered here, before any handler runs. The default
// body is not a problem document despite the media type it claims, and it names the failing
// .NET type; Problems.ForBindingFailure says the same thing in the shape everything else uses.
config.Errors.ProducesMetadataType = null;
config.Errors.ResponseBuilder =
static (_, context, statusCode) => Problems.ForBindingFailure(context, statusCode);
// Applied to every endpoint rather than endpoint by endpoint: the default is the dangerous
// one, so the safe choice has to be the one nobody can forget.
config.Endpoints.Configurator =
static endpoint => endpoint.RequestBinder(typeof(BodyOnlyRequestBinder<>));
});
// Registered as each feature lands:
// Identity — key rotation, devices, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
return app;
}
}
/// <summary>
/// Binds a request DTO from the JSON body and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// FastEndpoints' default binder deserialises the body and then writes route values, query-string
/// parameters, headers, claims and cookies over the top, matching DTO properties by name. Minimal APIs
/// bound a body parameter from the body alone, so leaving the default in place would silently widen
/// every request: <c>?cursor=…</c> would override the cursor in a pull body, and — the reason this is
/// not merely untidy — <c>?identityProviderToken=…</c> would let an ID token be supplied in a URL,
/// where proxies, browser history and access logs all keep copies of it. This API takes some trouble to
/// keep tokens out of logs; see <c>IncludeErrorDetails = false</c> in <see cref="Auth"/>.
/// </para>
/// <para>
/// Route values are still read, deliberately and one at a time, with <c>Route&lt;T&gt;("name")</c> in
/// the handlers that need one. That reads the route directly rather than through the DTO, so it is
/// unaffected by this.
/// </para>
/// </remarks>
/// <typeparam name="TRequest">The request DTO being bound.</typeparam>
internal sealed class BodyOnlyRequestBinder<TRequest>()
: RequestBinder<TRequest>(BindingSource.JsonBody)
where TRequest : notnull;
+7 -1
View File
@@ -6,7 +6,7 @@ namespace DodoSSH.Api.Setup;
internal static class Json
{
/// <summary>
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the minimal-API serialiser.
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the host's serialiser.
/// </summary>
/// <remarks>
/// <para>
@@ -22,6 +22,12 @@ internal static class Json
/// serialised its requests with <c>PostAsJsonAsync</c>'s defaults, so both sides agreed on integers and
/// nothing disagreed with anything.
/// </para>
/// <para>
/// FastEndpoints does not use this <see cref="System.Text.Json.JsonSerializerOptions"/> instance. It
/// copies from it, once, while the pipeline is being built. That copy is undocumented and process-wide,
/// so <c>Setup/EndpointRegistration.cs</c> applies the same settings to it explicitly rather than
/// trusting it — which is the lesson of the paragraph above, applied to the mechanism that replaced it.
/// </para>
/// </remarks>
internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions));
+93 -2
View File
@@ -1,3 +1,7 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace DodoSSH.Api.Setup;
/// <summary>
@@ -9,13 +13,15 @@ namespace DodoSSH.Api.Setup;
/// contract change fails the pull request. The desktop client's actual contract is the
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
/// </remarks>
internal static class OpenApi
internal static partial class OpenApi
{
internal const string DocumentName = "v1";
internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services)
{
services.AddOpenApi(DocumentName);
services.AddOpenApi(
DocumentName,
options => options.AddOperationTransformer<RouteParameterTransformer>());
// Added in M1, once there are endpoints to describe:
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
@@ -25,3 +31,88 @@ internal static class OpenApi
return services;
}
}
/// <summary>
/// Declares the path parameters that the route template uses but no handler parameter binds.
/// </summary>
/// <remarks>
/// <para>
/// Endpoints that need a route value read it with <c>Route&lt;T&gt;("name")</c> rather than binding it
/// onto the request DTO, so nothing in the endpoint's signature mentions it and the generator emits
/// <c>/api/v1/vaults/{vaultId}/sync/pull</c> with an empty <c>parameters</c> 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.
/// </para>
/// <para>
/// The constraint survives in <c>ApiDescription.RelativePath</c> even though the document's path key
/// strips it, which is what makes the type recoverable rather than guessed.
/// </para>
/// </remarks>
internal sealed partial class RouteParameterTransformer : IOpenApiOperationTransformer
{
/// <inheritdoc />
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;
}
/// <remarks>
/// 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.
/// </remarks>
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 },
};
/// <summary>Matches <c>{name}</c> and <c>{name:constraint}</c>, ignoring catch-all and optional forms.</summary>
/// <remarks>
/// 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.
/// </remarks>
[GeneratedRegex(
@"\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?::(?<constraint>[^}]+))?\}",
RegexOptions.None,
matchTimeoutMilliseconds: 1000)]
private static partial Regex TemplateParameter();
}
+72
View File
@@ -0,0 +1,72 @@
using System.Diagnostics;
using DodoSSH.Contracts;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.WebUtilities;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The one shape every error this API returns takes.
/// </summary>
/// <remarks>
/// RFC 9457 with a root-level <c>code</c>. The prose is for whoever reads a log; the code is what the
/// client branches on, which is why it is a constant in <see cref="ProblemCodes"/> and never a literal
/// at the call site. Shared rather than duplicated per feature because there are now several endpoint
/// classes per file, and a second copy is how two of them end up disagreeing.
/// </remarks>
internal static class Problems
{
/// <summary>An error a handler decided on.</summary>
internal static ProblemHttpResult Coded(int statusCode, string code, string detail) =>
TypedResults.Problem(
detail: detail,
statusCode: statusCode,
type: ProblemCodes.TypeBaseUri + code,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal) { ["code"] = code });
/// <summary>The same shape, for the one path that cannot return an <see cref="IResult"/>.</summary>
/// <remarks>
/// FastEndpoints answers a request whose body will not deserialise before any handler runs, through
/// a builder that returns a plain object rather than a result — so <see cref="Coded"/> cannot be
/// reused and the shape has to be spelled out. Replacing the default is not cosmetic: it announces
/// <c>application/problem+json</c> while sending something that is not a problem document, and it
/// puts the failing .NET type name on the wire, which is exactly what <c>IncludeErrorDetails =
/// false</c> on the bearer handler exists to prevent.
/// </remarks>
internal static CodedProblem ForBindingFailure(HttpContext context, int statusCode)
{
ArgumentNullException.ThrowIfNull(context);
return new CodedProblem(
Type: ProblemCodes.TypeBaseUri + ProblemCodes.MalformedRequest,
Title: ReasonPhrases.GetReasonPhrase(statusCode),
Status: statusCode,
// Deliberately says nothing about which member failed. The binder knows, but naming it
// describes the server's types rather than the caller's request.
Detail: "The request body could not be read. Check it against the contract for the server "
+ "version reported by GET /api/v1/meta.",
Code: ProblemCodes.MalformedRequest,
TraceId: Activity.Current?.Id ?? context.TraceIdentifier);
}
}
/// <summary>A problem document written by something other than <c>TypedResults.Problem</c>.</summary>
/// <remarks>
/// Member order here is member order on the wire, and the names match what
/// <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> serialises to, so a client cannot tell which
/// of the two produced a given response.
/// </remarks>
/// <param name="Type">The problem type URI, formed under <see cref="ProblemCodes.TypeBaseUri"/>.</param>
/// <param name="Title">The status code's reason phrase.</param>
/// <param name="Status">The HTTP status code.</param>
/// <param name="Detail">Human-readable explanation. Never a secret and never a .NET type name.</param>
/// <param name="Code">The stable code the client switches on.</param>
/// <param name="TraceId">Correlates the response with the server's trace.</param>
internal sealed record CodedProblem(
string Type,
string Title,
int Status,
string Detail,
string Code,
string? TraceId);