npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

exjs-controllers

v0.11.1

Published

Declarative controllers, DTO validation, dependency injection, OpenAPI and Express bootstrap for TypeScript APIs.

Readme

exjs-controllers

Declarative controllers, DTO validation, dependency injection, OpenAPI generation and Express bootstrap for TypeScript APIs.

📖 Documentation: https://thebylito.github.io/exjs-controllers/

Install

npm install exjs-controllers express zod
npm install -D @types/express   # Express types for TypeScript

express ^5 and zod ^4 are peer dependencies: your project owns them, so the schemas you pass to @Field and the middlewares you register share one copy with the framework. npm and pnpm install peers automatically; with Yarn, add them explicitly as above. Requires Node.js >=22.


Table of contents


Quick start

import { z } from 'zod'
import { createApplication } from 'exjs-controllers/http/application'
import { configureApplication } from 'exjs-controllers/config/configureApplication'
import { Controller } from 'exjs-controllers/decorators/Controller'
import { Get } from 'exjs-controllers/decorators/HttpMethod'
import { Field } from 'exjs-controllers/decorators/Field'
import { BaseSchema } from 'exjs-controllers/schemas/BaseSchema'

class HealthOutput extends BaseSchema {
  @Field(z.string())
  status!: string
}

@Controller('/health')
class HealthController {
  @Get('/', { outputClass: HealthOutput, summary: 'Health check', tags: ['health'] })
  getHealth(): HealthOutput {
    return HealthOutput.create({ status: 'ok' })
  }
}

const app = createApplication()

await configureApplication(app, {
  controllers: [HealthController],
  openapi: {
    documentation: { info: { title: 'Example API', version: '1.0.0' } },
  },
  enableScalar: true,
})

app.listen(3000)

Controllers

@Controller(prefix, options?)

Registers a class as a controller. All routes inside will be prefixed with prefix.

@Controller('/users')
class UsersController { ... }

options.group puts every route of the controller in an OpenAPI documentation group (see Multiple documents). It does not affect routing.

@JsonController(prefix, options?)

Like @Controller, but automatically serialises the return value with res.json() instead of res.send(). Accepts the same options.

@JsonController('/api/v1/users')
class UsersController { ... }

Route decorators

All route decorators accept a path and an optional RouteOptions object.

@Get(path, options?)
@Post(path, options?)
@Put(path, options?)
@Patch(path, options?)
@Delete(path, options?)

RouteOptions

| Property | Type | Description | |---|---|---| | inputClass | typeof BaseSchema | DTO class used to validate and document the request body | | outputClass | typeof BaseSchema | DTO class used to document the response body | | outputIsArray | boolean | When true, the OpenAPI response schema is wrapped as { type: 'array', items: <outputClass schema> } | | summary | string | Short description shown in the OpenAPI docs | | description | string | Long description shown in the OpenAPI docs | | tags | string[] | OpenAPI tags for grouping | | group | string | OpenAPI documentation group; overrides the controller's group (default: default) |

@Get('/items', {
  outputClass: ItemOutput,
  outputIsArray: true,
  summary: 'List all items',
  tags: ['items'],
})
listItems(): ItemOutput[] { ... }

Parameter decorators

Use these decorators on method parameters to bind request data automatically.

| Decorator | Binds | |---|---| | @Body(schemaClass?) | req.body — optionally hydrates into an instance of schemaClass | | @Param(name) | req.params[name] | | @QueryParam(name) | req.query[name] | | @QueryParams() | req.query (entire object) | | @HeaderParam(name) | req.headers[name] | | @Req() | The raw Express Request object | | @Res() | The raw Express Response object | | @CurrentUser(options?) | The resolved user principal — responds 401 when missing unless { optional: true } | | @CurrentApiKey(options?) | The resolved api-key principal — same rules as @CurrentUser | | @UploadedFile(field, options?) | A single uploaded file from a multipart/form-data field (via multer) | | @UploadedFiles(field, options?) | All uploaded files from a multipart/form-data field (array) |

@Controller('/orders')
class OrdersController {
  @Post('/')
  create(
    @Body(CreateOrderInput) input: CreateOrderInput,
    @CurrentUser() user: AuthenticatedUser,
  ) {
    console.log('Created by', user.id)
    return this.orderService.create(input)
  }

  @Get('/:id')
  findOne(
    @Param('id') id: string,
    @QueryParam('expand') expand?: string,
  ) { ... }
}

File uploads

@UploadedFile / @UploadedFiles wire multer into the route automatically. With no options the file is kept in memory (buffer is populated); pass options (object or factory) to configure multer — storage, limits, fileFilter. The OpenAPI document advertises the route as multipart/form-data with the field as a binary, so Scalar renders a file picker.

import { UploadedFile, type UploadedFileInfo } from 'exjs-controllers/decorators/Params'

@Controller('/imports')
class ImportsController {
  @Post('/')
  upload(@UploadedFile('file') file: UploadedFileInfo) {
    return this.service.import(file.buffer.toString('utf-8'))
  }

