Skip to content

Request Lifecycle

Every HTTP request follows the same path through Crate:

HTTP Request
→ vibe.d URLRouter
→ CrateRouter.handler()
→ CrateRouter.match()
→ IApiOperation.handle()
→ Middleware pipeline
→ Database query
→ Serialization
→ HTTP Response

When CrateRouter is constructed, it registers a catch-all handler with the vibe.d URLRouter:

this(URLRouter router) {
this.router = router;
router.any("*", requestErrorHandler(&this.handler));
}

The any("*") pattern matches all HTTP methods and all paths. The requestErrorHandler wrapper catches exceptions and converts them to JSON error responses.

The handler method is the entry point for all requests:

void handler(HTTPServerRequest request, HTTPServerResponse response) {
// Lazy initialization on first request
if (!routerReady) {
callOnRouterReady(); // Finalizes MCP tool lists, etc.
}
// Always set CORS headers
this.options(request.requestPath.toString, response);
// Handle OPTIONS preflight
if (request.method == HTTPMethod.OPTIONS) {
if ("Access-Control-Allow-Origin" !in response.headers) {
return; // No matching route — vibe.d returns 404
}
response.statusCode = 204;
response.writeVoidBody();
return;
}
// Parse body for POST/PUT/PATCH (needed for body-matched routing)
Json requestBody = Json.init;
if (request.method == HTTPMethod.POST || ...) {
try { requestBody = request.json; } catch (Exception) {}
}
// Find matching operation
auto operation = match(request.method, request.requestPath.toString, requestBody);
if (operation is null) return; // No match — vibe.d handles 404
// Execute the operation
scope storage = new OperationContext;
storage.request = request;
storage.response = response;
operation.handle(storage);
}

Key details:

  • callOnRouterReady() runs once on the first request, not during setup. This allows all models to be registered before MCP finalizes its tool list.
  • The request body is parsed before matching because MCP/GraphQL route based on body content.
  • If no operation matches, the handler returns without writing a response. vibe.d’s URLRouter then falls through to other registered handlers or returns 404.

CrateRouter.match() iterates through all registered TypeRouters and BlankRouters:

IApiOperation match(HTTPMethod method, string routeName, Json requestBody) {
foreach (type; types) {
auto matched = type.match(method, routeName, requestBody);
if (matched !is null) return matched;
}
return null;
}

Each router’s match() method checks its operations:

Uses matchTemplateRoute() to compare the request path against operation path templates:

Request: GET /users/abc123
Template: GET /users/:id → Match! (:id = abc123)
Template: GET /users → No match (different segment count)
Template: GET /posts/:id → No match (different static segment)

For operations with a bodyMatcher, the request body is checked against the matcher pattern:

// MCP operation has bodyMatcher:
// {"method": "tools/call", "params": {"name": "list_users"}}
// Request body must contain all matcher fields with matching values
if (!matchJson(requestBody, operation.rule.request.bodyMatcher)) {
continue; // Try next operation
}

matchJson() recursively checks that every field in the matcher exists in the request body with the same value. Extra fields in the request are ignored.

Each operation type follows a similar pattern. Here’s GetItemApiOperation as an example:

void handle(OperationContext storage) {
// 1. Prepare: run middleware, fetch item
Json item = this.prepareItemOperation!"getItem"(storage);
// 2. Check if middleware already wrote response
if (storage.response.headerWritten) return;
// 3. Apply response headers from CrateRule
this.applyRule(storage);
// 4. Create response mapper
storage.mapper = this.mapper(storage);
// 5. Serialize and write response
auto responseData = _rule.response.serializer.toResponseItem(item, storage);
storage.response.writeJsonBody(responseData, _rule.response.statusCode);
}

This method (from CrateOperationMixin) handles the common database fetch logic:

  1. Extract item ID from URL path parameters
  2. Run non-query middleware (authentication, validation)
  3. Build query: crate.get.where("_id").equal(id)
  4. Run query middleware (visibility filters)
  5. Execute query and fetch item
  6. Generate ETag (using hash field or CRC32 of JSON)
  7. Return the JSON item

Similar but for collections:

  1. Create empty query: crate.get()
  2. Run all middleware (filters add .where() clauses, pagination adds .skip()/.limit())
  3. Store query in context (execution deferred to serializer for streaming)

Middleware runs in two phases:

Authentication, validation, and request transformation. These run first because:

  • Auth failures should short-circuit before touching the database
  • Request data needs to be validated before building queries

Visibility filters, pagination, and sorting. These modify the IQuery that will be executed:

@getList
IQuery getList(IQuery selector, HTTPServerRequest request) @safe {
selector.where("visibility").equal("public");
selector.limit(20);
return selector;
}

If any middleware writes to the response (e.g., 401 Unauthorized), subsequent middleware and the operation itself are skipped:

if (storage.response.headerWritten) return;

The serializer (determined by the policy) converts internal JSON to the protocol format:

{
"user": {
"_id": "abc123",
"name": "Alice"
}
}
{
"data": {
"type": "users",
"id": "abc123",
"attributes": {"name": "Alice"}
}
}
{
"jsonrpc": "2.0",
"result": {
"content": [{"type": "text", "text": "{...}"}]
}
}

CORS headers are set on every response, not just OPTIONS:

void options(string routeName, HTTPServerResponse response) {
// Collect allowed methods from all routers matching this path
foreach (type; types) {
methods ~= type.methods(routeName);
headers ~= type.headers(routeName);
}
response.headers["Access-Control-Allow-Origin"] = "*";
response.headers["Access-Control-Allow-Headers"] = headers.join(", ");
response.headers["Access-Control-Allow-Methods"] = methods.join(", ");
}

For OPTIONS requests specifically, the response is 204 with an empty body.