Skip to content

Tutorial: Build a Complete API

This tutorial walks you through building a complete API with Crate, from installing D to running a multi-model service with authentication, middleware, and custom operations. No prior D experience required.

By the end you will have a project that looks like a real production service: a service class, configuration files, multiple models in separate packages, query filters, response mappers, and a custom publish endpoint.

D compiles to native code and has a package manager called dub. Install both:

Linux (most distros):

Terminal window
curl -fsS https://dlang.org/install.sh | bash -s dmd

This installs the D compiler (dmd) and dub. Follow the on-screen instructions to add them to your PATH.

macOS:

Terminal window
brew install dmd

Windows:

Download the installer from dlang.org/download. The installer includes both dmd and dub.

Verify the installation:

Terminal window
dmd --version
dub --version

Crate includes an init template that generates a complete project structure interactively:

Terminal window
mkdir blog-api && cd blog-api
dub init -t crate

The wizard asks for:

  • Project nameblog-api
  • DescriptionA blog API built with Crate
  • Number of models2
  • Model namesArticle, then Category
  • Storage backendmongo
  • Protocolsrest
  • Authenticationyes
  • Port9090

This generates the full project tree:

blog-api/
source/
app.d # Entry point
blog_api/api/
api.d # Central route registration
configuration.d # Config struct
service.d # WebService class
packages/
articles/
dub.json
source/blog_api/articles/
model.d # Article struct
api.d # Route setup
middleware/filter.d # Query filter
operations/count.d # Custom operation
tests/
getList.d, getItem.d, post.d, delete_.d
categories/
... # Same layout
config/
configuration.json # Runtime config
configuration.model.json # Reference template
db/mongo.json # MongoDB connection
about/
name.txt, version.txt, description.txt
dub.json # Root build config
.gitignore

Every model lives in its own packages/ sub-package. The source/ directory contains only the entry point, service class, configuration, and route registration. This separation keeps each feature self-contained.

Open source/app.d:

import blog_api.api.service;
import crate.service.main;
int main(string[] args) {
return mainService!(ApiService)(args);
}

mainService reads configuration from config/configuration.json, instantiates your ApiService, and calls its main() method. You start the server with:

Terminal window
dub run -- start

The -- start argument tells the service to boot the HTTP server.

Open source/blog_api/api/service.d. The service class extends WebService and is where you set up the HTTP server, connect to the database, and register routes:

class ApiService : WebService!ApiConfiguration {
override int main() {
auto router = new URLRouter;
// connect crates, register routes, start listening
return runEventLoop();
}
}

WebService!T handles configuration loading automatically. Your ApiConfiguration struct (in configuration.d) maps 1:1 to config/configuration.json:

struct ApiConfiguration {
GeneralConfig general;
HTTPConfig http;
}

You add fields to this struct as your project grows — each field maps to a key in the JSON file.

Open packages/articles/source/blog_api/articles/model.d. A model is a D struct:

struct Article {
string _id;
string title;
string content;
}

The _id field is the primary key. Crate introspects the struct at compile time to generate endpoints, serialization, and validation.

Mark fields that don’t need to be provided on creation with @optional:

import vibe.data.serialization : optional;
struct Article {
string _id;
string title;
string content;
@optional {
string[] tags;
string status;
SysTime publishedAt;
}
}

Fields inside @optional { } can be omitted in POST requests. They’ll be zero-initialized (empty string, empty array, etc).

Models can contain other structs:

struct Visibility {
string teamId;
bool isPublic;
}
struct Article {
string _id;
string title;
string content;
Visibility visibility;
@optional string[] tags;
}

Crate serializes nested structs as nested JSON objects automatically.

In your service class, crates are created from MongoDB collections:

import crate.collection.mongo;
auto articles = new MongoCrate!Article(client, "blog.articles");
auto categories = new MongoCrate!Category(client, "blog.categories");

The first argument is a MongoDB client (obtained from your config), the second is the database.collection name. MongoCrate implements the same interface as MemoryCrate, so you can swap storage backends without changing any other code.

Open source/blog_api/api/api.d. This is where all routes come together:

void setupApi(T)(CrateRouter!T crateRouter, /* crates, config, etc. */) {
setupArticleApi(crateRouter, crates);
setupCategoryApi(crateRouter, crates);
}

