Skip to content

HTTP

Contract

@modularityjs/http provides a decorator-based routing system with controller discovery, parameter extraction, guards, interceptors, middleware, and error filters.

Controllers

typescript
import {
  Body,
  Controller,
  Get,
  HttpControllersPool,
  Params,
  Post,
  StatusCode,
} from '@modularityjs/http';

@Controller('/users')
class UserController {
  @Get()
  list() {
    return [{ id: 1, name: 'Alice' }];
  }

  @Get('/:id')
  get(@Params('id') id: string) {
    return { id, name: 'Alice' };
  }

  @Post()
  @StatusCode(201)
  create(@Body() body: { name: string }) {
    return { id: 2, name: body.name };
  }
}

Register controllers via the HttpControllersPool:

typescript
@Module({
  name: 'users',
  imports: [HttpModule],
  providers: [UserController],
  pools: [
    {
      pool: HttpControllersPool,
      key: 'user-controller',
      useClass: UserController,
    },
  ],
})
class UsersModule {}

Route Decorators

DecoratorHTTP Method
@Get()GET
@Post()POST
@Put()PUT
@Patch()PATCH
@Delete()DELETE
@Head()HEAD
@Options()OPTIONS
@All()All methods

All accept an optional path string parameter.

Parameter Decorators

DecoratorSource
@Body()Request body
@Query()Query string
@Params()Route parameters
@Headers()Request headers
@Cookies()Cookies
@Request()Raw request object
@Response()Raw response object

@Body(), @Query(), @Params(), @Headers(), and @Cookies() accept an optional name to extract a specific field (e.g., @Params('id')).

Response Decorators

typescript
@StatusCode(201)                  // Set HTTP status code
@SetHeader('X-Custom', 'value')   // Set response header (method or class level)
@Html()                           // Shorthand for @SetHeader('content-type', 'text/html; charset=utf-8')

@SetHeader and @Html() work at both class and method level. Applied to a class, the header is set on every route in that controller. A method-level decorator overrides the class-level one for that specific route.

typescript
@Html()
@Controller('/pages')
class PageController {
  @Get('/home')
  home() {
    return '<h1>Home</h1>'; // content-type: text/html
  }

  @Get('/data')
  @SetHeader('content-type', 'application/json')
  data() {
    return '{"ok":true}'; // method-level overrides class-level
  }
}

HtmlResponse

Return HtmlResponse from a controller method to send an HTML body with Content-Type: text/html; charset=utf-8. Useful for partial/fragment responses where the method doesn't carry @Html():

typescript
import { HtmlResponse } from '@modularityjs/http';

@Controller('/partials')
class PartialsController {
  @Post('/item')
  async item(@Body('text') text: string) {
    const html = await this.templates.render('item', { text });
    return new HtmlResponse(html);
  }
}

HtmlResponse overrides any @SetHeader('content-type', ...) on the method, mirroring the behaviour of FileResponse.

Guards

Guards control access to routes. They implement the Guard interface and run before the handler. If any guard returns false, the request is rejected with a 403 status.

typescript
interface Guard<TRequest = unknown> {
  activate(request: TRequest): Promise<boolean> | boolean;
}
typescript
import { Injectable } from '@modularityjs/di';
import { Guard, UseGuard, type HttpRequest } from '@modularityjs/http';

@Injectable()
class FeatureFlagGuard implements Guard<HttpRequest> {
  activate(request: HttpRequest): boolean {
    return request.headers['x-feature-beta'] === 'on';
  }
}

@Controller('/beta')
@UseGuard(FeatureFlagGuard)
class BetaController {
  @Get()
  index() {
    return { status: 'ok' };
  }

  @Get('/extras')
  @UseGuard(ExtraGuard)
  extras() {
    return { status: 'restricted' };
  }
}

@UseGuard() can be applied at class level or method level. When both are present, class-level guards execute first, then method-level guards. If any guard returns false, the adapter responds with 403 Forbidden and the handler is never called.

