Skip to content

Latest commit

 

History

History
283 lines (227 loc) · 9.69 KB

File metadata and controls

283 lines (227 loc) · 9.69 KB

Decorator-based validation and OpenAPI

OpenAPI/Swagger is always available at /docs for any Express backend generated by dot. You pick how the spec is built:

  • Decorator runtime (default) — TypeScript class decorators declare routes, validate inputs with Zod, and the OpenAPI v3 document is built from the metadata Express sees at runtime.
  • Classic JSDoc — handlers stay plain functions; the spec is assembled at boot from @openapi JSDoc blocks in src/**/*.ts via swagger-jsdoc.

This page focuses on the decorator path. The classic path needs no configuration: every handler shipped by dot already carries a @openapi JSDoc block, and you keep using the same syntax for new endpoints.

The decorator system is built around a small RouterAdapter interface so it can be extended to other frameworks (Fastify, etc.) without rewriting the decorators.

Quickstart

dot init
# Answer the survey:
#   Library / Framework             → Express
#   Choose your architecture         → Clean Architecture | MVC
#   Use decorator-based validation…  → Yes (or No for the classic JSDoc path)
#   Validation library               → Zod   (only when Yes)
# ...
pnpm install
pnpm dev
# Open http://localhost:3000/docs — the Swagger UI is served regardless of
# which path you chose above.

The scaffold ships with one decorated example controller you can copy or delete. Its routes appear under /api/example and in the OpenAPI document at /docs/openapi.json. The DecoratorRouter is mounted at the root of the app (not under /api), so each controller declares its own full prefix ('/api/example', '/auth', …) — there is no implicit base path to remember.

Anatomy of a decorated controller

import type { Request, Response } from 'express';
import {
  ApiResponse,
  Body,
  Controller,
  Get,
  Params,
  Post,
} from '../shared/decorators';
import { z } from '../shared/openapi/registry';

const userParams = z.object({ id: z.string().uuid() });
const createUser = z.object({ email: z.string().email(), name: z.string().min(1) });
const userResponse = z.object({ id: z.string().uuid(), email: z.string(), name: z.string() });

@Controller({ tag: 'users', prefix: '/users', description: 'User management' })
export class UsersController {
  @Get(':id')
  @Params(userParams)
  @ApiResponse(200, 'User fetched', userResponse)
  @ApiResponse(404, 'User not found')
  get(req: Request, res: Response): void {
    res.json({ id: req.params.id, email: 'demo@example.com', name: 'Demo' });
  }

  @Post('/')
  @Body(createUser)
  @ApiResponse(201, 'User created', userResponse)
  create(req: Request, res: Response): void {
    res.status(201).json({ id: '…', ...req.body });
  }
}

Behind the scenes:

  • @Controller records the OpenAPI tag and the route prefix.
  • @Get/@Post/@Put/@Patch/@Delete declare an HTTP method and path.
  • @Body/@Query/@Params install validation middleware that runs before the handler. On success the parsed value replaces req.body|query|params, so the handler sees coerced, typed input. On failure the request is rejected with a 400 and a structured payload listing every Zod issue.
  • @ApiResponse(status, description, schema?) adds a response definition to the OpenAPI spec. It can be stacked to document multiple status codes.
  • @Auth() marks the route as protected. The route gets a BearerAuth security entry in OpenAPI, and (when an authMiddleware is registered on the adapter) the middleware runs before the handler.
  • @RequiredHeaders([...]) installs a pre-handler check that returns 400 if any of the named headers are missing.

Wiring the router

import 'reflect-metadata';
import express from 'express';
import {
  DecoratorRouter,
  ExpressRouterAdapter,
} from './shared/decorators';
import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi';
import { UsersController } from './controllers/users.controller';

const app = express();
app.use(express.json());

const router = new DecoratorRouter(new ExpressRouterAdapter())
  .registerController(new UsersController());

app.use(router.build());

const spec = buildOpenApiSpec({
  info: { title: 'My API', version: '1.0.0' },
  servers: [{ url: '/' }],
  routes: router.routes(),
  registry: createRegistry(),
});
mountSwagger(app, spec);

DecoratorRouter walks each controller's metadata, pushes a RouteRegistration into the adapter, and remembers what it registered so the OpenAPI generator sees exactly the routes that are wired.

Authentication

Pass an Express middleware to the adapter and the @Auth() decorator will gate the route with it:

import { authMiddleware } from './shared/middlewares/auth.middleware';

