Skip to main content

Routes: how does a caller that is not the app reach the server?

The app calls POST /dw/<WireName> with a DTO. Some callers cannot: a payment provider posting a webhook, a partner's callback, a tool that downloads a file, an MCP client. For them a project declares routes — plain HTTP doors on the same port, over the framework's own small HTTP types.

final routes = <DwHttpRoute>[
DwHttpRoute.get(
'/hello',
(ctx, request) => DwHttpResponse.json({
'hello': request.query['name'] ?? 'world',
'agent': request.headers['x-agent'],
}),
),
DwHttpRoute.post('/echo', (ctx, request) async {
final body = await request.json() as Map<String, Object?>;
await ctx.jobs.enqueue(EchoJobs.record, body['tag']);
return DwHttpResponse.json(body, status: 201);
}),
];

Passed as a feature's DwServerFeature(routes: routes); both are from packages/dartway_core_server/test/routes_test.dart.

Declaring a route​

DwHttpRoute.get(path, handle, {auth}), DwHttpRoute.post(…) and DwHttpRoute.any(…) — every method; a route of the same path with a specific method wins over it. The handler is a DwRouteHandler:

typedef DwRouteHandler =
FutureOr<DwHttpResponse> Function(DwCallContext ctx, DwHttpRequest request);

Paths match exactly — a map lookup, no patterns and no path parameters; an id goes in the query or the body. A route change does not affect generated Kamal web configuration. An unknown path is 404; a known path with another method is 405 with Allow.

The server refuses to start on a route at /dw, under /dw/ or at /health (the framework's), on a path that does not start with /, or on the same method and path declared twice.

ctx: a DwCallContext​

A route's ctx is a DwCallContext (handlers): ctx.db, ctx.transaction, ctx.publish, ctx.jobs, ctx.accounts, ctx.files, ctx.log. A route may publish and enqueue jobs. It does not run in a transaction: ctx.db is the pool, so a route that writes more than one row opens ctx.transaction. That transaction is retried on a deadlock or a serialization failure, up to three attempts, like a command's (database): its body may run more than once, so a call to another service belongs before or after it, not inside. Publications are delivered when the route answers; one made before the transaction is delivered once, whatever the retries.

DwHttpRequest and DwHttpResponse​

DwHttpRequest:

Member
methodupper case
pathwithout the query
headersvalues by lower-case name; repeated headers joined with ,
query, queryAllquery parameters; query keeps the last value of a repeated one
bytes({maxBytes})the whole body, at most maxBytes (DwServerSettings.maxBodyBytes by default)
text({maxBytes})the body as UTF-8
json({maxBytes})the body as JSON

A body read fails with DwRequestBodyException, which answers its status when the route lets it propagate: 413 over the limit, 408 when the body does not arrive within bodyReadTimeout, 400 for bytes that are not UTF-8 or JSON. A body the route never reads is drained before the answer, so the caller can read the answer instead of a connection reset.

DwHttpResponse(status, {headers, body}), DwHttpResponse.json(value, {status, headers}), DwHttpResponse.text(text, {status, headers}) and DwHttpResponse.empty({status: 204, headers}). Content-Length is the server's to write.

What a thrown error answers​

ThrownAnswer
DwRequestBodyExceptionits status and message, as text
a refusal (ctx.refuse, DwRefusalException){"refusal": …} with the status the same refusal gets on a call — 422, or 403/404/409/429 (with Retry-After), 426 for an incompatibility
DwNotAuthenticatedException (ctx.requireAccountId)401
anything else500 with {"incident": "<id>"} only, and an alert (alerts)

An exception's text is written for the operator and may carry a query, a path or a secret; the caller of a door never sees it.

DwRouteAuth​

A route reads Authorization: Bearer <token> — the same session keys calls carry, app and personal alike — only when it says so:

  • DwRouteAuth.none (the default) — the header is not read; ctx.accountId and ctx.sessionKey are null. For doors whose callers prove themselves another way, like a signed webhook, whose Authorization header belongs to its sender.
  • DwRouteAuth.optional — a valid token signs the context in; no header leaves it anonymous.
  • DwRouteAuth.required — as optional, and no header is 401 too.

Under optional and required, a token that is unknown or revoked is answered 401 with WWW-Authenticate: Bearer before the handler runs, as on a call: a caller holding a dead token must learn it. An Authorization header that is not one bearer token — another scheme, a repeated header, two tokens folded together by a proxy — is 400. ctx.sessionKey?.kind tells a personal key made for a tool from the app (session keys).

Webhooks​

A webhook is a route with DwRouteAuth.none that proves its caller itself:

  1. read the body with request.bytes() — the raw bytes are what a provider signs — with a maxBytes that fits the provider's payloads;
  2. verify the provider's signature header against them, and answer 401 when it does not match;
  3. do the least that must happen now — typically ctx.jobs.enqueue with the provider's event id as key — and answer at once. Providers retry slow or failed deliveries; a job with retries and backoff is the place for the slow part (jobs).

A key deduplicates only while the job is pending. A delivery repeated after the job succeeded enqueues again, so the work itself checks whether the event was already applied — against a row of the project's, with a unique constraint on the event id.

Where routes are reachable​

With deploy kamal, the API service is the root service on its own host. With deploy run, the API domain also proxies every path to the server. In either topology, the app host proxies only /dw/ to the server; its Flutter router may use any other path, including one that matches a server route. External callers reach project routes on the API domain (deploy). In development, dart run dartway_cli:dartway dev forwards /dw/* by default; use --api-path /webhooks/payments to opt in to forwarding a project door (CLI).