Route Descriptors
Route descriptors allow you to attach metadata to routes. This metadata can be used by plugins like OpenAPI for documentation generation, authentication requirements, rate limiting, or any custom route-level configuration.
What is a Route Descriptor?
A route descriptor is either:
- A tuple
[symbol, value]- sets a specific metadata entry - A function
(route) => void- receives the route config for dynamic modifications
import type { RouteMetaDescriptor } from "@minimajs/server";
// Tuple form - simple key-value pair
const myDescriptor: RouteMetaDescriptor = [mySymbol, "value"];
// Function form - access to full route config
const dynamicDescriptor: RouteMetaDescriptor = (route) => {
route.metadata[mySymbol] = route.path;
};The RouteMetaDescriptor Type
The type definition:
type RouteMetaDescriptor<S = unknown> =
| [symbol: symbol, value: unknown]
| ((config: RouteConfig<S>) => void);The RouteConfig provides access to:
interface RouteConfig<S> {
methods: HTTPMethod[]; // HTTP methods (GET, POST, etc.)
path: string; // Route path pattern
handler: Handler<S>; // Route handler function
metadata: RouteMetadata; // Metadata object to modify
app: App<S>; // Application instance
}Using Descriptors on Routes
With export const routes (Recommended)
When using the file-based module system, use the handler() helper to attach descriptors to your route handlers.
import { handler, type Routes } from "@minimajs/server";
function getUsers() {
/* ... */
}
function createUser() {
/* ... */
}
export const routes: Routes = {
// Single descriptor
"GET /users": handler(myDescriptor, getUsers),
// Multiple descriptors - applied in order
"POST /users": handler(descriptor1, descriptor2, descriptor3, createUser),
};Programmatic Usage
Descriptors are passed between the path and handler in programmatic route definitions:
import { createApp } from "@minimajs/server/bun";
const app = createApp();
// Single descriptor
app.get("/users", myDescriptor, () => {
return getUsers();
});
// Multiple descriptors
app.post("/users", descriptor1, descriptor2, descriptor3, () => {
return createUser();
});Creating Custom Descriptors
Simple Tuple Descriptors
For static metadata, use helper functions that return tuples:
// descriptors/auth.ts
import type { RouteMetaDescriptor } from "@minimajs/server";
export const kAuthRequired = Symbol("auth.required");
export const kAuthRoles = Symbol("auth.roles");
export const kRateLimit = Symbol("rateLimit");
// Simple boolean flag
export function requireAuth(): RouteMetaDescriptor {
return [kAuthRequired, true];
}
// With parameters
export function requireRoles(...roles: string[]): RouteMetaDescriptor {
return [kAuthRoles, roles];
}
// With configuration object
export function rateLimit(config: { max: number; window: string }): RouteMetaDescriptor {
return [kRateLimit, config];
}Usage in a module:
import { handler, type Routes } from "@minimajs/server";
import { requireAuth, requireRoles, rateLimit } from "./descriptors/auth.js";
function getProfile() { /* ... */ }
function deleteUser() { /* ... */ }
export const routes: Routes = {
// Single descriptor
"GET /profile": handler(requireAuth(), getProfile),
// Multiple descriptors
"DELETE /users/:id": handler(
requireAuth(),
requireRoles("admin"),
rateLimit({ max: 10, window: "1m" }),
deleteUser
),
};Dynamic Function Descriptors
For metadata that depends on route properties:
import type { RouteMetaDescriptor } from "@minimajs/server";
const kOperationId = Symbol("operationId");
const kAuditLog = Symbol("auditLog");
// Generate operationId from route info
export function autoOperationId(): RouteMetaDescriptor {
return (route) => {
const method = route.methods[0].toLowerCase();
const path = route.path.replace(/[/:]/g, "_").replace(/^_/, "");
route.metadata[kOperationId] = `${method}_${path}`;
};
}
// Conditional metadata based on HTTP method
export function auditMutations(): RouteMetaDescriptor {
return (route) => {
const mutationMethods = ["POST", "PUT", "PATCH", "DELETE"];
if (route.methods.some(m => mutationMethods.includes(m))) {
route.metadata[kAuditLog] = true;
}
};
}Composing Descriptors
Create higher-level descriptors from multiple lower-level ones:
import type { RouteMetaDescriptor } from "@minimajs/server";
import { requireAuth, requireRoles, rateLimit } from "./auth.js";
import { describe } from "@minimajs/openapi";
// Combine multiple descriptors for admin routes
export function adminRoute(summary: string): RouteMetaDescriptor[] {
return [
requireAuth(),
requireRoles("admin"),
rateLimit({ max: 100, window: "1m" }),
describe({ summary, tags: ["Admin"] }),
];
}
// Usage with spread
app.get("/admin/users", ...adminRoute("List admin users"), () => {
return getAdminUsers();
});Reading Route Metadata
Access route metadata in hooks to implement cross-cutting concerns:
In Request Hooks
import { hook, abort } from "@minimajs/server";
import { kAuthRequired, kAuthRoles } from "./descriptors/auth.js";
app.register(
hook("request", async (ctx) => {
const metadata = ctx.route?.store.metadata;
if (!metadata) return;
// Check authentication requirement
if (metadata[kAuthRequired]) {
const token = ctx.request.headers.get("authorization");
if (!token) {
abort({ error: "Unauthorized" }, 401);
}
// Verify token...
}
// Check role requirements
const requiredRoles = metadata[kAuthRoles] as string[] | undefined;
if (requiredRoles?.length) {
const userRoles = getUserRoles(); // From verified token
const hasRole = requiredRoles.some(r => userRoles.includes(r));
if (!hasRole) {
abort({ error: "Forbidden" }, 403);
}
}
})
);In Send Hooks
import { hook } from "@minimajs/server";
const kCacheControl = Symbol("cacheControl");
app.register(
hook("send", (response, ctx) => {
const cacheControl = ctx.route?.store.metadata[kCacheControl] as string | undefined;
if (cacheControl) {
response.headers.set("Cache-Control", cacheControl);
}
return response;
})
);Building Plugins from Metadata
Create reusable plugins that act on metadata:
import { plugin, hook, abort } from "@minimajs/server";
import { kRateLimit } from "./symbols.js";
interface RateLimitConfig {
max: number;
window: string;
}
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export const rateLimitPlugin = plugin(async (app) => {
app.register(
hook("request", (ctx) => {
const config = ctx.route?.store.metadata[kRateLimit] as RateLimitConfig | undefined;
if (!config) return;
const key = `${ctx.request.ip}:${ctx.pathname}`;
const now = Date.now();
const entry = rateLimitStore.get(key);
if (entry && entry.resetAt > now) {
if (entry.count >= config.max) {
abort({ error: "Rate limit exceeded" }, 429);
}
entry.count++;
} else {
rateLimitStore.set(key, {
count: 1,
resetAt: now + parseWindow(config.window)
});
}
})
);
});Module-Level Descriptors
Use the descriptor() plugin to apply metadata to all routes in a module:
import { descriptor } from "@minimajs/server/plugins";
import type { Meta, Routes } from "@minimajs/server";
export const meta: Meta = {
plugins: [
descriptor(
requireAuth(),
rateLimit({ max: 100, window: "1m" })
),
],
};
function getData() { /* ... */ }
function createData() { /* ... */ }
export const routes: Routes = {
// All routes inherit the descriptors
"GET /data": getData,
"POST /data": createData,
};For detailed documentation on the
descriptor()plugin, see Plugins - Descriptor.
OpenAPI Integration
The @minimajs/openapi package provides built-in descriptors:
describe() - Operation Metadata
import { describe } from "@minimajs/openapi";
import { handler, type Routes } from "@minimajs/server";
function getUsers() { /* ... */ }
export const routes: Routes = {
"GET /users": handler(describe({
summary: "List all users",
description: "Returns a paginated list of all users.",
tags: ["Users"],
operationId: "listUsers",
deprecated: false,
security: [{ bearerAuth: [] }],
}), getUsers),
};internal() - Exclude from OpenAPI
import { internal } from "@minimajs/openapi";
import { handler, type Routes } from "@minimajs/server";
function getHealth() { return "ok"; }
function getMetrics() { /* ... */ }
export const routes: Routes = {
// Won't appear in OpenAPI documentation
"GET /health": handler(internal(), getHealth),
"GET /metrics": handler(internal(), getMetrics),
};Combining with Schema
import { schema, createBody, createResponse } from "@minimajs/schema";
import { describe } from "@minimajs/openapi";
import { handler, type Routes } from "@minimajs/server";
import { z } from "zod";
const CreateUser = createBody(
z.object({
name: z.string(),
email: z.string().email(),
})
);
const UserResponse = createResponse(201, z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}));
function createUser() {
const body = CreateUser();
return UserResponse({ id: "123", ...body });
}
export const routes: Routes = {
"POST /users": handler(
describe({ summary: "Create user", tags: ["Users"] }),
schema(CreateUser, UserResponse),
createUser
),
};Best Practices
Use symbols for keys - Prevents naming collisions between packages
typescript// Good - unique symbol export const kAuth = Symbol("myapp.auth"); // Avoid - string keys can collide metadata["auth"] = true;Create helper functions - More readable than raw tuples
typescript// Good export const routes: Routes = { "GET /admin": handler(requireAuth(), requireRoles("admin"), getAdmin), };Export symbols for consumers - Allow other code to read your metadata
typescript// auth.ts export const kAuthRequired = Symbol("auth.required"); export function requireAuth(): RouteMetaDescriptor { return [kAuthRequired, true]; }Keep descriptors focused - Single responsibility per descriptor
typescript// Good - separate concerns app.get("/data", requireAuth(), rateLimit(100), cacheFor("1h"), handler); // Avoid - mixing concerns app.get("/data", authAndRateLimitAndCache(), handler);Use module-level for common metadata - Route-level for exceptions
typescriptconst kPublic = Symbol("public"); export const meta: Meta = { plugins: [descriptor(requireAuth())], // Default: auth required }; export const routes: Routes = { "GET /public": handler([kPublic, true], getPublic), // Exception: public "GET /private": getPrivate, // Uses module default };