Skip to content

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:

  1. A tuple [symbol, value] - sets a specific metadata entry
  2. A function (route) => void - receives the route config for dynamic modifications
typescript
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:

typescript
type RouteMetaDescriptor<S = unknown> =
  | [symbol: symbol, value: unknown]
  | ((config: RouteConfig<S>) => void);

The RouteConfig provides access to:

typescript
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

When using the file-based module system, use the handler() helper to attach descriptors to your route handlers.

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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

typescript
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

typescript
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:

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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

  1. 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;
  2. Create helper functions - More readable than raw tuples

    typescript
    // Good
    export const routes: Routes = {
      "GET /admin": handler(requireAuth(), requireRoles("admin"), getAdmin),
    };
  3. 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];
    }
  4. 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);
  5. Use module-level for common metadata - Route-level for exceptions

    typescript
    const 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
    };

See Also