const router = new DecoratorRouter(
  new ExpressRouterAdapter({ authMiddleware }),
).registerController(new UsersController());

When you scaffold with JWT auth + decorators, dot wires this for you: the auth_jwt_vanilla generator injects authMiddleware into ExpressRouterAdapter, and the auth controller (MVC or Clean Architecture) is generated as a decorated AuthController registered on the same router. @Auth()-protected routes are then gated automatically.

BetterAuth keeps its own catch-all route (toNodeHandler(auth)) and is not exposed through the decorator system — it manages its own routing.

The OpenAPI document is updated regardless: protected routes always show the BearerAuth requirement, even if you have not registered a middleware yet.

Async error handling

The Express adapter wraps every decorated handler so a thrown error or a rejected promise is forwarded to Express' error pipeline via next(err) — you can rely on your existing error middleware without sprinkling try/catch through every controller method.

OpenAPI

  • The spec is served raw at GET /docs/openapi.json and rendered at GET /docs via swagger-ui-express.

  • Every Zod schema you reference in @Body/@Query/@Params/@ApiResponse is automatically converted using @asteasolutions/zod-to-openapi.

  • For named, reusable component schemas, register them on the registry and reference them inline:

    import { getSharedRegistry, z } from '../openapi/registry';
    
    export const userResponse = z.object({ id: z.string(), email: z.string() })
      .openapi('User');
    getSharedRegistry().register('User', userResponse);
  • Tests should call createRegistry() to obtain a fresh, isolated registry rather than share global state.

Architecture-specific layout

Architecture Controller location Schema location
Clean src/modules/<name>/application/controllers/ src/modules/<name>/application/validators/
MVC src/controllers/ src/shared/validators/
Hexagonal src/adapters/primary/http/controllers/ src/adapters/primary/http/schemas/

The decorator and OpenAPI runtimes themselves live in src/shared/decorators/ and src/shared/openapi/ regardless of architecture.

Adding a Fastify (or other framework) adapter

The decorator-router reads metadata and produces a RouteRegistration:

export interface RouteRegistration {
  method: HttpMethod;
  path: string;
  handler: (...args: unknown[]) => unknown;
  validation: { body?: ZodSchema; params?: ZodSchema; query?: ZodSchema };
  requiredHeaders: string[];
  isProtected: boolean;
}

To plug in another framework, implement RouterAdapter<TNative>:

import type { FastifyInstance } from 'fastify';
import type { RouteRegistration, RouterAdapter } from '../shared/decorators/router-adapter';

export class FastifyRouterAdapter implements RouterAdapter<FastifyInstance> {
  constructor(private readonly fastify: FastifyInstance) {}

  register(reg: RouteRegistration): void {
    // translate validation/auth/headers into Fastify hooks and routes
  }

  build(): FastifyInstance {
    return this.fastify;
  }
}

The decorator surface — @Controller, @Get, @Body, … — stays the same. Only the adapter changes.

Tests shipped with the scaffold

Decorator path:

  • src/shared/decorators/__tests__/decorators.unit.test.ts covers route registration, body/params/query validation, auth, and required headers.
  • src/shared/openapi/__tests__/spec.unit.test.ts verifies the generated OpenAPI document — paths, tags, security, and the served JSON endpoint.
  • src/__tests__/decorators-<arch>.e2e.test.ts boots the real app via supertest and exercises the example controller end to end.

Classic JSDoc path:

  • src/shared/swagger/__tests__/swagger.unit.test.ts boots the app, hits /docs/openapi.json, and asserts that /health (and any other @openapi-annotated endpoint) is present in the spec.

Run them with pnpm test.


Classic JSDoc path: writing new docs

When you opt out of decorators, every endpoint that should appear in the spec needs a @openapi JSDoc block above its handler. swagger-jsdoc scans src/**/*.{ts,js} at boot and folds every block into a single document.

/**
 * @openapi
 * /users/{id}:
 *   get:
 *     tags: [Users]
 *     summary: Fetch a user by id
 *     parameters:
 *       - name: id
 *         in: path
 *         required: true
 *         schema: { type: string, format: uuid }
 *     responses:
 *       200:
 *         description: The user
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 id: { type: string, format: uuid }
 *                 email: { type: string, format: email }
 *       404: { description: Not found }
 */
export async function getUser(req: Request, res: Response): Promise<void> {
  // ...
}

The dot-generated controllers (auth.controller.ts, app.ts's /health) already follow this convention — copy one of them as a template.