Each feature package exports a setup*Api function. Inside packages/articles/source/blog_api/articles/api.d:

void setupArticleApi(T)(CrateRouter!T crateRouter, Crate!Article articleCrate) {
crateRouter.add(articleCrate);
}

This single line generates six endpoints:

MethodPathOperation
GET/articlesList all
GET/articles/:idGet one
POST/articlesCreate
PUT/articles/:idReplace
PATCH/articles/:idUpdate
DELETE/articles/:idDelete

Crate integrates with vibe-auth for OAuth2. In your central api.d:

import crate.auth.usercollection;
import vibeauth.authenticators.OAuth2;
auto collection = new UserCrateCollection([], userCrate);
auto auth = new OAuth2(collection);
crateRouter.router.crateSetup.enable(auth);

This protects all routes globally. For finer control, you can apply auth as middleware on specific models (covered below).

Use different middleware classes to control access per model:

import crate.auth.middleware;
auto publicAuth = new PublicDataMiddleware(collection, null, OAuth2Configuration());
auto privateAuth = new PrivateDataMiddleware(collection, null, OAuth2Configuration());
// Articles: readable without auth, writable with auth
crateRouter.prepare(articleCrate).and(publicAuth);
// Categories: always requires auth
crateRouter.prepare(categoryCrate).and(privateAuth);

PublicDataMiddleware allows unauthenticated reads but requires a token for writes. PrivateDataMiddleware requires a token for everything.

Middleware classes intercept requests at specific points. They use annotations to declare which operations they apply to.

A filter modifies the database query based on request parameters. Create packages/articles/source/blog_api/articles/middleware/filter.d:

class TagFilter {
struct Parameters {
@describe("Filter articles by tag. Only articles containing this tag will be returned.")
@example("dlang", "Return only articles tagged with `dlang`.")
@example("tutorial", "Return only articles tagged with `tutorial`.")
string tag;
}
@get
IQuery get(IQuery selector, Parameters parameters) {
if (parameters.tag == "") return selector;
return selector.where("tags").arrayContains(parameters.tag).and;
}
}

The @get annotation means this runs on GET list and GET item requests. The nested Parameters struct declares which query parameters this filter accepts — Crate parses them from the URL automatically at compile time. No manual req.params.get(...) needed.

The @describe and @example annotations are picked up by OpenAPI spec generation, so your API docs stay in sync with the code. A request to /articles?tag=dlang only returns articles whose tags array contains "dlang".

A mapper decorates each item in the response. Useful for computed fields:

class ArticleStatsMapper {
private Crate!Metric metricCrate;
this(Crate!Metric metricCrate) {
this.metricCrate = metricCrate;
}
@mapper
void addViewCount(HTTPServerRequest req, ref Json item) {
item["viewCount"] = metricCrate.get
.where("articleId").equal(item["_id"].to!string).and
.size;
}
}

The @mapper annotation runs this on every item returned by GET requests. It adds a viewCount field computed from a separate collection.

Validate or transform data before it hits the database:

class SlugMiddleware {
@create
void setSlug(HTTPServerRequest req) {
auto json = req.json;
if ("slug" !in json["article"] || json["article"]["slug"].to!string == "") {
json["article"]["slug"] = json["article"]["title"].to!string
.toLower.replace(" ", "-");
}
}
}

@create runs on POST requests. @put runs on PUT. You can combine them: a method annotated @create @put runs on both.

Back in api.d, chain middleware with .prepare() and .and():

void setupArticleApi(T)(CrateRouter!T crateRouter, Crate!Article articleCrate) {
auto tagFilter = new TagFilter;
auto slugMiddleware = new SlugMiddleware;
auto statsMapper = new ArticleStatsMapper(metricCrate);
crateRouter
.prepare(articleCrate)
.and(tagFilter)
.and(slugMiddleware)
.and(statsMapper);
}

Middleware runs in the order it’s added. Filters narrow the query, validators check/transform the payload, and mappers decorate the response.

Standard CRUD covers most endpoints, but sometimes you need something specific — like publishing an article. Custom operations add new routes to an existing model.

Create packages/articles/source/blog_api/articles/operations/publish.d:

