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 | |
|---|---|
method | upper case |
path | without the query |
headers | values by lower-case name; repeated headers joined with , |
query, queryAll | query 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
| Thrown | Answer |
|---|---|
DwRequestBodyException | its 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 else | 500 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.accountIdandctx.sessionKeyarenull. For doors whose callers prove themselves another way, like a signed webhook, whoseAuthorizationheader belongs to its sender.DwRouteAuth.optional— a valid token signs the context in; no header leaves it anonymous.DwRouteAuth.required— asoptional, and no header is401too.
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:
- read the body with
request.bytes()— the raw bytes are what a provider signs — with amaxBytesthat fits the provider's payloads; - verify the provider's signature header against them, and answer
401when it does not match; - do the least that must happen now — typically
ctx.jobs.enqueuewith the provider's event id askey— 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).
Related
- The application server — the one port routes share with calls.
- Alerts — what a failed route reports.