Skip to content

Custom Operations

The simplest way to add custom endpoints is to define methods directly on your model struct. Crate calls these “actions” and auto-discovers them when you use prepare() or add().

A method that returns a value becomes a GET endpoint:

struct User {
string _id;
string firstName;
string lastName;
string getFullName() {
return firstName ~ " " ~ lastName;
}
}

This creates GET /users/:id/getFullName which returns the user’s full name as a string.

A method that takes a parameter becomes a POST endpoint:

struct User {
string _id;
string name;
string setName(string newName) {
name = newName;
return name;
}
}

This creates POST /users/:id/setName. The request body is the parameter value.

An action can also accept an HTTPServerRequest for full request access:

import vibe.http.server : HTTPServerRequest;
struct Report {
string _id;
string data;
string handleRequest(HTTPServerRequest request) {
// Access headers, query params, etc.
return "processed";
}
}

By default, prepare() and add() auto-discover and expose all action methods on a model. If you want to control which actions are exposed, use using() and manually call .expose!:

auto typeRouter = crateRouter.using(userCrate);
// Only expose specific actions
typeRouter.expose!"getFullName";
typeRouter.expose!"setName";
// handleRequest is NOT exposed

For more control, use .itemOperation!() to define custom endpoints with explicit handlers:

auto typeRouter = crateRouter.prepare(reportCrate);
// Define a custom GET operation
@mime("custom/mime")
string generatePdf() {
return "pdf-data";
}
typeRouter.itemOperation!("generate-pdf")(&generatePdf);

This creates GET /reports/:id/generate-pdf.

For POST operations, the handler takes a parameter:

@mime("application/json")
int calculate(int value) {
return value * 2;
}
typeRouter.itemOperation!("calculate")(&calculate);

This creates POST /reports/:id/calculate.

itemOperation always resolves an item, so its path carries an :id. When the endpoint acts on the collection as a whole — a summary, a bulk import, a search — use collectionOperation instead:

auto typeRouter = crateRouter.prepare(reportCrate);
@mime("application/json")
string stats() {
return `{"total": 42}`;
}
typeRouter.collectionOperation!("stats")(&stats);

This creates GET /reports/stats. As with itemOperation, a handler taking a single built-in parameter becomes a POST instead.

The collection middlewares still run, so auth and visibility rules apply exactly as they do to getList — the operation simply skips the item lookup.

A handler may take a struct parameter, and Crate describes it into the operation’s input schema. For a REST call the struct is filled from the JSON body, falling back to query parameters when the body is empty:

struct SearchParams {
@describe("Text to search for")
string term;
@describe("Maximum results to return")
int limit;
}
@mime("application/json")
string search(SearchParams params) {
// ...
}
typeRouter.collectionOperation!("search")(&search);

The first struct parameter that is not a vibe Json becomes the input type. For MCP the same description generates the tool’s input schema, and the tool call’s arguments object is deserialized into it.

A custom operation registered this way is exposed as a tool by the MCP policy as well as an HTTP endpoint. writeToolResult lets one handler serve both:

import crate.protocols.mcp.toolResult;
void summary(HTTPServerRequest request, HTTPServerResponse response) {
auto data = Json.emptyObject;
data["total"] = 42;
writeToolResult(request, response, data);
}

For a JSON-RPC request it wraps the payload in the MCP tools/call envelope, replying with a text content block that carries the serialized JSON. For a plain REST call it writes the JSON directly. The handler does not need to know which surface it is answering.

writeToolImageResult(request, response, bytes, mimeType) is the binary form: MCP clients get an image content block with base64 data, REST clients get the raw bytes with their content type.

For endpoints that don’t follow the item pattern, use .withCustomOperation() with a class that extends ApiOperation:

import crate.http.operations;
class HealthCheckOperation : ApiOperation {
this() {
CrateRule rule;
rule.request.path = "/health";
rule.request.method = HTTPMethod.GET;
rule.response.statusCode = 200;
super(rule);
}
override void handle(OperationContext ctx) {
ctx.response.writeJsonBody(Json(["status": Json("ok")]));
}
}

Register it on a router:

crateRouter
.prepare(someCrate)
.withCustomOperation(new HealthCheckOperation);

Or on a blank router (no model attached):

crateRouter
.blank()
.withCustomOperation(new HealthCheckOperation);

A common pattern is adding a history/audit endpoint to a model:

class HistoryOperation : ApiOperation {
this(string modelPath) {
CrateRule rule;
rule.request.path = modelPath ~ "/:id/history";
rule.request.method = HTTPMethod.GET;
rule.response.statusCode = 200;
super(rule);
}
override void handle(OperationContext ctx) {
auto id = ctx.request.params["id"];
// Fetch history from audit log...
ctx.response.writeJsonBody(historyData);
}
}
crateRouter
.prepare(campaignCrate)
.withCustomOperation(new HistoryOperation("/campaigns"));