@setu-ts/decorator-plugin
v0.11.0
Published
Optional NestJS-style decorators and reflection, without reflect-metadata
Readme
@setu-ts/decorator-plugin
Optional NestJS-style decorators as syntactic sugar over the kernel's programmatic API.
Decorators capture metadata in a plain MetadataStore — no reflect-metadata, no reflection
polyfill. DecoratorPlugin reads that store at registration time and registers routes, services,
and middleware with the kernel. Decorators are inert unless the plugin is registered.
The store itself is registered under CAPABILITIES.METADATA_STORE ('metadata-store').
Schema compatibility
@ValidateBody, @ValidateQuery, and @ValidateParams preserve application-created schemas and
hand them to the validation/OpenAPI boundaries; this package does not inspect Zod internals. The
supported Zod range is therefore the shared validation contract: >=4.6.6 <5.
pnpm run check:compat exercises Zod 4 through the validation and OpenAPI owners.
Installation
import { Controller, DecoratorPlugin, Get } from '@setu-ts/decorator-plugin';Usage
import {
Body,
Controller,
DecoratorPlugin,
Get,
Param,
Params,
Post,
} from '@setu-ts/decorator-plugin';
@Controller('/users')
class UsersController {
@Get('/:id')
@Params(Param('id'))
findOne(id: string) {
return { id };
}
@Post('/')
@Params(Body())
create(body: unknown) {
return body;
}
}
app.register(DecoratorPlugin({ controllers: [UsersController] }));Non-HTTP ingress
The same explicit class list registers queue processors, scheduled jobs, domain-event and broker
subscriptions, WebSocket gateways, and CQRS handlers. Put those classes in ingress; registration
happens during onInit, after the corresponding provider plugin has registered its capability. A
declared handler with no provider fails startup with the class, method, and missing plugin named.
controllers and ingress are independent lists: a class listed in only one has the OTHER half of
its composition ignored, and register() warns naming the ignored family and the option that would
register it. Listing a class in both is the correct composition — both families register. A
controller contributed by a @Module or by autoDiscover counts as registered for this check;
@Module has no ingress list, so a module controller with ingress decorators still needs ingress.
import { DecoratorPlugin, Processor } from '@setu-ts/decorator-plugin';
import type { IJob } from '@setu-ts/common';
class BackgroundWork {
@Processor('email')
process(_job: IJob): void {}
}
app.register(DecoratorPlugin({ ingress: [BackgroundWork] }));Register the provider plugins a class uses (QueuePlugin, SchedulerPlugin, EventsPlugin,
MessagingPlugin, WebSocketPlugin, or CqrsPlugin) alongside the decorator plugin.
Validation is enforced
@ValidateBody(schema) (and @ValidateQuery / @ValidateParams) do not merely describe a route
for OpenAPI. When a CAPABILITIES.VALIDATION provider is registered — ValidationPlugin, or any
replacement — and enforceSchemas is not false, the capability's middleware is appended LAST in
the route's chain (innermost, after guards), so an invalid request is answered 400 before the
handler runs while guard 401/403 precedence is preserved. @Body(), @Query() and @Param()
then hand the handler the VALIDATED value — transforms, defaults and coercions included — falling
back to the raw source when no validated value exists. Without a validation provider the schemas
stay description-only and the plugin logs one warning per affected route naming ValidationPlugin.
import {
Body,
Controller,
DecoratorPlugin,
Params,
Post,
ValidateBody,
} from '@setu-ts/decorator-plugin';
import { ValidationPlugin } from '@setu-ts/validation-plugin';
import { z } from 'zod';
const CreateUser = z.object({ email: z.string().email(), age: z.number().int().default(18) });
@Controller('/users')
class UsersController {
@Post('/')
@ValidateBody(CreateUser)
@Params(Body())
create(body: { email: string; age: number }) {
return body; // the TRANSFORMED value: `age` defaulted to 18 by the schema
}
}
app.register(ValidationPlugin());
app.register(DecoratorPlugin({ controllers: [UsersController] }));Rendered views
@Render(Component) marks a route as rendered: the handler returns the component's PROPS bag and
the framework answers with the component rendered to HTML (text/html; charset=utf-8), never JSON.
The view is named by reference — a function the application already has — and the decorator
type-checks that return against the component's props, so a wrong props bag is a compile error
naming the mismatch.
import { Controller, Get, Render } from '@setu-ts/decorator-plugin';
// JSX escapes every interpolation, as does hono's `html` tag. A plain
// `(props) => \`<li>${user}</li>\`` template would NOT: escaping belongs to the
// rendering runtime, and a plain string component is returned unchanged.
const UserList = (props: { readonly users: readonly string[] }) => (
<ul>
{props.users.map((user) => (
<li>{user}</li>
))}
</ul>
);
@Controller('/pages')
class PagesController {
@Render(UserList)
@Get('/users')
users() {
return { users: ['ada', 'grace'] }; // the props bag — the framework answers HTML
}
}The engine is resolved from CAPABILITIES.VIEW once at register() — which is why the token joins
this plugin's optionalDependencies (a real ordering edge, not priority luck). A rendered route
with no provider FAILS at register(), naming the controller, the handler and both remedies
(register ViewPlugin, or any other CAPABILITIES.VIEW provider); the check is per route, so an
application with no @Render route needs no view plugin. A FIXED status or header alongside a
rendered body is declared with @HttpCode / @ResponseHeader (see below), and a computed one goes
through @Params(Ctx()); the return value IS the props bag, so it cannot also carry a status, which
is why @Render grows no status argument of its own. Rendering itself — engines, escaping, the
Suspense refusal — lives in @setu-ts/view-plugin; this package imports no rendering runtime.
Response shaping
A decorated handler returning a plain value is answered with ctx.response.json(result). Left
undecorated that is the DEFAULT — 200, JSON, no headers — and the three decorators below change
it, each writing to the response builder before the handler runs. @HttpCode(status),
@ResponseHeader(name, value) (repeatable, for distinct names) and @Redirect(url, status?) let a
route state something FIXED about its response in its declaration, instead of accepting a request
context purely to say it.
import {
Controller,
Get,
HttpCode,
Post,
Redirect,
ResponseHeader,
} from '@setu-ts/decorator-plugin';
@Controller('/orders')
class OrderController {
@HttpCode(201)
@Post('/')
create() {
return { id: 'o-1' };
}
@ResponseHeader('Cache-Control', 'no-store')
@Get('/latest')
latest() {
return { sku: 'a-1' };
}
@Redirect('/orders', 301)
@Get('/v1')
legacy() {
return { moved: true };
}
}@Params(Ctx()) is neither replaced nor deprecated by these: it stays the way to compute a status
or header PER REQUEST, and the only way to write a multi-valued header
(ctx.response.appendHeader).
Three rules. A returned HandlerResult wins — the declared shaping is written to the response
builder BEFORE the handler runs, so ctx.response.status(202).json(...) answers 202 even under
@HttpCode(201), and the same ordering is why @Render composes with a declared status.
@Redirect does not skip the handler — a decorator cannot decline to call the method, so the
body still runs and a plain return is still serialised alongside Location. Every argument is
refused at register(), never per request: @HttpCode takes an integer in [200, 599],
@Redirect one in [300, 399] plus a non-blank target the runtime will carry as a Location
header, a header pair must be one the runtime accepts, a name may not be declared twice, and one
handler may not carry both @HttpCode and @Redirect. Each refusal names the controller, the
method and the value — the alternative is a RangeError thrown inside the HTTP adapter after the
pipeline has finished, or a TypeError while headers are written, answering 500 on every request.
@HttpCode(204) serves a bodiless response, which is what a DELETE handler wants.
@setu-ts/openapi-plugin derives an operation's success status from @HttpCode/@Redirect (on by
default, deriveResponseStatus), so a 201 route is documented under 201 rather than the assumed
200. @ResponseHeader is deliberately not derived: an OpenAPI response-header entry needs a
schema and a description the declaration does not carry.
What it exports
- Routing —
@Controller,@Version,@Get/@Post/@Put/@Patch/@Delete/@Head/@Options - Parameters —
@Params(...), taking the sourcesBody,Query,Param,Header,Cookie,CurrentUser,Ctx,Service(a DI service from the request's scope),Custom - Modules —
@Module({ controllers, providers, imports }) - Non-HTTP ingress —
@Processor,@Cron,@Every,@OnEvent,@Subscribe,@CommandHandler,@QueryHandler,@Gateway(+@OnOpen/@OnMessage/@OnClose) - Injection —
@Injectable,@Inject,@Optional - Security —
@Roles,@Permissions(enforced — see below),@Public(unrestricted OpenAPI marking) - Authorization policies —
@Policy,@Ability,@RequirePolicy(see Authorization policies below) - Pipeline —
@UseGuards,@UseInterceptors,@UseFilters - Validation —
@ValidateBody,@ValidateQuery,@ValidateParams - Idempotency —
@Idempotent(options?), appended LAST in a route's middleware (after guards, declarative authorization and validation), so a refused or invalid request consumes no key; it requires aCAPABILITIES.IDEMPOTENCYprovider — see@setu-ts/idempotency-plugin - Views —
@Render(Component)(renders through aCAPABILITIES.VIEWprovider — see@setu-ts/view-plugin) - Response shaping —
@HttpCode,@ResponseHeader,@Redirect - OpenAPI —
@ApiTags,@ApiOperation,@ApiResponse(@ApiOperationforwardsoperationId,summaryanddescriptioninto the route schema; OpenAPI uses a declared id verbatim and derives one from method and path when it is absent.) - Extension —
createDecorator,Custom,registerParameterResolver - Discovery —
discoverControllers
Options
| Option | Type | Default | Description |
| ----------------- | --------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| controllers | Constructor[] | [] | Controller classes to register explicitly. |
| ingress | Constructor[] | [] | Classes carrying non-HTTP ingress decorators (@Processor, @Cron, @OnEvent, …); registered during onInit. Independent of controllers — see Non-HTTP ingress. |
| services | Constructor[] | [] | Service classes to register explicitly. |
| modules | Constructor[] | [] | Root @Module classes to flatten depth-first; imported module providers register before controllers. |
| autoDiscover | boolean | false | Scan controllersPath for decorated classes. |
| controllersPath | string | — | Glob path used when autoDiscover is true. |
| enforceSchemas | boolean | true | Append the validation capability's middleware for each present @ValidateXxx target. false keeps schemas description-only (OpenAPI) and silences the missing-provider warning. |
| enforceRoles | boolean | true | Append enforcing authorization middleware for each route carrying @Roles/@Permissions. false keeps the metadata description-only and silences the missing-provider warning. |
| policies | Constructor[] | [] | @Policy classes to construct (with constructor injection) and register with AuthPlugin's policy service before any controller. Refused without a CAPABILITIES.AUTHORIZATION_POLICIES provider. |
Discovery failures are logged as warnings and never crash the application.
Node and Bun: the Symbol.metadata carrier
Node 24 and Bun 1.4 do not define Symbol.metadata, which TC39 decorators store their metadata
under. Importing @setu-ts/decorator-plugin installs it once, write-if-absent:
Symbol.metadata ??= Symbol.for('Symbol.metadata'). This is a deliberate global mutation at module
load; on Deno, which defines the symbol, it does nothing. Import the package before any module that
defines a decorated class — an import { Controller } from '@setu-ts/decorator-plugin' in that
module already guarantees it. A class evaluated first, or a bundle that dropped the import as
side-effect-free, fails at definition with DecoratorMetadataUnavailableError, whose message names
both remedies.
Security
@Roles and @Permissions are enforced by default: each decorated route's chain gets
middleware that resolves CAPABILITIES.AUTHORIZATION per request and answers 401 without a
principal and 403 when the check fails — the same statuses and bodies the equivalent
@UseGuards(requireRole(...)) spelling produces. A route carrying both decorators requires any of
the roles AND any of the permissions; method-level declarations override class-level ones.
When no authorization provider is registered, a decorated route fails closed: it answers
501 and the handler never runs — it is never served unguarded — and register() warns once per
affected route naming both remedies (register a provider under CAPABILITIES.AUTHORIZATION, or set
enforceRoles: false).
@Public contributes security: [] to the OpenAPI document only when the route has no enforced
@Roles/@Permissions restriction. It does not exempt a route from a guard or from that
enforcement; a restricted route keeps its derived OpenAPI security requirement.
Authorization policies
@Policy(name) marks a class as an authorization policy and @Ability() marks each ability method;
a method named before is the policy's before hook. List the class in policies, and require an
ability on a route with @RequirePolicy(policy, ability, target?) — the target is a value or an
extractor called per request:
import type { IPrincipal } from '@setu-ts/common';
import {
Ability,
Controller,
DecoratorPlugin,
Patch,
Policy,
RequirePolicy,
} from '@setu-ts/decorator-plugin';
interface Post {
readonly id: string;
readonly authorId: string;
}
@Policy('post')
class PostPolicy {
@Ability()
update(principal: IPrincipal, post: Post | undefined): boolean {
return post?.authorId === principal.id;
}
}
@Controller('/posts')
class PostController {
@Patch('/:id')
@RequirePolicy(PostPolicy, 'update', (ctx) => ({ id: ctx.params.id ?? '', authorId: 'ann' }))
update(): { readonly saved: boolean } {
return { saved: true };
}
}
export const decorators = DecoratorPlugin({
policies: [PostPolicy],
controllers: [PostController],
});The policy service comes from AuthPlugin (@setu-ts/auth-plugin), which must be registered:
register() fails, naming the route, when it is missing or when a @RequirePolicy names a policy
or ability that is not registered. @RequirePolicy also accepts a definePolicy definition, may be
repeated (every one must allow, top to bottom), and runs after guards and @Roles/@Permissions
but before validation — so an extractor reading the body sees the unvalidated body. Refusals are
401 (anonymous) and 403 (signed in), the same bodies as AuthPlugin's requirePolicy guard;
enforceRoles does not affect @RequirePolicy. See
Authorization.
Scoped roles
@ScopedRoles(roles, scope?) (any-of) and @ScopedPermissions(permissions, scope?) (all-of)
require a catalogue role or permission in the request's scope — the class form of AuthPlugin's
requireScopedRole / requireScopedPermission. The scope defaults to the resolved request tenant;
pass a ScopeSource from @setu-ts/common to read it elsewhere:
import { scopeFromParam } from '@setu-ts/common';
import {
Controller,
DecoratorPlugin,
Get,
Post,
ScopedPermissions,
ScopedRoles,
} from '@setu-ts/decorator-plugin';
@Controller('/invoices')
@ScopedRoles(['viewer'])
class InvoiceController {
@Get('/')
list(): string[] {
return [];
}
// A method decorator overrides the class default.
@Post('/:id/approve')
@ScopedPermissions(['invoices:approve'])
approve(): { readonly approved: boolean } {
return { approved: true };
}
@Get('/orgs/:orgId')
@ScopedRoles(['org-admin'], scopeFromParam('orgId', 'organisation'))
forOrg(): string[] {
return [];
}
}
export const decorators = DecoratorPlugin({ controllers: [InvoiceController] });They need AuthPlugin({ rbac, scopedRbac }): register() fails, naming the route, when no policy
service is registered or a name is outside the catalogue (a role a tenant defines at runtime is
checked through the permissions it bundles). They run after @Roles/@Permissions and
@RequirePolicy, answer 401/403 with the same bodies as the guards, and are not governed by
enforceRoles. See
Scoped Roles.
Exports
| Export | Kind |
| ---------------------------- | --------- |
| Ability | function |
| ApiOperation | function |
| ApiResponse | function |
| ApiTags | function |
| Body | function |
| clearParameterResolvers | function |
| CommandHandler | function |
| Controller | function |
| Cookie | function |
| createDecorator | function |
| Cron | function |
| Ctx | function |
| CurrentUser | function |
| Custom | function |
| DecoratorPlugin | function |
| discoverControllers | function |
| Every | function |
| Gateway | function |
| getParameterResolver | function |
| Header | function |
| HttpCode | function |
| Idempotent | function |
| Inject | function |
| Injectable | function |
| Module | function |
| OnEvent | function |
| Optional | function |
| Param | function |
| Params | function |
| parseCookies | function |
| Permissions | function |
| Policy | function |
| Processor | function |
| Public | function |
| Query | function |
| QueryHandler | function |
| Redirect | function |
| registerParameterResolver | function |
| Render | function |
| RequirePolicy | function |
| resolveParameter | function |
| resolveParameters | function |
| ResponseHeader | function |
| Roles | function |
| ScopedPermissions | function |
| ScopedRoles | function |
| Service | function |
| Subscribe | function |
| UseFilters | function |
| UseGuards | function |
| UseIngressBehaviors | function |
| UseInterceptors | function |
| UsePipelineBehaviors | function |
| ValidateBody | function |
| ValidateParams | function |
| ValidateQuery | function |
| Version | function |
| MetadataStore | class |
| Delete | const |
| Get | const |
| Head | const |
| metadataStore | const |
| OnClose | const |
| OnMessage | const |
| OnOpen | const |
| Options | const |
| Patch | const |
| Post | const |
| Put | const |
| AbilityOptions | interface |
| ApiOperationConfig | interface |
| ApiResponseConfig | interface |
| DecoratorPluginOptions | interface |
| DiscoveryOptions | interface |
| DiscoveryResult | interface |
| InjectableOptions | interface |
| ModuleOptions | interface |
| OptionalToken | interface |
| ParameterMetadata | interface |
| ParamSource | interface |
| CustomParameterResolver | type |
| HttpMethodDecorator | type |
| InjectToken | type |
| MiddlewareLike | type |
| ModuleImporter | type |
| ParameterType | type |
| PolicyClassAbility | type |
| PolicyClassTarget | type |
| RenderDecorator | type |
| SetuClassDecorator | type |
| SetuClassOrMethodDecorator | type |
| SetuMethodDecorator | type |
| SourceValues | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
