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 {}exports accepts services only. Listing a MODULE there — the NestJS re-export idiom — throws OneBunInvalidExportError naming both modules. It never worked: a re-exported module contributed nothing to the importer, so either the importer failed at a controller with no mention of the export, or it also imported the module directly and got a second copy of every provider. Import the module that provides the service directly wherever the service is needed:
// Does not work — throws OneBunInvalidExportError at boot
@Module({ imports: [CoreModule], exports: [CoreModule] })
export class FeatureModule {}
// Import the providing module where the service is needed
@Module({ imports: [CoreModule], providers: [UserService] })
export class FeatureModule {}@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
}Note: Leading slash is optional.
@Controller('api/users')and@Controller('/api/users')are equivalent — the framework normalizes the path automatically.
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;
}Note: The leading slash in the path argument is optional.
@Get('users')and@Get('/users')are equivalent. Similarly,@Get(':id')is the same as@Get('/:id'). This makes migration from NestJS seamless — NestJS-style paths work out of the box.
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) {}
}@All() — catch-all routes
@All() is a true catch-all, like NestJS router.all(): the decorated handler answers every HTTP method on that path, not just the seven with a decorator of their own. That includes OPTIONS and HEAD, and it includes methods the framework has no decorator for at all — PROPFIND, PURGE, LOCK, QUERY, vendor verbs. This is what makes @All() usable for reverse proxies, webhook receivers and legacy-path shims.
@Controller('/gateway')
export class GatewayController extends BaseController {
@All('/proxy/:id')
async proxy(@Param('id') id: string, @Req() req: OneBunRequest) {
// req.method is whatever the client sent: GET, POST, PROPFIND, QUERY, ...
return { id, method: req.method };
}
}Precedence — an explicitly declared verb always wins. If the same path carries both @All() and a concrete verb decorator, the concrete one handles its verb and @All() handles everything else. The rule is fixed; it does not depend on the order the decorators are written in:
@Controller('/webhooks')
export class WebhookController extends BaseController {
@All('/github')
async fallback() {
return { handled: 'all' }; // PUT, DELETE, PROPFIND, ... land here
}
@Get('/github')
async health() {
return { handled: 'get' }; // GET lands here, not in fallback()
}
}CORS preflight. Because an
@All()path claimsOPTIONS, a preflight request reaches that route's middleware chain. When the application is configured withcors, the built-inCorsMiddlewareruns first and answers the preflight with204— the@All()handler body never runs. Withoutcorsconfigured, the@All()handler itself answersOPTIONS.
OpenAPI. An
@All()route has no single HTTP method, so it cannot be expressed as one OpenAPI operation. Document the individual verbs you care about with concrete decorators, or exclude the route from the generated spec.
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 '@onebun/core';
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 '@onebun/core';
const createUserSchema = type({
name: 'string',
email: 'string.email',
'age?': 'number > 0',
});
type CreateUserBody = typeof createUserSchema.infer;
// Schema doesn't accept undefined → required
@Post('/')
async create(
@Body(createUserSchema) body: CreateUserBody,
) {}
// Schema accepts undefined → optional
const optionalBodySchema = type({
name: 'string',
}).or(type.undefined);
type OptionalBody = typeof optionalBodySchema.infer;
@Post('/optional')
async createOptional(
@Body(optionalBodySchema) body: OptionalBody,
) {}
// Explicit override
@Post('/force-optional')
async forceOptional(
@Body(createUserSchema, { required: false }) body: CreateUserBody,
) {}
// 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,
) {
await file.writeTo(`./uploads/${file.name}`);
return { 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[],
) {
for (const file of files) {
await file.writeTo(`./uploads/${file.name}`);
}
return { count: files.length };
}
// All files from request (no field name filter)
@Post('/batch')
async uploadBatch(
@UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
) {
return { 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,
) {
await avatar.writeTo(`./uploads/${avatar.name}`);
return { 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)
@Inject(token: symbol | string)When to use @Inject:
- Interface or abstract class injection
- Token-based injection (custom Context.Tag)
- Overriding automatic resolution
- Picking WHICH named registration a parameter gets, in a module that selected two of them
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();
}
}Selecting a named registration:
Only needed in a module that imported TWO registrations of one service. A module that selected one resolves it by type, with no annotation at all.
@Module({
imports: [
DrizzleModule.forFeature(MAIN_DB),
DrizzleModule.forFeature(ANALYTICS_DB),
],
providers: [Reconciler],
})
export class ReconcileModule {}
@Service()
export class Reconciler extends BaseService {
constructor(
@Inject(MAIN_DB) private main: DrizzleService,
@Inject(ANALYTICS_DB) private analytics: DrizzleService,
private clock: ClockService, // un-annotated parameters resolve normally
) {
super();
}
}Asking for a token the module never selected throws at startup, naming what it did select — the alternative is being handed the other database, silently.
@Optional()
Marks a constructor parameter as optional for dependency injection. When the dependency cannot be resolved, undefined is injected instead of throwing DependencyResolutionError.
@Optional()When to use @Optional:
- Feature-gated dependencies (e.g., caching that may not be configured)
- Graceful degradation when an optional module is not imported
Example:
@Service()
export class NotificationService extends BaseService {
constructor(
private userService: UserService, // required — throws if missing
@Optional() private emailService?: EmailService, // optional — undefined if missing
) {
super();
}
async notify(userId: string, message: string) {
if (this.emailService) {
await this.emailService.send(userId, message);
} else {
this.logger.warn('EmailService not available, skipping email notification');
}
}
}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 { message: 'Secret data' };
}
@Post('/action')
@UseMiddleware(LogMiddleware, AuthMiddleware) // Multiple middleware
async action() {
return { 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 { stats: {} };
}
@Put('/settings')
@UseMiddleware(AuditLogMiddleware) // Additional middleware for this route
updateSettings() {
return { 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;
})Example:
import { type } from '@onebun/core';
const userResponseSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
@Controller('/users')
export class UserController extends BaseController {
@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 { id, name: 'John', email: 'john@example.com' };
}
}The schema is enforced, not merely documented: a body that violates it never reaches the client. The request answers HTTP 500 with the default filter's fixed { success: false, error: 'Internal Server Error', code: 500 } — a response that does not match its own declared schema is a server-side contract bug, and the violation detail quotes the offending value, so it goes to the application log rather than to the caller.
Documentation Decorators
Package: @onebun/docs
These decorators add metadata for OpenAPI/Swagger documentation generation.
@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('Users', 'User Management')
@Controller('/users')
export class UserController extends BaseController {
// All endpoints tagged with 'Users' and 'User Management'
@ApiTags('Admin')
@Get('/admins')
async getAdmins() {
return [];
}
}@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({
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 { 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, type } from '@onebun/core';
import { ApiTags, ApiOperation } from '@onebun/docs';
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
type CreateUserBody = typeof createUserSchema.infer;
@ApiTags('Users')
@Controller('/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: CreateUserBody) {
// ...
}
}Tracing Decorators
@Span()
Create a trace span for a method (from @onebun/trace).
@Span(name?: string)Example:
import { Span } from '@onebun/trace';
@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() // Default span name is `ClassName.methodName`
async processUser(user: User): Promise<void> {
// Span name: "UserService.processUser" — the class prefix is part of it
}
}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,
Param,
Body,
Query,
Header,
UseMiddleware,
BaseMiddleware,
ApiResponse,
HttpException,
type OneBunRequest,
type OneBunResponse,
type,
} from '@onebun/core';
import { Span } from '@onebun/trace';
// Validation schemas (in a real app, these live in a separate schema file)
const createUserSchema = type({
name: 'string',
email: 'string.email',
});
type CreateUserBody = typeof createUserSchema.infer;
const userSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
});
type User = typeof userSchema.infer;
// Service
@Service()
export class UserService extends BaseService {
private users = new Map<string, User>();
@Span('find-all-users')
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
@Span('find-user-by-id')
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async create(data: CreateUserBody): Promise<User> {
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,
) {
return this.userService.findAll();
}
@Get('/:id')
@ApiResponse(200, { schema: userSchema })
@ApiResponse(404, { description: 'User not found' })
async findOne(@Param('id') id: string) {
const user = await this.userService.findById(id);
if (!user) throw new HttpException(404, 'User not found');
return user;
}
@Post('/')
@UseMiddleware(AuthMiddleware)
@ApiResponse(201, { schema: userSchema })
async create(
@Body(createUserSchema) body: CreateUserBody,
@Header('X-Request-ID') requestId?: string,
) {
this.logger.info('Creating user', { requestId });
return this.userService.create(body);
}
}
// Module
@Module({
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}