  // Custom multer options (disk storage, size limit, ...)
  @Post('/large')
  uploadLarge(
    @UploadedFile('file', { options: { limits: { fileSize: 16 * 1024 * 1024 } } })
    file: UploadedFileInfo,
  ) { ... }
}

UploadedFileInfo is a self-contained type (mirrors Express.Multer.File) re-exported from exjs-controllers/decorators/Params — no need to install @types/multer in the consumer.


DTOs and field validation

BaseSchema

All DTO classes must extend BaseSchema. Use @Field(zodSchema) to declare each property.

import { z } from 'zod'
import { BaseSchema } from 'exjs-controllers/schemas/BaseSchema'
import { Field } from 'exjs-controllers/decorators/Field'

class CreateUserInput extends BaseSchema {
  @Field(z.string().min(1))
  name!: string

  @Field(z.string().email())
  email!: string
}

Inheritance — @Field decorators are collected from the full prototype chain, so child classes inherit all fields from parent DTOs:

class AuditedInput extends BaseSchema {
  @Field(z.string())
  createdBy!: string
}

class CreateAssetInput extends AuditedInput {
  @Field(z.string())
  assetTag!: string
}
// classToZod(CreateAssetInput) includes both createdBy and assetTag

Useful methods

// Create an instance (optional initial data)
const input = CreateUserInput.create({ name: 'Alice', email: '[email protected]' })

// Merge partial data into an existing instance
input.merge({ name: 'Bob' })

// Validate against the Zod schema — throws ZodError on failure
input.validate()

// Get the Zod schema for the class
const schema = CreateUserInput.toZod()

Dependency injection

@Injectable(options?)

Marks a class as injectable. By default creates a singleton — set { singleton: false } to create a new instance on every injection.

import { Injectable } from 'exjs-controllers/decorators/DependencyInjection'

@Injectable()
class UserRepository {
  findAll() { ... }
}

@Inject(token)

Injects a specific token into a constructor parameter. Useful when the parameter type is an interface or abstract class.

@Injectable()
class UserService {
  constructor(
    @Inject(UserRepository) private readonly repo: UserRepository,
  ) {}
}

Controllers are resolved automatically via the DI container — no need to mark them with @Injectable.


Authentication and authorization

exjs-controllers does not ship an auth backend. You implement the AuthenticationConfig contract — resolve the caller, decide permissions — and pass it to configureApplication. The authentication middleware is mounted only on routes that use @Authorized, @CurrentUser or @CurrentApiKey.

Configuration

import type { AuthenticationConfig } from 'exjs-controllers/core/authentication'

const authentication: AuthenticationConfig<AuthenticatedUser> = {
  // Who is calling? Return { principal, kind } or null for anonymous.
  currentUserChecker: async ({ request }) => {
    const header = request.headers.authorization
    if (!header?.startsWith('Bearer ')) return null

    const session = await sessions.verify(header.slice('Bearer '.length))
    return session ? { principal: session.user, kind: 'user' } : null
  },

  // Does the principal satisfy the route's permissions?
  authorizationChecker: (_action, user, _kind, required) =>
    required.every((permission) => user.permissions.includes(permission)),

  // Schemes to document in OpenAPI, tagged with the kinds they cover.
  openApiSecuritySchemes: {
    bearerAuth: {
      scheme: { type: 'http', scheme: 'bearer' },
      kinds: ['user'],
    },
  },
}

await configureApplication(app, { controllers, authentication })

kind is 'user' or 'api-key', so human sessions and machine clients can coexist behind a single config.

@Authorized(...permissions)

Protects a route. Permissions are passed verbatim to your authorizationChecker; their semantics are yours. @Authorized() with no arguments only requires an authenticated principal.

@JsonController('/admin')
class AdminController {
  @Authorized('admin:read')
  @Get('/users')
  listUsers() { ... }

  // Object form also restricts which principal kinds may call the route
  @Authorized({ permissions: ['admin:write'], kinds: ['user'] })
  @Delete('/users/:id')
  deleteUser(@Param('id') id: string) { ... }
}

@CurrentUser() and @CurrentApiKey()

Inject the resolved principal into a handler argument. Both accept { optional?: boolean }; without it, a missing principal responds 401 and a wrong-kind principal responds 403.

import { CurrentUser } from 'exjs-controllers/decorators/CurrentUser'

@Get('/me')
getMe(@CurrentUser() user: AuthenticatedUser) {
  return user
}

@Get('/me-or-anon')
maybeMe(@CurrentUser({ optional: true }) user?: AuthenticatedUser) {
  return user ?? { anonymous: true }
}

Errors

UnauthorizedError (401) and ForbiddenError (403) are exported from exjs-controllers/core/authentication. Handle them in your errorHandler to shape the HTTP response.

Full reference: Authentication and @Authorized & principals.


Use cases

Use cases encapsulate business logic. exjs-controllers provides decorators to connect controllers to use cases with automatic input validation.

@DefineUseCase()

Wraps the execute method of a use case class with input validation and OpenTelemetry tracing.