import crate.base;
class ArticlePublishOperation : CrateOperation {
private Crate!Article crate;
this(Crate!Article crate) {
CrateRule rule;
rule.request.path = "/articles/:id/publish";
rule.request.method = HTTPMethod.POST;
super(crate, rule);
this.crate = crate;
}
override void handle(OperationContext storage) {
auto item = this.prepareItemOperation!"getItem"(storage);
item["status"] = "published";
item["publishedAt"] = Clock.currTime.toISOExtString;
crate.updateItem(item);
storage.response.statusCode = 200;
storage.response.writeJsonBody(item);
}
}

Register it in the route setup:

auto publishOp = new ArticlePublishOperation(articleCrate);
crateRouter
.prepare(articleCrate)
.withCustomOperation(publishOp)
.and(tagFilter)
.and(slugMiddleware);

This adds POST /articles/:id/publish alongside the auto-generated CRUD routes.

The config/ directory contains runtime configuration that the framework loads automatically:

config/configuration.json — main config, maps to your ApiConfiguration struct:

{
"general": {
"serviceName": "blog-api",
"apiUrl": "http://localhost:9090",
"db": "./config/db"
},
"http": {
"hostName": "0.0.0.0",
"port": 9090
}
}

config/db/mongo.json — MongoDB connection:

{
"type": "mongo",
"url": "mongodb://127.0.0.1:27017/blog",
"configuration": {
"database": "blog",
"maxConnections": 10
}
}

Add new fields to your configuration struct as needed. The framework deserializes the JSON directly into the struct — no parsing code required.

Hardcoding values in configuration.json works for local development, but in production you want to inject settings from the environment. Crate supports three substitution syntaxes that are resolved before the JSON is deserialized into your struct:

SyntaxExampleResult
"$VAR""$apiUrl"Replaced with the env var value as a string
"#VAR""#logLevel"Replaced with the env var value as an integer
"{$A}...{$B}""{$host}:{#port}"Inline interpolation — each {...} segment is resolved and concatenated

If an env var is not set, the service fails to start with a clear error message. This is intentional — missing configuration should be caught immediately, not silently default to empty strings.

Create a config/configuration.docker.json for deployment:

{
"general": {
"serviceName": "$serviceName",
"apiUrl": "$apiUrl",
"logLevel": "#logLevel",
"db": "./config/db"
},
"http": {
"hostName": "$HOSTNAME",
"port": "#httpPort"
}
}

And a config/db.model/mongo.json:

{
"type": "mongo",
"url": "$mongoUrl",
"configuration": {
"database": "$mongoDBName"
}
}

The .model suffix is a convention — these are reference templates that get copied into place during container build. The actual config/db/mongo.json used at runtime is gitignored. See Deploying for Docker, Compose, and Helm setup.

Build and start the server:

Terminal window
dub build
dub run -- start

Test with curl:

Terminal window
# Create an article
curl -X POST http://localhost:9090/articles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"article": {"title": "Hello World", "content": "My first post", "tags": ["intro"]}}'
# List articles
curl http://localhost:9090/articles
# Filter by tag
curl http://localhost:9090/articles?tag=intro
# Publish an article
curl -X POST http://localhost:9090/articles/000000000000000000000001/publish \
-H "Authorization: Bearer <token>"

Run the test suite:

Terminal window
dub test

Tests use in-memory crates (MemoryCrate) so they don’t need a running database. Each test file spins up a router, seeds test data, and makes HTTP requests against it.

The pattern used in this tutorial is the same pattern used in production Crate services:

source/ # Thin entry point + wiring
app.d # mainService!(ApiService)(args)
blog_api/api/
service.d # WebService subclass
configuration.d # Config struct
api.d # Central setupApi() function
packages/ # One sub-package per feature
articles/
source/blog_api/articles/
model.d # Data struct
api.d # setupArticleApi() — route + middleware wiring
middleware/ # Filters, validators, mappers
operations/ # Custom non-CRUD endpoints
tests/ # Per-feature tests
config/ # Runtime configuration (not compiled)
configuration.json
db/mongo.json

Each feature is a self-contained dub sub-package with its own model, route setup, middleware, operations, and tests. The central api.d calls each feature’s setup function. This scales cleanly — adding a new model means adding a new package directory without touching existing code.