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.
Kern-Kombinatoren
Abschnitt betitelt „Kern-Kombinatoren“path(segment, child)
Abschnitt betitelt „path(segment, child)“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(...routes)
Abschnitt betitelt „concat(...routes)“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)),);Methoden-Kombinatoren
Abschnitt betitelt „Methoden-Kombinatoren“get(handler); // GETpost(handler); // POSTput(handler); // PUTdel(handler); // DELETE (heißt `del`, weil `delete` ein JS-Schlüsselwort ist)patch(handler); // PATCHhead(handler); // HEADoptions(handler); // OPTIONSJeder nimmt einen Handler (req) => Promise<HttpResponse> | HttpResponse
entgegen. Der Handler darf async sein — das Framework awaitet
den Rückgabewert.
Response-Helper
Abschnitt betitelt „Response-Helper“completeJson(status, body, headers?)
Abschnitt betitelt „completeJson(status, body, headers?)“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.
completeText(status, body, headers?)
Abschnitt betitelt „completeText(status, body, headers?)“return completeText(200, 'hello');Content-Type: text/plain; charset=utf-8.
complete(status, body?, headers?)
Abschnitt betitelt „complete(status, body?, headers?)“return complete(204); // kein Inhaltreturn complete(200, 'roher Body'); // kein automatischer Content-TypeDie generische Form — Body / Header / Content-Type explizit übergeben.
redirect(url, status?)
Abschnitt betitelt „redirect(url, status?)“return redirect('/login'); // 302return redirect('/new-home', Status.MovedPermanently); // 301Setzt Location: <url> und den passenden Status. Default ist
Status.Found (302).
reject(status, message, extra?)
Abschnitt betitelt „reject(status, message, extra?)“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; // 200Status.Created; // 201Status.NoContent; // 204Status.MovedPermanently; // 301Status.Found; // 302Status.BadRequest; // 400Status.Unauthorized; // 401Status.Forbidden; // 403Status.NotFound; // 404Status.UnprocessableEntity; // 422Status.TooManyRequests; // 429Status.InternalServerError; // 500Status.ServiceUnavailable; // 503Benannte Konstanten für die häufigen Status-Codes. Nimm sie gegenüber Zahlen-Literalen — an der Call-Site leichter zu lesen.
Mit dem Request arbeiten
Abschnitt betitelt „Mit dem Request arbeiten“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;}Pfadparameter
Abschnitt betitelt „Pfadparameter“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.)
Query-Parameter
Abschnitt betitelt „Query-Parameter“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.
Ein größeres Beispiel
Abschnitt betitelt „Ein größeres Beispiel“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.
Fehlerbehandlung
Abschnitt betitelt „Fehlerbehandlung“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.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- HTTP-Übersicht — das große Bild: Backends, Middleware, Extension-Setup.
- Marshalling —
entity<T>(req)- Dekodierung, Response-Kodierung über Accept. - Backends — Fastify — die Optionen des Default-Backends.
Die Route-API-Referenz deckt die volle
Type-Oberfläche ab.