Zum Inhalt springen
Deutsch

Route-DSL

Die Route-DSL ist einfach Funktionen. Komponiere sie zu einem Routenbaum; die Extension flacht den Baum zu einer Liste von Registrierungen ab, die das Backend versteht.

import { path, get, post, concat, completeJson, entity, redirect, reject, Status } from 'actor-ts/http';
const orders = path('orders',
concat(
get(async () => completeJson(200, { orders: [] })),
post(async (req) => {
const body = entity<NewOrder>(req);
if (!body.sku) reject(Status.BadRequest, 'missing sku');
return completeJson(201, { id: 'o-1' });
}),
path(':id',
get(async (req) => {
const id = parseId(req);
return completeJson(200, { id, status: 'ok' });
}),
),
),
);
const api = path('api', orders);

Drei Kern-Kombinatoren (path, concat, Methoden-Kombinatoren) plus drei Response-Helper (complete*, redirect, reject) decken ~95 % des Routings ab.

path('api', orders) // → /api/<orders-Pfade>
path('api/v1', users) // → /api/v1/<users-Pfade>

Stellt jedem Child-Pfad ein statisches Segment voran. Komponierbar:

path('api',
path('v1',
path('users',
get(handler), // → GET /api/v1/users
),
),
);

Pfadsegmente mit Slashes werden flachgelegt — path('api/v1', ...) ist äquivalent zu path('api', path('v1', ...)).

concat(
get(getHandler), // GET auf diesem Pfad
post(postHandler), // POST auf diesem Pfad
);

OR-Kombinator — mehrere Routen auf derselben Ebene. Das Matching ist First-Match-Wins; die Reihenfolge zählt, wenn Patterns überlappen.

// Mehrere Methoden auf demselben Pfad:
path('orders',
concat(
get(listOrders),
post(createOrder),
put(updateOrder),
),
);
// Mehrere Pfade auf derselben Ebene:
concat(
path('orders', get(listOrders)),
path('customers', get(listCustomers)),
);
get(handler); // GET
post(handler); // POST
put(handler); // PUT
del(handler); // DELETE (heißt `del`, weil `delete` ein JS-Schlüsselwort ist)
patch(handler); // PATCH
head(handler); // HEAD
options(handler); // OPTIONS

Jeder nimmt einen Handler (req) => Promise<HttpResponse> | HttpResponse entgegen. Der Handler darf async sein — das Framework awaitet den Rückgabewert.

return completeJson(200, { ok: true });
return completeJson(201, { id: 'new' }, { 'x-custom': 'value' });

Liefert eine HttpResponse mit Content-Type: application/json. Der Body wird beim Schreiben JSON-serialisiert.

return completeText(200, 'hello');

Content-Type: text/plain; charset=utf-8.

return complete(204); // kein Inhalt
return complete(200, 'roher Body'); // kein automatischer Content-Type

Die generische Form — Body / Header / Content-Type explizit übergeben.

return redirect('/login'); // 302
return redirect('/new-home', Status.MovedPermanently); // 301

Setzt Location: <url> und den passenden Status. Default ist Status.Found (302).

if (!auth) reject(Status.Unauthorized, 'missing token');
if (!entity.valid) reject(Status.BadRequest, 'invalid payload', { field: 'sku' });

Wirft einen HttpError. Das Framework fängt ihn und erzeugt eine Response mit dem passenden Status + einem JSON-Body, der die Message + ggf. Extras enthält.

Das ist der sauberste Weg, einen Handler kurzzuschließen — kein Return nötig; einfach werfen.

import { Status } from 'actor-ts/http';
Status.OK; // 200
Status.Created; // 201
Status.NoContent; // 204
Status.MovedPermanently; // 301
Status.Found; // 302
Status.BadRequest; // 400
Status.Unauthorized; // 401
Status.Forbidden; // 403
Status.NotFound; // 404
Status.UnprocessableEntity; // 422
Status.TooManyRequests; // 429
Status.InternalServerError; // 500
Status.ServiceUnavailable; // 503

Benannte Konstanten für die häufigen Status-Codes. Nimm sie gegenüber Zahlen-Literalen — an der Call-Site leichter zu lesen.

Der Handler bekommt eine HttpRequest:

interface HttpRequest {
readonly method: HttpMethod;
readonly path: string;
readonly headers: Record<string, string>;
readonly query: Record<string, string>;
readonly body: Uint8Array | null;
}

Die aktuelle DSL hat keinen typisierten Pfadparameter-Extraktor — Parameter sind Teil des rohen req.path. Extrahiere von Hand:

path('orders',
path(':id',
get(async (req) => {
// req.path ist z. B. "/api/orders/o-42"
const id = req.path.split('/').pop()!;
return completeJson(200, { id });
}),
),
);

(Eine typisierte Extraktor-DSL ist auf der Roadmap; siehe FAQ.)

get(async (req) => {
const limit = req.query.limit ?? '10';
return completeJson(200, { limit: parseInt(limit) });
});

req.query ist bereits geparst — kein manuelles URLSearchParams.

const auth = req.headers['authorization'] ?? '';
if (!auth.startsWith('Bearer ')) reject(Status.Unauthorized, 'no token');

Keys in Kleinbuchstaben.

import { entity } from 'actor-ts/http';
post(async (req) => {
const order = entity<NewOrder>(req);
// ↑ dekodiert JSON / CBOR je nach Content-Type, wirft 400 bei Malformed.
});

Siehe Marshalling für die volle Body-Geschichte.

import {
path, concat, get, post, put, del,
completeJson, complete, reject, Status, entity,
} from 'actor-ts/http';
interface Customer { id: string; name: string; }
const customers = path('customers',
concat(
get(async () => {
const all = await registry.ask({ kind: 'list' });
return completeJson(Status.OK, all);
}),
post(async (req) => {
const body = entity<{ name: string }>(req);
if (!body.name) reject(Status.BadRequest, 'missing name');
const created = await registry.ask({
kind: 'create', name: body.name,
});
return completeJson(Status.Created, created);
}),
path(':id',
concat(
get(async (req) => {
const id = idOf(req);
const c = await registry.ask({ kind: 'get', id });
if (!c) reject(Status.NotFound, `no customer ${id}`);
return completeJson(Status.OK, c);
}),
put(async (req) => {
const id = idOf(req);
const body = entity<Partial<Customer>>(req);
await registry.ask({ kind: 'update', id, ...body });
return complete(Status.NoContent);
}),
del(async (req) => {
const id = idOf(req);
await registry.ask({ kind: 'delete', id });
return complete(Status.NoContent);
}),
),
),
),
);
const root = path('api',
path('v1', concat(customers, ...)),
);
await http.newServerAt('0.0.0.0', 8080).bind(root);

Das Muster: Routen rufen über ask in Actors hinein; HTTP-Handler bleiben dünn; Business-Logik lebt in Actors.

get(async () => {
if (somethingWrong) reject(Status.BadRequest, 'bad input');
if (notFound) reject(Status.NotFound, 'not here');
try {
return completeJson(200, await heavyWork());
} catch (e) {
if (e instanceof MyDomainError) reject(Status.UnprocessableEntity, e.message);
throw e; // unbekannte Fehler fallen auf einen 500 durch
}
});

Zwei Pfade für Fehler:

  • reject(status, message) — für bekannte Fehlerfälle. Liefert einen strukturierten JSON-Fehler an den Client.
  • Wirf irgendetwas anderes — vom Backend gefangen, in einen 500 mit generischer Message verwandelt.

Die Route-API-Referenz deckt die volle Type-Oberfläche ab.