Guards registered as DI providers are resolved from the container. Otherwise, they are instantiated directly.

For authentication and permission gating, use @RequireAuth() (http-auth) and @RequirePermission() (http-authz) instead of writing a guard — the adapter enforces them after middleware and guards run, and returns 401 / 403 automatically.

File Uploads

File upload support is split across two packages: @modularityjs/http-upload owns the driver-agnostic HttpUploadConfig (max file size, MIME allowlist, sanitizeFilename) and @modularityjs/http-fastify-upload is the Fastify driver that registers @fastify/multipart under the hood. Bridges like http-storage depend only on the abstract.

Setup

typescript
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpFastifyUploadModule } from '@modularityjs/http-fastify-upload';
import { HttpUploadModule } from '@modularityjs/http-upload';

const modules = [
  HttpModule.forRoot({ port: 3000 }),
  HttpFastifyModule,
  HttpUploadModule.forRoot({ maxFileSize: 5 * 1024 * 1024 }), // optional config
  HttpFastifyUploadModule,
];

Parameter Decorators

DecoratorDescription
@UploadedFile()Single file upload
@UploadedFile('fieldName')Single file from a specific form field
@UploadedFiles()Multiple file uploads (array)

Files are provided as FileUpload objects:

typescript
interface FileUpload {
  readonly filename: string;
  readonly mimetype: string;
  readonly size: number;
  toBuffer(): Promise<Buffer>;
}

Example

typescript
import {
  Controller,
  Post,
  UploadedFile,
  UploadedFiles,
} from '@modularityjs/http';
import type { FileUpload } from '@modularityjs/http';

@Controller('/uploads')
class UploadController {
  @Post('/avatar')
  async uploadAvatar(@UploadedFile() file: FileUpload) {
    const buffer = await file.toBuffer();
    return { filename: file.filename, size: file.size };
  }

  @Post('/documents')
  async uploadDocuments(@UploadedFiles() files: FileUpload[]) {
    return { count: files.length };
  }

  @Post('/profile')
  async uploadProfile(@UploadedFile('photo') photo: FileUpload) {
    return { filename: photo.filename };
  }
}

FileResponse

Return a FileResponse from a controller method to send binary data as a download:

typescript
import { Controller, FileResponse, Get } from '@modularityjs/http';

@Controller('/files')
class FileController {
  @Get('/report')
  download() {
    const buffer = generateReport();
    return new FileResponse(buffer, 'application/pdf', 'report.pdf');
  }

  @Get('/image')
  image() {
    const buffer = loadImage();
    return new FileResponse(buffer, 'image/png');
  }
}

FileResponse takes three arguments:

ParameterTypeDescription
bodyBufferThe binary content
contentTypestringMIME type (e.g., 'application/pdf', 'image/png')
filenamestringOptional — sets Content-Disposition: attachment header

When filename is provided, the browser treats the response as a file download. Without it, the response is sent inline.

Server-Sent Events

Return SseResponse from a controller method to stream events to the client using the Server-Sent Events protocol. The Fastify driver writes the text/event-stream headers and streams each event directly over the raw connection.

typescript
import { Controller, Get, SseResponse } from '@modularityjs/http';
import type { SseEvent } from '@modularityjs/http';

@Controller('/notifications')
class NotificationsController {
  @Get('/stream')
  stream(): SseResponse {
    async function* generate(): AsyncGenerator<SseEvent> {
      for (let i = 0; i < 10; i++) {
        yield { data: { count: i }, event: 'tick', id: String(i) };
        await new Promise((resolve) => setTimeout(resolve, 1000));
      }
    }
    return new SseResponse(generate());
  }
}

SseResponse takes two arguments:

ParameterTypeDescription
streamAsyncIterable<SseEvent>Async generator or iterable that yields events
heartbeatMsnumber | nullInterval in ms to send keep-alive comments; null to disable (default: null)

Each SseEvent is serialized as an SSE frame:

typescript
interface SseEvent {
  data: unknown; // Required — serialized as JSON
  id?: string; // Optional event ID (for Last-Event-ID reconnect)
  event?: string; // Optional event type (client listens with addEventListener)
  retry?: number; // Optional reconnect delay hint in ms
}

When heartbeatMs is set, the driver periodically writes a bare SSE comment line (:\n\n) between events to keep the connection alive through proxies and load balancers that close idle connections.

Permission Enforcement

When HttpAuthzModule is loaded, @RequirePermission() decorators are enforced automatically. The adapter reads the required permissions from decorator metadata at route registration time and runs the authorization checks inside the request pipeline (after guards/auth, before the handler).

typescript
import { RequirePermission } from '@modularityjs/http-authz';

@Controller('/api/admin')
@RequirePermission('admin.access')
class AdminController {
  @Get('/users')
  listUsers() { ... }

  @Post('/users')
  @RequirePermission('users.create')
  createUser() { ... }
}

The enforcement flow:

  1. The adapter collects permissions from both class-level and method-level @RequirePermission() decorators
  2. If any permissions are required, the request must carry an authenticated identity — otherwise a 401 Authentication Required response is returned
  3. Each required permission is checked via AuthzService.can(identity, permission), delegated through the permission checker bound by HttpAuthzModule
  4. If any permission check fails, a 403 Forbidden response is returned with the denied permission in the response context

This requires HttpAuthzModule in the module list, which imports both AuthzModule and HttpModule and binds the permission checker in afterLoad().

Interceptors

Interceptors wrap handler execution as an onion: each one receives the request, the response, and a next() it must call to advance the chain. next() returns the value the next inner interceptor (or the handler at the innermost layer) produced. The interceptor returns the value that should be serialized as the response — usually await next() unchanged, sometimes transformed.

typescript
import { Injectable } from '@modularityjs/di';
import {
  type HttpRequest,
  type HttpResponse,
  type Interceptor,
  UseInterceptor,
} from '@modularityjs/http';

@Injectable()
class LoggingInterceptor implements Interceptor<HttpRequest, HttpResponse> {
  async intercept(
    request: HttpRequest,
    _response: HttpResponse,
    next: () => Promise<unknown>,
  ): Promise<unknown> {
    const start = Date.now();
    const result = await next();
    console.log(`request done in ${Date.now() - start}ms`);
    return result;
  }
}

@Controller('/api')
@UseInterceptor(LoggingInterceptor)
class ApiController {
  /* ... */
}

Composition order: method-level interceptors are outermost (run first on the way in, last on the way out); class-level interceptors are closer to the handler.

Middleware

Middleware runs before guards/auth/handler — Express-style preprocessing. Call next() to advance; skip it to short-circuit (typically after writing a response on HttpResponse).

typescript
import { Injectable } from '@modularityjs/di';
import {
  ApplyMiddleware,
  type HttpRequest,
  type HttpResponse,
  type Middleware,
} from '@modularityjs/http';

@Injectable()
class RequestIdMiddleware implements Middleware<HttpRequest, HttpResponse> {
  async execute(
    _request: HttpRequest,
    response: HttpResponse,
    next: () => Promise<void>,
  ): Promise<void> {
    response.setHeader('x-request-id', crypto.randomUUID());
    await next();
  }
}

@Controller('/api')
@ApplyMiddleware(RequestIdMiddleware)
class ApiController {
  /* ... */
}

Both interceptors and middleware use the abstract HttpRequest / HttpResponse types — no driver-specific casts needed for status, headers, cookies, send, or redirect. The Fastify reply implements HttpResponse; other drivers do the same.

Middleware vs. Interceptor vs. Plugin

Three tools that all "wrap something" — easy to confuse. The right one depends on what scope the wrap operates at:

MiddlewareInterceptorPlugin
LayerHTTP request pipelineHTTP request pipelineDI container (any method on any class)
What next() wrapsThe rest of the request pipeline (guards → auth → interceptors → handler)The HTTP handler invocationA single method on a single class (or every subclass of an abstract base)
HTTP-aware?Yes (request, response)Yes (request, response)No — sees method args + return only
Fires for non-HTTP code?NoNoYes — CLI commands, scheduled jobs, queue workers, anything the container instantiates
Can transform handler's return?No (runs before handler exists)Yes — await next() returns the handler's value; you return what gets serializedOnly if your target IS the handler method; usually targets a lower-level method
Can short-circuit the HTTP reply?Yes (skip next(), write reply directly)Yes (skip next(), return a value)No (no response in scope)
Discovery modelOpt-in: @ApplyMiddleware(MyMw) on controller/methodOpt-in: @UseInterceptor(MyInt) on controller/methodAuto-applied at boot to every bound subclass matching @Plugin({ target })
Typical use casesRequest ID, body parsing, IP rate-limit, CORSWrap response in envelope, cache HTTP response, log handler timingTrace every DB query, cache every cache-miss-prone service call, lock acquisition AOP

The mental shortcut

Each layer's next() lives at a different depth in the call stack:

middleware's   next()  ⟶  the rest of THE REQUEST PIPELINE
interceptor's  next()  ⟶  THE HANDLER (one specific method)
plugin's       next()  ⟶  THE WRAPPED METHOD (any one method on any class)

Pipeline order for one HTTP request

HTTP request
  → onRequest hooks (session loader, auth resolver, …)
    → middleware           ← @ApplyMiddleware
      → guards             ← @UseGuard
        → @RequireAuth / @RequirePermission
          → interceptors   ← @UseInterceptor   (method-outer, class-inner onion)
            → handler                       ← @Get / @Post / …
              → service methods              ← plugins fire here
                → repository methods          ← plugins fire here
                → cache / lock / …            ← plugins fire here
            ← interceptors (post-handler, reverse)
        ← (status/headers applied)
    ← (response sent)
  → onSend hooks (cookie writer, telemetry, …)

Picking the right tool

GoalUse
Set an X-Request-Id header on every responseMiddleware
Wrap every handler's JSON return value in { data, meta: { … } }Interceptor
Log how long each handler tookInterceptor
Cache the GET response for /productsInterceptor
Trace every DatabaseConnection.query (HTTP, CLI, jobs, …)Plugin
Auto-acquire a lock around every method on @Locked classesPlugin
Add OpenTelemetry spans to every CacheService.getPlugin (this is what *-telemetry packages do)
Reject requests with no session, returning 401Middleware (or @RequireAuth)
Validate body, throw ValidationException on failure@ValidatedBody(schema) parameter decorator

The rule of thumb: if the wrap should fire for non-HTTP code too (CLI commands, scheduled jobs, queue consumers), it has to be a plugin. If it's specific to one handler's return value, it's an interceptor. Everything else that runs in the HTTP request pipeline is middleware.

Error Filters

Error filters catch exceptions thrown by handlers and return an ErrorResponse value the adapter applies to the reply (status, body, headers, cookies). Filters are pure — they don't mutate the response object directly, which keeps them driver-agnostic, unit-testable as functions, and impossible to leave half-finalized.

typescript
import { Injectable } from '@modularityjs/di';
import {
  CatchError,
  type ErrorFilter,
  type ErrorResponse,
  type HttpRequest,
  UseErrorFilter,
} from '@modularityjs/http';

@CatchError({ error: ValidationError })
@Injectable()
class ValidationErrorFilter implements ErrorFilter<ValidationError> {
  catch(error: ValidationError, _request: HttpRequest): ErrorResponse {
    return {
      status: 422,
      body: { code: 'VALIDATION_ERROR', message: error.message },
    };
  }
}

@Controller('/api')
@UseErrorFilter(ValidationErrorFilter)
class ApiController {
  /* ... */
}

ErrorResponse shape:

typescript
interface ErrorResponse {
  status: number;
  body?: unknown;
  headers?: Record<string, string | number | readonly string[]>;
  cookies?: Array<{ name: string; value: string; options?: CookieOptions }>;
}

Async filters return Promise<ErrorResponse> — the adapter awaits before finalizing. Method-level @UseErrorFilter filters run before class-level filters; the first one whose @CatchError({ error: X }) matches the thrown error wins.

The Fastify driver includes a built-in FrameworkExceptionFilter that maps framework exceptions to HTTP status codes:

ExceptionStatus
ValidationException422
NotFoundException404
AuthenticationException401
AuthorizationException403
ConflictException409
StateException409
RateLimitExceededException429
ServiceUnavailableException503

Drivers

Fastify (@modularityjs/http-fastify)

Fastify-based HTTP server.

typescript
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';

const modules = [
  HttpModule.forRoot({ host: '0.0.0.0', port: 3000 }),
  HttpFastifyModule,
];

Configuration

typescript
HttpModule.forRoot({
  host: '0.0.0.0', // default
  port: 3000, // default
  listen: true, // auto-start on boot (default: true)
  bodyLimit: 1_048_576, // max request body in bytes (default: 1 MiB)
  connectionTimeoutMs: undefined, // socket connection timeout; unset = driver default
  requestTimeoutMs: undefined, // per-request timeout; unset = driver default
  keepAliveTimeoutMs: undefined, // HTTP keep-alive timeout; unset = driver default
});
OptionDefaultDescription
host'0.0.0.0'Bind address
port3000TCP port (0 = OS-assigned)
listentrueAuto-bind the listener on boot
bodyLimit1048576Maximum accepted request body, in bytes (must be a positive integer)
connectionTimeoutMsundefinedSocket connection timeout in ms; non-negative integer when set
requestTimeoutMsundefinedPer-request timeout in ms; non-negative integer when set
keepAliveTimeoutMsundefinedHTTP keep-alive timeout in ms; non-negative integer when set

Fastify-specific feature extensions

A handful of small packages add Fastify-specific request-handling features by registering Fastify plugins on the underlying server. They live alongside http-fastify and depend on it being the active HTTP driver.

PackageAdds
@modularityjs/http-fastify-uploadMultipart upload parsing via @fastify/multipart; provides @UploadedFile() / @UploadedFiles() decorators. Reads HttpUploadConfig from the abstract @modularityjs/http-upload.
@modularityjs/http-fastify-formbodyForm-encoded body parsing via @fastify/formbody; lets controllers accept application/x-www-form-urlencoded payloads from HTML forms.
@modularityjs/http-fastify-corsRegisters @fastify/cors with framework-managed config.
@modularityjs/http-fastify-compressionRegisters @fastify/compress (br/gzip/deflate) on responses.
@modularityjs/http-fastify-security-headersRegisters @fastify/helmet (CSP, HSTS, X-Frame-Options, …).

These are explicitly Fastify-driver extensions (the package name carries fastify to make that scope honest). Apps using a different HTTP driver in the future would not consume them. The abstract HttpServer does not expose getInstance(); only the concrete HttpService from @modularityjs/http-fastify does. Inside a known Fastify-extension package, cast through the concrete type — e.g. (httpServer as unknown as { getInstance(): FastifyInstance }).getInstance() — or inject HttpService directly. Bridges that target the abstract HttpServer contract should do neither.

The active driver binds the abstract HttpServer contract — inject it for programmatic access:

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { HttpServer } from '@modularityjs/http';

@Injectable()
class MyService {
  constructor(@Inject(HttpServer) private readonly http: HttpServer) {}

  getAddress() {
    return this.http.getAddress(); // { host, port }
  }
}

Typed Driver Aliases

The Fastify driver provides typed aliases for use in guards, interceptors, middleware, and error filters:

typescript
import type {
  FastifyGuard,
  FastifyInterceptor,
  FastifyMiddleware,
  FastifyErrorFilter,
} from '@modularityjs/http-fastify';