Decorators API
Package: @onebun/core
Module Decorators
@Module()
Defines a module that groups controllers, services, and imports. See Architecture — Module System for concepts and lifecycle details.
@Module(options: ModuleOptions)ModuleOptions:
interface ModuleOptions {
/** Other modules to import (their exported services become available) */
imports?: Function[];
/** Controller classes to register */
controllers?: Function[];
/** Service classes to register as providers */
providers?: unknown[];
/** Services to export to parent modules */
exports?: unknown[];
}Example:
import { Module } from '@onebun/core';
import { CacheModule } from '@onebun/cache';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [CacheModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}@Global()
Marks a module as global. Global modules export their providers to all modules automatically without explicit import. This is useful for modules that provide cross-cutting concerns like database access or caching.
@Global()Example:
import { Module, Global } from '@onebun/core';
import { DatabaseService } from './database.service';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
// Now DatabaseService is available in ALL modules without importing DatabaseModule
@Module({
controllers: [UserController],
providers: [UserService], // UserService can inject DatabaseService
})
export class UserModule {}Related Functions:
// Check if a module is global
function isGlobalModule(target: Function): boolean;
// Remove module from global registry (used internally)
function removeFromGlobalModules(target: Function): void;Controller Decorators
@Controller()
Marks a class as an HTTP controller with a base path.
@Controller(basePath?: string)Example:
import { Controller, BaseController } from '@onebun/core';
@Controller('/api/users')
export class UserController extends BaseController {
// All routes will be prefixed with /api/users
}HTTP Method Decorators
@Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head(), @All()
Define HTTP endpoints on controller methods.
@Get(path?: string, options?: RouteOptions)
@Post(path?: string, options?: RouteOptions)
@Put(path?: string, options?: RouteOptions)
@Delete(path?: string, options?: RouteOptions)
@Patch(path?: string, options?: RouteOptions)
@Options(path?: string, options?: RouteOptions)
@Head(path?: string, options?: RouteOptions)
@All(path?: string, options?: RouteOptions)
interface RouteOptions {
/** Per-request idle timeout in seconds. Set to 0 to disable. */
timeout?: number;
}Per-request timeout:
Override the global idleTimeout for individual routes. Useful for long-running endpoints:
@Controller('/tasks')
export class TaskController extends BaseController {
@Post('/process', { timeout: 300 }) // 5 minutes for this endpoint
async processTask(@Body() body: unknown) {
// long-running task...
}
@Get('/export', { timeout: 0 }) // no timeout
async exportAll() {
// very long export...
}
}Path Parameters:
Use :paramName syntax for dynamic segments:
@Controller('/users')
export class UserController extends BaseController {
@Get('/') // GET /users
async findAll() {}
@Get('/:id') // GET /users/123
async findOne(@Param('id') id: string) {}
@Get('/:userId/posts') // GET /users/123/posts
async getUserPosts(@Param('userId') userId: string) {}
@Post('/') // POST /users
async create(@Body() body: CreateUserDto) {}
@Put('/:id') // PUT /users/123
async update(@Param('id') id: string, @Body() body: UpdateUserDto) {}
@Delete('/:id') // DELETE /users/123
async remove(@Param('id') id: string) {}
}Parameter Decorators
All parameter decorators support an options object to control whether the parameter is required:
interface ParamDecoratorOptions {
required?: boolean;
}@Param()
Extract path parameter from URL. Path parameters are always required per OpenAPI specification.
@Param(name: string, schema?: Type<unknown>)Example:
import { type } from 'arktype';
const idSchema = type('string.uuid');
@Get('/:id')
async findOne(
@Param('id') id: string, // Always required (OpenAPI spec)
@Param('id', idSchema) id: string, // With validation, always required
) {}@Query()
Extract query parameter from URL. Optional by default.
@Query(name: string, options?: ParamDecoratorOptions)
@Query(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
// GET /users?page=1&limit=10
@Get('/')
async findAll(
@Query('page') page?: string, // Optional (default)
@Query('limit', { required: true }) limit: string, // Explicitly required
) {}
// With validation schema
@Get('/search')
async search(
@Query('q', type('string')) query?: string, // Optional with validation
@Query('sort', type('string'), { required: true }) sort: string, // Required with validation
) {}@Body()
Extract and optionally validate request body. Required is determined from schema - if the schema accepts undefined, the body is optional; otherwise it's required.
@Body(schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
import { type } from 'arktype';
const createUserSchema = type({
name: 'string',
email: 'string.email',
'age?': 'number > 0',
});
// Schema doesn't accept undefined → required
@Post('/')
async create(
@Body(createUserSchema) body: typeof createUserSchema.infer,
) {}
// Schema accepts undefined → optional
const optionalBodySchema = type({
name: 'string',
}).or(type.undefined);
@Post('/optional')
async createOptional(
@Body(optionalBodySchema) body: typeof optionalBodySchema.infer,
) {}
// Explicit override
@Post('/force-optional')
async forceOptional(
@Body(createUserSchema, { required: false }) body: typeof createUserSchema.infer,
) {}
// Without validation - body is unknown
@Post('/simple')
async createSimple(
@Body() body: unknown,
) {}@Header()
Extract header value. Optional by default.
@Header(name: string, options?: ParamDecoratorOptions)
@Header(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
@Get('/protected')
async protected(
@Header('X-Request-ID') requestId?: string, // Optional (default)
@Header('Authorization', { required: true }) auth: string, // Explicitly required
) {}
// With validation schema
@Get('/api')
async api(
@Header('X-API-Key', type('string'), { required: true }) apiKey: string,
) {}@Cookie()
Extract cookie value from request. Uses BunRequest.cookies (CookieMap) under the hood. Optional by default.
@Cookie(name: string, options?: ParamDecoratorOptions)
@Cookie(name: string, schema?: Type<unknown>, options?: ParamDecoratorOptions)Example:
// GET /api/me (with Cookie: session=abc123; theme=dark)
@Get('/me')
async getMe(
@Cookie('session') session?: string, // Optional (default)
@Cookie('session', { required: true }) session: string, // Explicitly required
) {}
// With validation schema
@Get('/prefs')
async prefs(
@Cookie('theme', type('"light" | "dark"')) theme?: string, // Optional with validation
) {}@Req()
Inject the raw request object. The type is OneBunRequest (alias for BunRequest), which extends the standard Web API Request with:
.cookies— aCookieMapfor reading and setting cookies.params— route parameters extracted by Bun's routes API
@Req()Example:
import type { OneBunRequest } from '@onebun/core';
@Get('/raw')
async handleRaw(@Req() req: OneBunRequest) {
const url = new URL(req.url);
const headers = Object.fromEntries(req.headers);
// Access cookies via CookieMap
const session = req.cookies.get('session');
// Access route params (populated by Bun routes API)
// For route '/users/:id', req.params.id is available
}@Res() (deprecated)
Deprecated
@Res() is deprecated and currently injects undefined. Use return new Response(...) from your handler instead. Direct response manipulation is not supported — return a Response object to set custom headers, status codes, and cookies.
@Res()File Upload Decorators
Decorators for handling file uploads via multipart/form-data or JSON with base64-encoded data. The framework auto-detects the content type and provides a unified OneBunFile object.
@UploadedFile()
Extracts a single file from the request. Required by default.
@UploadedFile(fieldName?: string, options?: FileUploadOptions)FileUploadOptions:
interface FileUploadOptions {
/** Maximum file size in bytes */
maxSize?: number;
/** Allowed MIME types, supports wildcards like 'image/*'. Use MimeType enum. */
mimeTypes?: string[];
/** Whether the file is required (default: true) */
required?: boolean;
}Example:
import { Controller, Post, UploadedFile, MimeType, OneBunFile, BaseController } from '@onebun/core';
@Controller('/api/files')
export class FileController extends BaseController {
@Post('/avatar')
async uploadAvatar(
@UploadedFile('avatar', {
maxSize: 5 * 1024 * 1024,
mimeTypes: [MimeType.ANY_IMAGE],
}) file: OneBunFile,
): Promise<Response> {
await file.writeTo(`./uploads/${file.name}`);
return this.success({ filename: file.name, size: file.size });
}
}@UploadedFiles()
Extracts multiple files from the request. Required by default (at least one file expected).
@UploadedFiles(fieldName?: string, options?: FilesUploadOptions)FilesUploadOptions:
interface FilesUploadOptions extends FileUploadOptions {
/** Maximum number of files allowed */
maxCount?: number;
}Example:
@Post('/documents')
async uploadDocs(
@UploadedFiles('docs', { maxCount: 10 }) files: OneBunFile[],
): Promise<Response> {
for (const file of files) {
await file.writeTo(`./uploads/${file.name}`);
}
return this.success({ count: files.length });
}
// All files from request (no field name filter)
@Post('/batch')
async uploadBatch(
@UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
): Promise<Response> {
return this.success({ count: files.length });
}@FormField()
Extracts a non-file form field from the request. Optional by default.
@FormField(fieldName: string, options?: ParamDecoratorOptions)Example:
@Post('/profile')
async createProfile(
@UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
@FormField('name', { required: true }) name: string,
@FormField('email') email: string,
): Promise<Response> {
await avatar.writeTo(`./uploads/${avatar.name}`);
return this.success({ name, email, avatar: avatar.name });
}OneBunFile
Unified file wrapper returned by @UploadedFile and @UploadedFiles. Works the same regardless of upload method (multipart or JSON+base64).
class OneBunFile {
readonly name: string; // File name
readonly size: number; // File size in bytes
readonly type: string; // MIME type
readonly lastModified: number; // Last modified timestamp
async toBase64(): Promise<string>; // Convert to base64 string
async toBuffer(): Promise<Buffer>; // Convert to Buffer
async toArrayBuffer(): Promise<ArrayBuffer>; // Convert to ArrayBuffer
toBlob(): Blob; // Get underlying Blob
async writeTo(path: string): Promise<void>; // Write to disk
static fromBase64(data: string, filename?: string, mimeType?: string): OneBunFile;
}MimeType Enum
Common MIME types for use with file upload options:
import { MimeType } from '@onebun/core';
// Wildcards
MimeType.ANY // '*/*'
MimeType.ANY_IMAGE // 'image/*'
MimeType.ANY_VIDEO // 'video/*'
MimeType.ANY_AUDIO // 'audio/*'
// Images
MimeType.PNG, MimeType.JPEG, MimeType.GIF, MimeType.WEBP, MimeType.SVG
// Documents
MimeType.PDF, MimeType.JSON, MimeType.XML, MimeType.ZIP, MimeType.CSV, MimeType.XLSX, MimeType.DOCX
// Video/Audio
MimeType.MP4, MimeType.WEBM, MimeType.MP3, MimeType.WAV
// Text
MimeType.PLAIN, MimeType.HTML, MimeType.CSS, MimeType.JAVASCRIPT
// Binary
MimeType.OCTET_STREAMJSON Base64 Upload Format
When sending files via application/json, the framework accepts two formats:
// Full format with metadata
{ "avatar": { "data": "iVBORw0KGgo...", "filename": "photo.png", "mimeType": "image/png" } }
// Simplified format (raw base64 string)
{ "avatar": "iVBORw0KGgo..." }The same @UploadedFile decorator works for both multipart and JSON uploads.
WARNING
@Body() cannot be used together with @UploadedFile, @UploadedFiles, or @FormField on the same method, since both consume the request body.
Service Decorators
@Service()
Marks a class as an injectable service.
@Service(tag?: Context.Tag<T, T>)Example:
import { Service, BaseService } from '@onebun/core';
@Service()
export class UserService extends BaseService {
// Service with auto-generated tag
async findAll(): Promise<User[]> {
this.logger.info('Finding all users');
// ...
}
}
// With custom Effect.js tag
import { Context } from 'effect';
const CustomServiceTag = Context.GenericTag<CustomService>('CustomService');
@Service(CustomServiceTag)
export class CustomService extends BaseService {
// Service with explicit tag
}@Inject()
Explicit dependency injection for edge cases. In most cases, automatic DI works without this decorator. See Architecture — Dependency Injection for how DI resolution works.
@Inject(type: new (...args: any[]) => T)When to use @Inject:
- Interface or abstract class injection
- Token-based injection (custom Context.Tag)
- Overriding automatic resolution
Example:
@Controller('/users')
export class UserController extends BaseController {
constructor(
// Automatic injection (works in most cases) - no @Inject needed
private userService: UserService,
private cacheService: CacheService,
// @Inject needed only for edge cases:
// - When injecting by interface instead of concrete class
// - When using custom Effect.js Context.Tag
@Inject(SomeAbstractService) private abstractService: SomeAbstractService,
) {
super();
}
}Middleware Decorators
@Middleware()
Class decorator for middleware. Apply it to classes that extend BaseMiddleware so that the framework can resolve constructor dependencies automatically (TypeScript emits design:paramtypes when the class has a decorator). Without @Middleware(), you would need @Inject() on each constructor parameter for DI to work.
@Middleware()
class AuthMiddleware extends BaseMiddleware {
constructor(private authService: AuthService) {
super();
}
async use(req, next) { ... }
}@UseMiddleware()
Apply middleware to a route handler or to all routes in a controller. Works as both a method decorator and a class decorator. Pass class constructors extending BaseMiddleware (not instances).
// Method decorator — applies to a single route
@UseMiddleware(...middleware: MiddlewareClass[])
// Class decorator — applies to every route in the controller
@UseMiddleware(...middleware: MiddlewareClass[])Method-level example:
import { BaseMiddleware, type OneBunRequest, type OneBunResponse } from '@onebun/core';
class AuthMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
const token = req.headers.get('Authorization');
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
return next();
}
}
class LogMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
this.logger.info(`${req.method} ${req.url}`);
return next();
}
}
@Controller('/users')
export class UserController extends BaseController {
@Get('/protected')
@UseMiddleware(AuthMiddleware)
async protectedRoute() {
return this.success({ message: 'Secret data' });
}
@Post('/action')
@UseMiddleware(LogMiddleware, AuthMiddleware) // Multiple middleware
async action() {
return this.success({ message: 'Action performed' });
}
}Class-level example:
@Controller('/admin')
@UseMiddleware(AuthMiddleware) // Applied to ALL routes in this controller
export class AdminController extends BaseController {
@Get('/dashboard')
getDashboard() {
return this.success({ stats: {} });
}
@Put('/settings')
@UseMiddleware(AuditLogMiddleware) // Additional middleware for this route
updateSettings() {
return this.success({ updated: true });
}
}When both class-level and method-level middleware are present, execution order is: controller-level -> route-level -> handler.
Middleware classes support full DI through the constructor. Use @Middleware() on the class for automatic dependency resolution. See Controllers API — Middleware for details and examples.
Response Decorators
@ApiResponse()
Define response schema for documentation and validation.
@ApiResponse(statusCode: number, options?: {
schema?: Type<unknown>;
description?: string;
})Decorator Order
@ApiResponse must be placed below the route decorator (@Get, @Post, etc.) because the route decorator reads response schemas when it runs.
Example:
import { type } from 'arktype';
const userResponseSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
@Controller('/users')
export class UserController extends BaseController {
// @ApiResponse must be BELOW @Get
@Get('/:id')
@ApiResponse(200, {
schema: userResponseSchema,
description: 'User found successfully',
})
@ApiResponse(404, {
description: 'User not found',
})
async findOne(@Param('id') id: string) {
// Response will be validated against userResponseSchema
return this.success({ id, name: 'John', email: 'john@example.com' });
}
}Documentation Decorators
Package: @onebun/docs
These decorators add metadata for OpenAPI/Swagger documentation generation.
Decorator Order Matters
Due to how TypeScript decorators work with the @Controller wrapper:
@ApiTagsmust be placed above@Controller@ApiOperationmust be placed above route decorators (@Get,@Post, etc.)@ApiResponsemust be placed below route decorators
@ApiTags()
Group endpoints under tags for documentation organization.
import { ApiTags } from '@onebun/docs';
@ApiTags(...tags: string[])Can be used on controller class or individual methods:
Example:
import { Controller, BaseController, Get } from '@onebun/core';
import { ApiTags } from '@onebun/docs';
// @ApiTags must be ABOVE @Controller
@ApiTags('Users', 'User Management')
@Controller('/users')
export class UserController extends BaseController {
// All endpoints tagged with 'Users' and 'User Management'
// For method-level tags, place above the route decorator
@ApiTags('Admin')
@Get('/admins')
async getAdmins() {
return this.success([]);
}
}@ApiOperation()
Describe an API operation with summary, description, and additional tags.
import { ApiOperation } from '@onebun/docs';
@ApiOperation(options: {
summary?: string;
description?: string;
tags?: string[];
})Example:
import { Controller, BaseController, Get, Param } from '@onebun/core';
import { ApiOperation } from '@onebun/docs';
@Controller('/users')
export class UserController extends BaseController {
// @ApiOperation must be ABOVE the route decorator
@ApiOperation({
summary: 'Get user by ID',
description: 'Returns a single user by their unique identifier. Returns 404 if user not found.',
tags: ['Users'],
})
@Get('/:id')
async getUser(@Param('id') id: string) {
return this.success({ id, name: 'John' });
}
}Combining Documentation Decorators
Use both @onebun/core and @onebun/docs decorators together for complete documentation:
import { Controller, BaseController, Get, Post, Body, Param, ApiResponse } from '@onebun/core';
import { ApiTags, ApiOperation } from '@onebun/docs';
import { type } from 'arktype';
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
@Controller('/users')
@ApiTags('Users')
export class UserController extends BaseController {
@ApiOperation({ summary: 'Get user by ID' })
@Get('/:id')
@ApiResponse(200, { schema: userSchema, description: 'User found' })
@ApiResponse(404, { description: 'User not found' })
async getUser(@Param('id') id: string) {
// ...
}
@ApiOperation({ summary: 'Create new user', description: 'Creates a new user account' })
@Post('/')
@ApiResponse(201, { schema: userSchema, description: 'User created' })
@ApiResponse(400, { description: 'Invalid input' })
async createUser(@Body(createUserSchema) body: typeof createUserSchema.infer) {
// ...
}
}Tracing Decorators
@Span()
Create a trace span for a method (from @onebun/trace).
@Span(name?: string)Example:
import { Span } from '@onebun/core';
@Service()
export class UserService extends BaseService {
@Span('user-find-by-id')
async findById(id: string): Promise<User | null> {
// This method is automatically traced
return this.repository.findById(id);
}
@Span() // Uses method name as span name
async processUser(user: User): Promise<void> {
// Span name: "processUser"
}
}Utility Functions
getControllerMetadata()
Get metadata for a controller class.
function getControllerMetadata(target: Function): ControllerMetadata | undefined;
interface ControllerMetadata {
path: string;
routes: RouteMetadata[];
}
interface RouteMetadata {
path: string;
method: HttpMethod;
handler: string;
params?: ParamMetadata[];
middleware?: Function[];
responseSchemas?: ResponseSchemaMetadata[];
}getModuleMetadata()
Get metadata for a module class.
function getModuleMetadata(target: Function): ModuleMetadata | undefined;
interface ModuleMetadata {
imports?: Function[];
controllers?: Function[];
providers?: unknown[];
exports?: unknown[];
}getServiceMetadata()
Get metadata for a service class.
function getServiceMetadata(serviceClass: Function): ServiceMetadata | undefined;
interface ServiceMetadata {
tag: Context.Tag<unknown, unknown>;
impl: new () => unknown;
}getServiceTag()
Get Effect.js Context tag for a service class.
function getServiceTag<T>(serviceClass: new (...args: unknown[]) => T): Context.Tag<T, T>;registerDependencies()
Manually register constructor dependencies (fallback method).
function registerDependencies(target: Function, dependencies: Function[]): void;Complete Example
import {
Module,
Controller,
BaseController,
Service,
BaseService,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
Header,
Cookie,
Req,
UseMiddleware,
ApiResponse,
Inject,
type OneBunRequest,
} from '@onebun/core';
import { Span } from '@onebun/trace';
import { type } from 'arktype';
// Validation schemas
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
// Service
@Service()
export class UserService extends BaseService {
private users = new Map<string, { id: string; name: string; email: string }>();
@Span('find-all-users')
async findAll(): Promise<Array<typeof userSchema.infer>> {
return Array.from(this.users.values());
}
@Span('find-user-by-id')
async findById(id: string): Promise<typeof userSchema.infer | null> {
return this.users.get(id) || null;
}
async create(data: typeof createUserSchema.infer): Promise<typeof userSchema.infer> {
const user = { id: crypto.randomUUID(), ...data };
this.users.set(user.id, user);
this.logger.info('User created', { userId: user.id });
return user;
}
}
// Middleware
class AuthMiddleware extends BaseMiddleware {
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
const token = req.headers.get('Authorization');
if (!token?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ success: false, message: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return next();
}
}
// Controller
@Controller('/users')
export class UserController extends BaseController {
constructor(private userService: UserService) {
super();
}
@Get('/')
@ApiResponse(200, { schema: userSchema.array() })
async findAll(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
): Promise<Response> {
const users = await this.userService.findAll();
return this.success(users);
}
@Get('/:id')
@ApiResponse(200, { schema: userSchema })
@ApiResponse(404, { description: 'User not found' })
async findOne(@Param('id') id: string): Promise<Response> {
const user = await this.userService.findById(id);
if (!user) {
return this.error('User not found', 404, 404);
}
return this.success(user);
}
@Post('/')
@UseMiddleware(AuthMiddleware)
@ApiResponse(201, { schema: userSchema })
async create(
@Body(createUserSchema) body: typeof createUserSchema.infer,
@Header('X-Request-ID') requestId?: string,
): Promise<Response> {
this.logger.info('Creating user', { requestId });
const user = await this.userService.create(body);
return this.success(user);
}
}
// Module
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}