A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
expediate provides an Express-compatible API surface with full TypeScript types, built-in body parsing, static file serving, JWT authentication, multipart form handling, a Git Smart HTTP gateway, and a suite of production-ready middleware — all in a single package with no runtime dependencies beyond Node.js itself.
- Installation
- Quick start
- Router
- Body parsing
- Static files
- Middleware
- Request logging
- JWT Authentication
- API service builder
- Git Smart HTTP gateway
- OpenAPI spec generation
- TypeScript types
npm install expediateNode.js ≥ 18 is required. The package ships as native ESM with full TypeScript declarations.
import { createRouter, json, logger } from 'expediate';
const app = createRouter();
app.use('/', logger());
app.use('/', json());
app.get('/hello/:name', (req, res) => {
res.send(`Hello, ${req.params.name}!`);
});
app.post('/echo', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify((req as any).body));
});
app.listen(3000, () => console.log('Listening on :3000'));import { createRouter } from 'expediate';
const app = createRouter();createRouter() returns a Router object that also acts as a middleware function itself, making it nestable.
All registration methods share the same signature:
router.METHOD(path, ...middleware)| Method | HTTP verb | Notes |
|---|---|---|
router.get(path, ...mw) |
GET |
Path is not stripped from req.path — chained middlewares see the full path |
router.post(path, ...mw) |
POST |
|
router.put(path, ...mw) |
PUT |
|
router.delete(path, ...mw) |
DELETE |
|
router.patch(path, ...mw) |
PATCH |
|
router.use(path, ...mw) |
any | Strips the matched prefix from req.path before calling middleware; used for mounting sub-routers |
router.all(path, ...mw) |
any | Matches any method but don't strips the prefix |
Each middleware slot accepts a Middleware function, a Router instance, or an array of either:
const guard: Middleware = (req, res, next) => {
if (!req.headers.authorization) return res.status(401).end();
next();
};
app.get('/secret', guard, (req, res) => res.send('classified'));
app.get('/multi', [guard, anotherMiddleware], handler);Three pattern types are supported:
Plain strings with :param segments
app.get('/users/:id', handler); // req.params.id
app.get('/orgs/:org/repos/:repo', handler); // req.params.org, req.params.repoParameters accept an optional inline regex constraint in parentheses. Only paths where the segment matches the constraint are routed to the handler — other values fall through to the next matching route:
app.get('/items/:id(\\d+)', handler); // digits only — /items/42 ✓ /items/abc ✗
app.get('/files/:name([\\w-]+)', handler); // word chars and hyphens
app.get('/v:ver(\\d+)/status', handler); // literal suffix after constraintThe constraint replaces the default [^/]+ body; params are always strings — no coercion is performed. Named capture groups inside constraints are not allowed (they conflict with the outer (?<name>…) wrapper).
Glob patterns (.gitignore wildcard rules)
app.get('/api/*', handler); // one path segment
app.get('/api/**', handler); // any depth
app.get('/**/*.php', handler); // PHP files in any subdirectory
app.get('/v?/status', handler); // one character wildcardRegular expressions — named capture groups become req.params entries
app.get(/^\/users\/(?<id>\d+)/, handler); // req.params.idRoute specificity: when using
apiBuilder, routes are automatically sorted by specificity (more segments / fewer parameters first) to prevent shorter paths from shadowing longer ones.
Every request object is augmented with additional fields before any middleware runs:
| Field | Type | Description |
|---|---|---|
req.originalUrl |
string |
Raw URL string, never modified |
req.path |
string |
Pathname portion of the URL; rewritten by use() prefix layers |
req.params |
Record<string, string> |
Merged map of URL query params and named route params |
req.queries.url |
Record<string, string | string[]> |
URL query parameters (repeated keys collect into arrays) |
req.queries.route |
Record<string, string> |
Named route parameters captured from the path pattern |
req.cookies |
Record<string, unknown> |
Parsed Cookie header values |
req.ip |
string |
Remote client IP. When trustProxy: true, taken from X-Forwarded-For |
req.json(opts?) |
Promise<unknown> |
Read and parse the request body as JSON |
req.text(opts?) |
Promise<string> |
Read and decode the request body as plain text |
req.formData(opts?) |
Promise<FormPart[]> |
Read and parse a multipart/form-data body |
Enable proxy IP trust when running behind nginx / a load balancer:
const app = createRouter({ trustProxy: true });Every response object is augmented with convenience methods:
res.send('Hello'); // write body and end
res.send(); // end with no body
res.status(404).send('Not found'); // set status + body (chainable)
res.status(201).end(); // set status and end
res.redirect('/new-url'); // 302 redirect
res.json({ ok: true }); // JSON body + Content-Type header
res.type('text/csv').send(data); // set Content-Type (chainable)
res.etag('v1').json(payload); // weak ETag W/"v1" (chainable)
res.etag(sha256hex, true).send(buf); // strong ETag "sha256hex"
res.download('/path/to/file.pdf'); // Content-Disposition: attachment
res.download('/path/to/file.pdf', 'invoice.pdf'); // custom download name
res.cookie('session', 'abc', {
maxAge: 3_600_000, // milliseconds
path: '/api',
httpOnly: true,
secure: true,
sameSite: 'Strict',
});Register a global error handler to catch sync throws, async rejections, and next(err) calls:
app.onError((err, _req, res) => {
const status = (err as any)?.status ?? 500;
res.status(status).json({ error: String(err) });
});Register a custom 404 handler for unmatched routes:
app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));Pass an error to next() from within a middleware to skip to the error handler:
app.use('/protected', (req, _res, next) => {
if (!req.headers.authorization) return next(new Error('Unauthorized'));
next();
});Routers are fully nestable. Use router.use() to mount a child router under a prefix — the prefix is stripped from req.path before child middleware runs:
const api = createRouter();
api.get('/users', listUsers);
api.get('/users/:id', getUser);
api.post('/users', createUser);
const app = createRouter();
app.use('/api/v1', api); // /api/v1/users → api sees /usersPass a Router instance directly (no need to unwrap .listener):
app.use('/auth', authRouter); // Router instance
app.use('/auth', authRouter.listener); // equivalentrouter.listen() returns the underlying http.Server (or https.Server) instance:
// HTTP
const server = app.listen(3000, () => console.log('Ready'));
// HTTPS
import { readFileSync } from 'fs';
app.listen(443, {
key: readFileSync('server.key'),
cert: readFileSync('server.crt'),
});
// HTTP/2
app.listen(443, { key, cert, http2: true });
// Graceful shutdown
process.on('SIGTERM', () => app.shutdown(10_000));
// Discover OS-assigned ephemeral port (useful in tests)
const srv = app.listen(0, () => {
const { port } = srv.address() as AddressInfo;
console.log(`Listening on port ${port}`);
});Inspect all registered routes at runtime:
console.log(app.routes());
// [{ method: 'GET', path: '/users', stripPath: false }, ...]All body-parsing middleware must be registered before route handlers that need req.body.
Parses application/json request bodies and populates req.body. Also attaches res.json(data) for sending JSON responses.
import { json } from 'expediate';
app.use('/', json());
app.post('/data', (req, res) => {
const body = (req as any).body;
res.json({ received: body });
});| Option | Type | Default | Description |
|---|---|---|---|
limit |
string | number |
'100kb' |
Maximum body size. Accepts '10kb', '2mb', '1gb', or a number of bytes |
inflate |
boolean |
true |
Decompress gzip/deflate bodies automatically |
reviver |
Reviver | null |
null |
Passed as the second argument to JSON.parse |
strict |
boolean |
true |
Reserved for top-level primitive rejection |
Status codes returned on error:
413 Content Too Large— body exceedslimit415 Unsupported Media Type— wrongContent-Typeor unsupported encoding500 Internal Server Error— malformed JSON or unsupported charset
Parses multipart/form-data bodies. Populates req.body with an array of FormPart objects, each exposing:
headers— part headers (lowercased, e.g.content-disposition)content— rawBufferof the part body
import { formData } from 'expediate';
app.post('/upload', formData(), (req, res) => {
const parts = (req as any).body as FormPart[];
for (const part of parts) {
const disp = part.headers['content-disposition'];
console.log(disp, '-', part.content.length, 'bytes');
}
res.status(201).end();
});Accepts the same limit and inflate options as json().
Parses application/x-www-form-urlencoded bodies. Repeated keys (e.g. tags=a&tags=b) produce array values.
import { formEncoded } from 'expediate';
app.post('/form', formEncoded(), (req, res) => {
const { username, tags } = (req as any).body;
res.json({ username, tags }); // tags may be string | string[]
});Auto-detects the Content-Type and dispatches to the appropriate parser. Supports:
| Content-Type | Result in req.body |
|---|---|
application/json |
Parsed JS value |
multipart/form-data |
FormPart[] |
application/x-www-form-urlencoded |
Record<string, string | string[]> |
text/plain |
Decoded string |
import { parseBody } from 'expediate';
app.use('/', parseBody());Unsupported MIME types receive 415 Unsupported Media Type.
Async generator that yields each part of a multipart/form-data body as a stream, without waiting for the entire body to buffer first.
import { streamFormData } from 'expediate';
app.post('/upload', async (req, res) => {
for await (const part of streamFormData(req)) {
const name = part.headers['content-disposition'];
const chunks: Buffer[] = [];
for await (const chunk of part.stream) chunks.push(chunk);
const content = Buffer.concat(chunks);
// process content...
}
res.send('ok');
});Serve an entire directory of static assets:
import { serveStatic } from 'expediate';
app.use('/public', serveStatic('./dist'));Features: ETag, Last-Modified, conditional GET (304), Cache-Control, MIME-type detection, dot-file handling, directory index redirect, gzip/deflate decompression.
| Option | Type | Default | Description |
|---|---|---|---|
maxage / maxAge |
number |
0 |
Cache lifetime in milliseconds → Cache-Control: public, max-age=<s> |
immutable |
boolean |
false |
Appends , immutable to Cache-Control |
etag |
boolean |
true |
Send weak ETag header |
lastModified |
boolean |
true |
Send Last-Modified header |
dotfiles |
'allow' | 'deny' | 'hide' |
'hide' |
Dot-file access policy |
redirect |
boolean |
true |
Redirect directory requests to index.html |
fallthrough |
boolean |
true |
Call next() instead of sending 404 for missing files |
contentType |
string | null |
null |
Override auto-detected Content-Type |
headers |
Record<string, string> |
security defaults | Extra response headers merged with built-in CSP / XCTO headers |
Serve a single fixed file for every request — ideal for SPA catch-all routes:
import { serveFile } from 'expediate';
// Serve dist/index.html for every unmatched route
app.get('/**', serveFile('./dist/index.html'));Supports the same caching and method-filtering options as serveStatic(). Returns 500 EISDIR if the path points to a directory.
Low-level utility for sending an arbitrary file path dynamically:
import { sendFile } from 'expediate';
import type { StaticOptions } from 'expediate';
const opts: StaticOptions = { etag: true, lastModified: true };
app.get('/downloads/:file', (req, res) => {
const filePath = path.join('./downloads', req.params.file);
sendFile(req as any, res as any, filePath, opts as any);
});A suite of production-ready middleware is included. All middleware factories return standard Middleware functions and can be mounted globally or on individual routes.
Transparent response compression (Brotli, gzip, deflate). Must be mounted before any middleware that writes response bodies.
import { compress } from 'expediate';
app.use(compress()); // default: Brotli > gzip > deflate, threshold 1 KB
app.use(compress({ threshold: 512, brotliQuality: 6 }));| Option | Type | Default | Description |
|---|---|---|---|
threshold |
number |
1024 |
Minimum body size in bytes before compression is applied |
br |
boolean |
true |
Enable Brotli when the client supports it |
brotliQuality |
number |
4 |
Brotli quality level (0–11) |
gzipLevel |
number |
default | gzip / deflate compression level (1–9) |
filter |
(req, res) => boolean |
— | Custom function to skip compression for specific responses |
Handles If-None-Match and If-Modified-Since request headers (RFC 7232). When the response ETag or Last-Modified header indicates the client's cache is still fresh, a 304 Not Modified is sent instead of the full response body.
import { conditionalGet } from 'expediate';
app.get('/api/user/:id', conditionalGet(), (req, res) => {
const user = getUser(req.params.id);
res.etag(user.updatedAt.toISOString()); // set before sending
res.json(user); // → 304 when client is up to date
});res.etag(value, strong?) is a response helper available on every response:
res.etag('v1'); // weak ETag: W/"v1"
res.etag(sha256hex, true); // strong ETag: "sha256hex"Freshness is checked in RFC 7232 priority order: If-None-Match first (weak comparison, * wildcard supported), then If-Modified-Since. Only GET and HEAD are eligible for 304 — other methods pass through unchanged.
Sets Cache-Control, Expires, and Vary response headers.
import { cacheControl } from 'expediate';
app.use('/api', cacheControl({ noStore: true }));
app.use('/static', cacheControl({ maxAge: 31_536_000, immutable: true }));| Option | Type | Description |
|---|---|---|
maxAge |
number |
max-age=<seconds>. Also sets Expires. |
sMaxAge |
number |
s-maxage=<seconds> for shared/CDN caches. |
public / private |
boolean |
Cache scope directive. |
noStore |
boolean |
Disables caching entirely. |
noCache |
boolean |
Requires revalidation before serving cached response. |
mustRevalidate |
boolean |
Stale responses must be revalidated. |
immutable |
boolean |
Response body will never change within its max-age. |
vary |
string | string[] |
Sets the Vary header. |
Attaches a unique req.id to every request and echoes it in the response header.
import { requestId } from 'expediate';
app.use(requestId());
app.get('/health', (req, res) => res.json({ id: req.id, status: 'ok' }));| Option | Type | Default | Description |
|---|---|---|---|
header |
string |
'x-request-id' |
Header name to read and write. |
allowFromHeader |
boolean |
true |
Reuse client-supplied ID (set false in security-sensitive contexts). |
generator |
() => string |
crypto.randomUUID |
Custom ID generator. |
In-memory sliding-window rate limiting.
import { rateLimit } from 'expediate';
// 100 requests per minute per IP
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
// Tighter limit on login
app.post('/auth/login', rateLimit({ windowMs: 60_000, max: 5 }), loginHandler);| Option | Type | Default | Description |
|---|---|---|---|
windowMs |
number |
required | Sliding window duration in milliseconds. |
max |
number |
required | Maximum requests per client key within the window. |
keyBy |
(req) => string |
req.ip |
Key extraction function. |
message |
string |
'Too Many Requests' |
Body of the 429 response. |
statusCode |
number |
429 |
HTTP status on limit exceeded. |
headers |
boolean |
true |
Set X-RateLimit-* headers on every response. |
Note: state is in-memory and is lost on process restart. Not suitable for multi-process deployments without a shared store.
CSRF protection using the double-submit cookie pattern.
import { csrf } from 'expediate';
app.use(csrf());
app.get('/form', (req, res) =>
res.send(`<input type="hidden" name="_csrf" value="${req.csrfToken!()}">`));
// POST /form is validated automaticallySafe methods (GET, HEAD, OPTIONS, TRACE) are exempted. State-mutating requests must include the token in X-CSRF-Token header or _csrf body field.
| Option | Type | Default | Description |
|---|---|---|---|
cookieName |
string |
'_csrf' |
Cookie that stores the token. |
headerName |
string |
'x-csrf-token' |
Request header carrying the token. |
fieldName |
string |
'_csrf' |
Parsed body field (fallback when header absent). |
secure |
boolean |
false |
Mark the CSRF cookie as Secure. |
sameSite |
'Strict' | 'Lax' | 'None' |
'Strict' |
SameSite attribute of the CSRF cookie. |
Sets a hardened baseline of HTTP security headers.
import { securityHeaders } from 'expediate';
app.use(securityHeaders());
// Disable HSTS on plain HTTP:
app.use(securityHeaders({ hsts: false }));| Header set by default | Default value |
|---|---|
Strict-Transport-Security |
max-age=15552000; includeSubDomains |
X-Frame-Options |
SAMEORIGIN |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), microphone=(), camera=() |
X-XSS-Protection |
0 |
Every header can be individually disabled (pass false) or overridden with a custom string.
import { logger } from 'expediate';
app.use('/', logger({
track: true, // warn on requests that never finish (dev only)
trackTimeout: 30_000, // ms before emitting a LOST warning
user: (req) => (req as any).user?.username ?? '-',
locale: 'en-US',
logger: (msg) => process.stderr.write(msg + '\n'),
}));Output format (one line per completed request, ANSI-coloured by status class):
21 Mar, 14:32 200 GET /api/users 127.0.0.1 <alice> 4 ms (1234)
| Option | Type | Default | Description |
|---|---|---|---|
track |
boolean |
false |
Enable lost-request detection |
trackTimeout |
number |
30000 |
Timeout in ms before a LOST line is emitted |
user |
(req) => string |
() => '-' |
Extract a user identity from the request |
locale |
string |
'en-GB' |
BCP 47 locale for the timestamp |
dateFormat |
Intl.DateTimeFormatOptions |
short date+time | Timestamp format |
logger |
(msg: string) => void |
console.log |
Custom logging sink |
import { createRouter, json, createJwtPlugin } from 'expediate';
const app = createRouter();
const auth = createJwtPlugin({
accessTokenSecret: process.env.JWT_SECRET!,
refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
});
// Mount auth endpoints (require json() body parser first)
app.post('/auth/login', json(), auth.login);
app.post('/auth/refresh', json(), auth.refresh);
app.post('/auth/logout', json(), auth.logout);// Request body
{ "username": "alice", "password": "password123" }
// Response 200
{
"accessToken": "eyJ...",
"refreshToken": "a3f8...",
"expiresIn": 900,
"tokenType": "Bearer"
}// Request body
{ "username": "alice", "refreshToken": "a3f8..." }
// Response 200 — new token pair (old refresh token is invalidated)
{ "accessToken": "eyJ...", "refreshToken": "b9c2...", ... }Refresh tokens are rotated on every use — the presented token is always invalidated and a fresh pair is issued.
{ "refreshToken": "b9c2..." }
// Response 200 — refresh token revoked// authenticate — silently populates req.user; calls next() even on failure
// authorize — rejects with 401 if req.user is not set
app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
res.send(`Hello, ${(req as any).user.username}`);
});req.user is populated with the decoded TokenPayload:
interface TokenPayload {
sub: string; // user ID
username: string;
iss: string; // issuer
iat: number; // issued at (Unix s)
exp: number; // expires at (Unix s)
roles?: string[];
permissions?: string[];
}// Require at least one of the listed roles
app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
// Require ALL listed permissions
app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);Both factories return [authenticate, guard] — spread them into the route registration:
app.get('/report', ...auth.requireRole('admin', 'editor'), getReport);const auth = createJwtPlugin({
// Secrets — always override in production
accessTokenSecret: 'change-me',
refreshTokenSecret: 'change-me',
// Expiry
accessTokenExpiry: 15 * 60, // 15 minutes (seconds)
refreshTokenExpiry: 7 * 24 * 3600, // 7 days (seconds)
// Token claims
issuer: 'my-app',
checkIssuer: true, // reject tokens with wrong iss claim
alg: 'HS256', // 'HS256' | 'HS384' | 'HS512'
// 'RS256' | 'RS384' | 'RS512'
// 'ES256' | 'ES384' | 'ES512'
// For RS*/ES* algorithms supply PEM keys instead of a shared secret
// accessTokenPrivateKey: readFileSync('private.pem', 'utf8'),
// accessTokenPublicKey: readFileSync('public.pem', 'utf8'),
// User lookup (replace with a database query) — must override
fetchUser: async (username) => {
return await db.users.findOne({ username });
},
// Password validation — default uses SHA-256; replace with bcrypt/argon2
isPasswordValid: async (user, password) => {
return await bcrypt.compare(password, user.passwordHash);
},
// Custom JWT payload
payload: (user) => ({
sub: user.id,
email: user.email,
roles: user.roles,
}),
// Custom token store (replace with Redis for multi-instance deployments)
refreshTokenStore: redisAdapter,
});A built-in in-memory token store factory is available for testing:
import { createMapTokenStore } from 'expediate';
const auth = createJwtPlugin({ refreshTokenStore: createMapTokenStore() });Security note: the default password hashing uses SHA-256, which is fast and unsuitable for production. Replace
isPasswordValidwith a bcrypt or argon2 implementation.
apiBuilder lets you define REST endpoints as a controller-style service object, handling scoping, lifecycle, method binding, and error translation automatically.
import { createRouter, json, apiBuilder } from 'expediate';
import type { ServiceDefinition } from 'expediate';
interface TodoState {
items: Record<string, { title: string; done: boolean }>;
nextId: number;
}
const todoService: ServiceDefinition<TodoState> = {
// Initial state
data: () => ({ items: {}, nextId: 1 }),
// Shared helper methods (bound to `this`)
methods: {
findOrThrow(this: TodoState, id: string) {
const item = this.items[id];
if (!item) throw { status: 404, message: 'Todo not found' };
return item;
},
},
GET: {
'/todos': function (this: TodoState) { return Object.entries(this.items).map(([id, v]) => ({ id, ...v })); },
'/todos/:id': function (this: TodoState, params) { return this.findOrThrow(params.id); },
},
POST: {
'/todos': function (this: TodoState, _params, body: any) {
const id = String(this.nextId++);
this.items[id] = { title: body.title, done: false };
return { id, ...this.items[id] };
},
},
DELETE: {
'/todos/:id': function (this: TodoState, params) {
this.findOrThrow(params.id);
delete this.items[params.id];
return undefined; // → 201 No Content
},
},
};
const app = createRouter();
app.use('/', json());
app.use('/api', apiBuilder(todoService));
app.listen(3000);Handler conventions:
| Return value | HTTP response |
|---|---|
Truthy value or Promise resolving to one |
200 OK with JSON body |
undefined, null, false, 0, '' |
201 No Content |
Throw { status, message } |
<status> with plain-text body |
Throw { status, data } |
<status> with JSON body |
| Throw anything else | 500 Internal Server Error |
Control how many instances of the service state are created:
const service: ServiceDefinition = {
// Singleton (default — omit `scope`): one global instance
// scope: undefined
// Per-session: same instance reused for all requests sharing the key
scope: (req) => (req as any).session?.ssid ?? null,
// Per-request: fresh instance for every request (null key)
scope: () => null,
data: () => ({ /* initial state */ }),
};The key returned by the
scope()method is store atthis.$key.
Throw structured errors from any handler or method to send precise HTTP responses:
// Plain message
throw { status: 404, message: 'Resource not found' };
// JSON body
throw { status: 422, data: { field: 'email', error: 'invalid format' } };
// Guard pattern for async setup
methods: {
throwIfNotReady(this: any) {
if (!this.ready)
throw { status: 503, message: 'Service initialising — try again shortly' };
},
},
setup: function (this: any) {
this.loadData().then(() => { this.ready = true; });
},Serve a Git repository over HTTP (fetch / clone only — push is not supported):
import { createRouter, gitHandler } from 'expediate';
import path from 'path';
const app = createRouter();
app.use('/repos/:repo', gitHandler({
repository: (req) => {
// Resolve the repo path from the request; return falsy to 404
const name = req.params.repo;
if (!/^[\w.-]+$/.test(name)) return null;
return path.join('/srv/git', name + '.git');
},
}));
app.listen(3000);Clients can now clone with:
git clone http://localhost:3000/repos/myproject| Option | Type | Default | Description |
|---|---|---|---|
repository |
(req) => string | null |
required | Resolve the absolute path to the bare repository. Return falsy to send 404 |
gitPath |
string |
'' |
Directory prefix for the git-upload-pack binary (include trailing /) |
strict |
boolean |
false |
When true, omits --no-strict — git will reject non-bare repositories |
timeout |
number | string |
none | Kill git-upload-pack after this many seconds |
Supported endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/info/refs?service=git-upload-pack |
Capability advertisement |
POST |
/git-upload-pack |
Pack negotiation and transfer |
Gzip-compressed POST bodies are decompressed transparently. Spawn errors (e.g. git not installed) return 500 with a descriptive message.
Annotate service definitions and generate an OpenAPI 3.1 document automatically.
import { describe, openApiSpec, serializeSpec } from 'expediate';
const todoService = describe({
summary: 'Todo list API',
description: 'Manage todos',
GET: {
'/todos': {
summary: 'List all todos',
responses: { 200: { description: 'Todo array' } },
handler: function (this: TodoState) { return Object.values(this.items); },
},
},
POST: {
'/todos': {
summary: 'Create a todo',
requestBody: { required: true },
responses: { 200: { description: 'Created todo' } },
handler: function (this: TodoState, _params, body: any) {
const id = String(this.nextId++);
this.items[id] = { title: body.title, done: false };
return { id, ...this.items[id] };
},
},
},
});
const app = createRouter();
app.use('/api', apiBuilder(todoService));
app.get('/openapi.json', openApiSpec(todoService, { title: 'Todo API', version: '1.0.0' }));
app.get('/openapi.yaml', openApiSpec(todoService, { title: 'Todo API', version: '1.0.0', format: 'yaml' }));Full type declarations are included. Key types exported from the package:
// Router
import type {
Router, RouterOptions, RouterRequest, RouterResponse,
Middleware, MiddlewareArg, NextFunction, ErrorHandler,
Layer, RouteInfo, CookieOptions, TlsOptions, StringMap,
} from 'expediate';
// Body parsing
import type { BodyOptions, FormPart, FormPartStream, LoggerOptions } from 'expediate';
// Static files
import type { StaticOptions, Mime } from 'expediate';
// Middleware
import type {
CompressOptions, RequestIdOptions, RateLimitOptions,
CacheControlOptions, CsrfOptions, SecurityHeadersOptions,
} from 'expediate';
// JWT
import type { JwtConfig, JwtPlugin, TokenPayload, UserRecord, TokenStore, RefreshTokenRecord } from 'expediate';
// API builder
import type { ServiceDefinition, ServiceMethod, ServiceMethods, RouteMap, ApiError } from 'expediate';
// OpenAPI
import type { OperationMeta, OpenApiServiceMeta, SpecOptions, OpenApiDocument } from 'expediate';
// Git
import type { GitHandlerOptions } from 'expediate';MIT © 2021 Fabien Bavent