import { UseCase } from 'exjs-controllers/core/UseCase'
import { DefineUseCase } from 'exjs-controllers/decorators/DefineUseCase'

@DefineUseCase()
class CreateUserUseCase implements UseCase<CreateUserInput, UserOutput> {
  async execute(input: CreateUserInput): Promise<UserOutput> {
    input.validate()
    // business logic...
    return UserOutput.create({ id: '1', name: input.name })
  }
}

Calling a use case from a controller

Inject the use case via @Inject, then call execute(input) directly from the handler. Any additional arguments forwarded to execute will reach the underlying method.

@Controller('/users')
class UsersController {
  constructor(
    @Inject(CreateUserUseCase) private readonly createUser: CreateUserUseCase,
  ) {}

  @Post('/', { inputClass: CreateUserInput })
  create(@Body(CreateUserInput) input: CreateUserInput) {
    return this.createUser.execute(input)
  }
}

UseCase<TInput, TOutput> interface

interface UseCase<TInput extends BaseSchema, TOutput> {
  execute(input: TInput, ...args: unknown[]): TOutput | Promise<TOutput>
}

Validation errors

When execute is called, input.validate() is invoked automatically. On failure, a UseCaseInputValidationError with statusCode: 422 is thrown and forwarded to Express error handlers.


OpenAPI and Scalar

Generating the document

generateOpenApiDocument(controllers, options) builds an OpenAPI 3.1 document from the metadata registered by route decorators.

import { generateOpenApiDocument } from 'exjs-controllers/openapi/generateOpenApiDocument'

const document = generateOpenApiDocument([UsersController], options)

Serving via configureApplication

When openapi.documentation is set, the document is automatically served at /docs/openapi.json (configurable via openapi.documentPath).

When enableScalar: true is set, Scalar API reference is served at /docs (configurable via scalar.referencePath).

Multiple documents

Declare a group on controllers or routes and configure one document per group with openapi.documents. Each document gets its own JSON and, with enableScalar, its own Scalar UI. Routes in a group that no document references are not exposed anywhere.

@JsonController('/admin/users', { group: 'admin' })
class AdminUsersController { ... }

await configureApplication(app, {
  controllers: [UsersController, AdminUsersController],
  openapi: {
    documentation: { info: { title: 'My API', version: '1.0.0' } },
    documents: {
      default: {}, // routes without a group → /docs/openapi.json, Scalar at /docs
      admin: {},   // group "admin"          → /docs/admin/openapi.json, Scalar at /docs/admin
    },
  },
  enableScalar: true,
})

Without documents, a single document includes every route. See the documentation for path and resolution rules.

Security schemes in OpenAPI

await configureApplication(app, {
  openapi: {
    documentation: {
      info: { title: 'My API', version: '1.0.0' },
      security: [{ bearerAuth: [] }],
      components: {
        securitySchemes: {
          bearerAuth: {
            type: 'http',
            scheme: 'bearer',
            bearerFormat: 'JWT',
          },
        },
      },
    },
  },
})

Observability

@TraceSpan(name?, options?)

Wraps a method in an OpenTelemetry span. Works on any class method — not just controllers.

import { TraceSpan } from 'exjs-controllers/decorators/TraceSpan'

class UserRepository {
  @TraceSpan('UserRepository.findAll')
  async findAll() { ... }
}

Controller route handlers are automatically wrapped in spans via runWithControllerSpan. Use case execute methods are also automatically traced when decorated with @DefineUseCase.

Requires an OpenTelemetry SDK to be initialised in the application before spans will be exported.


configureApplication options

| Option | Type | Description | |---|---|---| | controllers | ControllerClass[] | Controller classes to register | | controllerDiscovery | ControllerDiscoveryOptions | Discover controllers from directories when controllers is omitted | | authentication | AuthenticationConfig | Principal resolution and permission checks for protected routes | | logger | HttpLoggerOptions \| false | Pino HTTP logger, mounted when provided | | middlewares | MiddlewareRegistration[] | Express middlewares applied before controllers | | errorHandler | ErrorMiddleware | Express error middleware, mounted last | | openapi.documentation | OpenApiDocumentationOptions | OpenAPI document metadata | | openapi.documentPath | string | Path to serve the JSON document (default: /docs/openapi.json) | | openapi.documents | Record<string, OpenApiDocumentOptions> | One OpenAPI document (and Scalar UI) per group | | enableScalar | boolean | Mount the Scalar UI | | scalar | ScalarConfigurationOptions | Scalar runtime options; scalar.referencePath sets the UI path (default: /docs) |


Public entrypoints

exjs-controllers
exjs-controllers/config/*
exjs-controllers/core/*
exjs-controllers/core/authentication
exjs-controllers/decorators/*
exjs-controllers/http/*
exjs-controllers/logging/*
exjs-controllers/metadata/*
exjs-controllers/observability/*
exjs-controllers/openapi/*
exjs-controllers/schemas/*

Changelog

See CHANGELOG.md.


Releasing

Releases are automated: pushing a vX.Y.Z tag publishes to npm and creates the GitHub Release. See RELEASING.md.