Skip to content

Serialization

Each protocol policy defines a serializer that converts between internal JSON and the protocol’s wire format:

interface ModelSerializer {
// Model metadata
ModelDescription definition();
// Response formatting (returns streaming string ranges, not Json)
InputRange!string toResponseList(InputRange!Json data, OperationContext ctx);
InputRange!string toResponseItem(Json data, OperationContext ctx);
// Request parsing
Json fromRequest(string id, Json data);
// OpenAPI schema keys and definitions
string respondItemSchemaKey();
string respondListSchemaKey();
string itemKey();
Schema[string] schemas();
}

The serializer sits between the database layer (which works with raw JSON) and the HTTP response. It returns InputRange!string for streaming — chunks are written to the response as they’re produced, avoiding full materialization in memory. Different protocols format the same data differently.

RestApiSerializer wraps data in a key matching the model name:

Conceptually, the serializer wraps the item in a key matching the model’s singular name:

Input: {"_id": "abc", "name": "Alice"}

Output:

{
"user": {
"_id": "abc",
"name": "Alice"
}
}

The list serializer wraps items in the plural key. Items are streamed — the response mapper runs on each item as it’s produced:

Output:

{
"users": [
{"_id": "abc", "name": "Alice"},
{"_id": "def", "name": "Bob"}
]
}

REST requests wrap the data in a model key:

{
"user": {
"name": "Charlie",
"email": "charlie@example.com"
}
}

The serializer extracts the inner object using the item ID and request body:

Json fromRequest(string id, Json data) {
return data[definition.singular];
}

The ModelDescription struct provides metadata about a model, computed at compile time via describeModel!T:

struct ModelDescription {
string singular; // "user"
string plural; // "users"
Field[] fields; // Field names, types, optionality
}

The singular name is derived from the struct name:

  • Useruser
  • MapFilemapFile
  • SiteSubsetsiteSubset

The plural name appends “s”:

  • userusers
  • mapFilemapFiles

Each field records:

  • Name
  • D type
  • Whether it’s @optional
  • Whether it has a custom serializer
  • Whether it’s a relation (references another model with _id)

vibe.d’s serializeToJson handles the base conversion from D structs to JSON:

struct Point {
string type = "Point";
float[2] coordinates;
}
auto p = Point("Point", [1.0, 2.0]);
auto json = p.serializeToJson;
// {"type": "Point", "coordinates": [1.0, 2.0]}

Models can override the default serialization by implementing toJson and fromJson:

struct Site {
string _id;
Point position;
Json toJson() const @safe {
Json data = Json.emptyObject;
data["_id"] = _id;
data["position"] = position.serializeToJson;
return data;
}
static Site fromJson(Json src) @safe {
Site site;
site._id = src["_id"].get!string;
site.position = deserializeJson!Point(src["position"]);
return site;
}
}

When toJson/fromJson exist, vibe.d uses them instead of the default field-by-field serialization.

Model references are serialized differently depending on the direction:

Relations are serialized as full objects in responses:

{
"campaign": {
"_id": "abc",
"title": "Save the Park",
"team": {
"_id": "xyz",
"name": "Green Team"
}
}
}

Clients send relations as ID strings:

{
"campaign": {
"title": "Save the Park",
"team": "xyz"
}
}

The itemResolver function uses crateGetters to resolve the ID to a full object:

Json itemResolver(string modelName, string id) {
if (auto getter = modelName in crateGetters) {
return (*getter)(id);
}
throw new Exception("No getter for " ~ modelName ~ " model");
}

This happens in CreateItemApiOperation and UpdateItemApiOperation using the Lazy!T wrapper, which defers relation resolution until the object is actually needed.

Before serialization, response items pass through any registered @mapper middleware:

@mapper
Json mapper(HTTPServerRequest req, const Json item) @trusted nothrow {
Json result = item.clone;
// Add computed fields
result["displayName"] = result["firstName"].get!string ~ " " ~ result["lastName"].get!string;
// Remove sensitive fields
result.remove("passwordHash");
return result;
}

Mappers run per-item, applied by the serializer during response construction.

Operations generate ETags for caching:

  1. If the item has a hash field, use it directly
  2. Otherwise, compute CRC32 of the JSON string representation
auto etag = item["hash"].get!string; // If available
// or
import std.digest.crc : crc32Of;
auto etag = crc32Of(item.toString).toHexString;

The ETag is set in the response headers for conditional GET support (If-None-Match).

List responses use InputRange!Json for memory-efficient streaming:

auto responseData = serializer.toResponseList(
storage.query.exec.inputRangeObject, // Lazy stream
storage
);
storage.response.writeRangeBody(responseData, statusCode, mime);

Items are fetched from the database, mapped, and written to the response stream one at a time. This prevents loading entire collections into memory.