nicot
v1.2.12
Published
Nest.js interacting with class-validator + OpenAPI + TypeORM for Nest.js Restful API development.
Maintainers
Readme
NICOT
NICOT is an entity-driven REST framework for NestJS + TypeORM.
You define an entity once, and NICOT generates:
- ORM columns (TypeORM)
- Validation rules (class-validator)
- Request DTOs (Create / Update / Query)
- RESTful endpoints + Swagger docs
- Unified response shape, pagination, relations loading
with explicit, field-level control over:
- what can be written
- what can be queried
- what can be returned
Name & Philosophy
NICOT stands for:
- N — NestJS
- I — nesties (the shared utility toolkit NICOT builds on)
- C — class-validator
- O — OpenAPI / Swagger
- T — TypeORM
The name also hints at “nicotto” / “nicotine”: something small that can be habit-forming. The idea is:
One entity definition becomes the contract for everything
(DB, validation, DTO, OpenAPI, CRUD, pagination, relations).
NICOT’s design is:
- Entity-driven: metadata lives close to your domain model, not in a separate schema file.
- Whitelist-first: what can be queried or returned is only what you explicitly decorate.
- AOP-like hooks: lifecycle methods and query decorators let you inject logic without scattering boilerplate.
Installation
npm install nicot @nestjs/config typeorm @nestjs/typeorm class-validator class-transformer reflect-metadata @nestjs/swaggerNICOT targets:
- NestJS ^9 / ^10 / ^11
- TypeORM ^0.3.x
Quick Start
1. Define your entity
import { Entity } from 'typeorm';
import {
IdBase,
StringColumn,
IntColumn,
BoolColumn,
DateColumn,
NotInResult,
NotWritable,
QueryEqual,
QueryMatchBoolean,
} from 'nicot';
@Entity()
export class User extends IdBase() {
@StringColumn(255, { required: true, description: 'User name' })
@QueryEqual()
name: string;
@IntColumn('int', { unsigned: true })
age: number;
@BoolColumn({ default: true })
@QueryMatchBoolean()
isActive: boolean;
@StringColumn(255)
@NotInResult()
password: string;
@DateColumn()
@NotWritable()
createdAt: Date;
isValidInCreate() {
return this.age < 18 ? 'Minors are not allowed to register' : undefined;
}
isValidInUpdate() {
return undefined;
}
}2. Create a RestfulFactory
Best practice: one factory file per entity.
// user.factory.ts
export const UserFactory = new RestfulFactory(User, {
relations: [], // explicitly loaded relations (for DTO + queries)
skipNonQueryableFields: true, // query DTO = only fields with @QueryXXX
});3. Service with CrudService
// user.service.ts
@Injectable()
export class UserService extends UserFactory.crudService() {
constructor(@InjectRepository(User) repo: Repository<User>) {
super(repo);
}
}4. Controller using factory-generated decorators
// user.controller.ts
import { Controller } from '@nestjs/common';
import { PutUser } from '../auth/put-user.decorator'; // your own decorator
// Fix DTO types up front
export class CreateUserDto extends UserFactory.createDto {}
export class UpdateUserDto extends UserFactory.updateDto {}
export class FindAllUserDto extends UserFactory.findAllDto {}
@Controller('users')
export class UserController {
constructor(private readonly service: UserService) {}
@UserFactory.create()
async create(
@UserFactory.createParam() dto: CreateUserDto,
@PutUser() currentUser: User,
) {
// business logic - attach owner
dto.ownerId = currentUser.id;
return this.service.create(dto);
}
@UserFactory.findAll()
async findAll(
@UserFactory.findAllParam() dto: FindAllUserDto,
@PutUser() currentUser: User,
) {
return this.service.findAll(dto, qb => {
qb.andWhere('user.ownerId = :uid', { uid: currentUser.id });
});
}
@UserFactory.findOne()
async findOne(@UserFactory.idParam() id: number) {
return this.service.findOne(id);
}
@UserFactory.update()
async update(
@UserFactory.idParam() id: number,
@UserFactory.updateParam() dto: UpdateUserDto,
) {
return this.service.update(id, dto);
}
@UserFactory.delete()
async delete(@UserFactory.idParam() id: number) {
return this.service.delete(id);
}
}Start the Nest app, and you get:
POST /usersGET /users/:idGET /usersPATCH /users/:idDELETE /users/:id- Optional
POST /users/import
Documented in Swagger, with DTOs derived directly from your entity definition.
Base ID classes: IdBase / StringIdBase
In NICOT you usually don’t hand-roll primary key fields. Instead you inherit one of the base classes.
IdBase() — numeric auto-increment primary key
@Entity()
export class Article extends IdBase({ description: 'Article ID' }) {
// id: number (bigint unsigned, primary, auto-increment)
}Behavior:
- Adds
id: numbercolumn (bigint unsigned,primary: true,Generated('increment')) - Marks it as:
@NotWritable()— cannot be written via create/update DTO@IntColumn('bigint', { unsigned: true, ... })@QueryEqual()— usable as?id=...in GET queries
- By default, adds
ORDER BY id DESCinapplyQuery(you can override or disable withnoOrderById: true)
StringIdBase() — string / UUID primary key
@Entity()
export class ApiKey extends StringIdBase({
uuid: true,
description: 'API key ID',
}) {
// id: string (uuid, primary)
}Behavior:
- Adds
id: stringcolumn - When
uuid: true:@UuidColumn({ primary: true, generated: true, ... })@NotWritable()
- When
uuid: falseor omitted:@StringColumn(length || 255, { required: true, ... })@IsNotEmpty()+@NotChangeable()(writable only at create time)
- Default ordering:
ORDER BY id ASC(can be disabled vianoOrderById)
Summary:
| Base class | Type | Default order | Generation strategy |
|-------------------|---------|---------------|-----------------------------|
| IdBase() | number | id DESC | auto increment (bigint) |
| StringIdBase() | string | id ASC | UUID or manual string |
Column decorators overview
NICOT’s ***Column() decorators combine:
- TypeORM column definition
- class-validator rules
- Swagger
@ApiProperty()metadata
Common ones:
| Decorator | DB type | Validation defaults |
|----------------------|--------------------|-----------------------------|
| @StringColumn(len) | varchar(len) | @IsString(), @Length() |
| @TextColumn() | text | @IsString() |
| @UuidColumn() | uuid | @IsUUID() |
| @IntColumn(type) | integer types | @IsInt() |
| @FloatColumn(type) | float/decimal | @IsNumber() |
| @BoolColumn() | boolean | @IsBoolean() |
| @DateColumn() | timestamp/date | @IsDate() |
| @JsonColumn(T) | jsonb | @IsObject() / nested val. |
| @SimpleJsonColumn | json | same as above |
| @StringJsonColumn | text (stringified JSON) | same as above |
| @EnumColumn(Enum) | enum or text | enum validation |
All of them accept an options parameter:
@StringColumn(255, {
required: true,
description: 'Display name',
default: 'Anonymous',
})
displayName: string;Access control decorators
These decorators control where a field appears:
- in create/update DTOs
- in query DTOs (GET)
- in response DTOs (
ResultDto)
Write / read restrictions
| Decorator | Effect on DTOs |
|-------------------|-----------------------------------------------------------|
| @NotWritable() | Removed from both Create & Update DTO |
| @NotCreatable() | Removed from Create DTO only |
| @NotChangeable()| Removed from Update DTO only |
| @NotQueryable() | Removed from GET DTO (query params), can’t be used in filters |
| @NotInResult() | Removed from all response DTOs (including nested relations) |
Non-column & virtual fields
| Decorator | Meaning |
|----------------------|-------------------------------------------------------------------------|
| @NotColumn() | Not mapped to DB; usually set in afterGet() as a computed field |
| @QueryColumn() | Only exists in query DTO (no DB column), used with @QueryXXX() |
| @RelationComputed(() => Class) | Virtual field that depends on relations; participates in relation pruning |
Example:
@Entity()
export class User extends IdBase() {
@StringColumn(255, { required: true })
name: string;
@StringColumn(255)
@NotInResult()
password: string;
@DateColumn()
@NotWritable()
createdAt: Date;
@NotColumn()
@RelationComputed(() => Profile)
profileSummary: ProfileSummary;
}Decorator priority (simplified)
When NICOT generates DTOs, it applies a whitelist/cut-down pipeline. Roughly:
- Create DTO omits:
@NotColumn@NotWritable@NotCreatable- factory options:
fieldsToOmit,writeFieldsToOmit,createFieldsToOmit - relation fields (TypeORM relations are not part of simple create DTO)
- Update DTO omits:
@NotColumn@NotWritable@NotChangeable- factory options:
fieldsToOmit,writeFieldsToOmit,updateFieldsToOmit
- Query DTO (GET) omits:
@NotColumn@NotQueryable- fields that require a GetMutator but do not actually have one
- Response DTO (
ResultDto) omits:@NotInResult- factory
outputFieldsToOmit - relation fields that are not in the current
relationswhitelist
In short:
If you mark something as “not writable / queryable / in result”, that wins, regardless of column type or other decorators.
Query decorators & QueryCondition
Query decorators define how a field is translated into SQL in GET queries.
Internally they all use a QueryCondition callback:
export const QueryCondition = (cond: QueryCond) =>
Metadata.set(
'queryCondition',
cond,
'queryConditionFields',
) as PropertyDecorator;Lifecycle in findAll() / findAllCursorPaginated()
When you call CrudBase.findAll(ent):
- NICOT creates a new entity instance.
- Copies the DTO into it.
- Calls
beforeGet()(if present) — good place to adjust defaults. - Calls
entity.applyQuery(qb, alias)— from your base class (e.g.IdBaseaddsorderBy(id desc)). - Applies relations joins (
relationsconfig). - Iterates over all fields with
QueryConditionmetadata and runs the conditions to mutate theSelectQueryBuilder.
So @QueryXXX() is a declarative hook into the query building stage.
Built-in Query decorators
Based on QueryWrap / QueryCondition:
- Simple operators:
@QueryEqual()@QueryGreater(),@QueryGreaterEqual()@QueryLess(),@QueryLessEqual()@QueryNotEqual()@QueryOperator('<', 'fieldName?')for fully custom operators
- LIKE / search:
@QueryLike()(prefix matchfield LIKE value%)@QuerySearch()(contains matchfield LIKE %value%)
- Boolean handling:
@QueryMatchBoolean()— parses"true" / "false" / "1" / "0"
- Arrays / IN:
@QueryIn()—IN (...), supports comma-separated strings or arrays@QueryNotIn()—NOT IN (...)
- Null handling:
@QueryEqualZeroNullable()—0(or"0") becomesIS NULL, others= :value
- JSON:
@QueryJsonbHas()— Postgres?operator on jsonb field
All of these are high-level wrappers over the central abstraction:
export const QueryWrap = (wrapper: QueryWrapper, field?: string) =>
QueryCondition((obj, qb, entityName, key) => {
// ...convert obj[key] and call qb.andWhere(...)
});Composing conditions: QueryAnd / QueryOr
You can combine multiple QueryCondition implementations:
export const QueryAnd = (...decs: PropertyDecorator[]) => { /* ... */ };
export const QueryOr = (...decs: PropertyDecorator[]) => { /* ... */ };QueryAnd(A, B)— run both conditions on the same field (AND).QueryOr(A, B)— build an(A) OR (B)bracket group.
These are useful for e.g. multi-column search or fallback logic.
Full-text search: QueryFullText
PostgreSQL-only helper:
@StringColumn(255)
@QueryFullText({
configuration: 'english',
tsQueryFunction: 'websearch_to_tsquery',
orderBySimilarity: true,
})
content: string;NICOT will:
- On module init, create needed text search configuration & indexes.
- For queries, generate
to_tsvector(...) @@ websearch_to_tsquery(...). - Optionally compute a
ranksubject and order by it whenorderBySimilarity: true.
Note: full-text features are intended for PostgreSQL. On other databases they are not supported.
GetMutator & MutatorPipe
GET query params are always strings on the wire, but entities may want richer types (arrays, numbers, JSON objects).
NICOT uses:
@GetMutator(...)metadata on the entity fieldMutatorPipeto apply the conversion at runtimePatchColumnsInGetto adjust Swagger docs for GET DTOs
Concept
- Swagger/OpenAPI for GET shows the field as string (or string-based, possibly with example/enum from the mutator).
- At runtime,
MutatorPipereads the string value and calls your mutator function. - The controller receives a typed DTO (e.g. array of numbers, parsed JSON) even though the URL carried strings.
Example
@JsonColumn(SomeFilterObject)
@GetMutatorJson() // parse JSON string from ?filter=...
@QueryOperator('@>') // use jsonb containment operator
filter: SomeFilterObject;Built-in helpers include:
@GetMutatorBool()@GetMutatorInt()@GetMutatorFloat()@GetMutatorStringSeparated(',')@GetMutatorIntSeparated()@GetMutatorFloatSeparated()@GetMutatorJson()
Internally, PatchColumnsInGet tweaks Swagger metadata so that:
- Fields with GetMutator are shown as
type: string(withexample/enumif provided by the mutator metadata). - Other queryable fields have their default value cleared (so GET docs don’t misleadingly show defaults).
And RestfulFactory.findAllParam() wires everything together:
- Applies
MutatorPipeif GetMutators exist. - Applies
OmitPipe(fieldsInGetToOmit)to strip non-queryable fields. - Optionally applies
PickPipe(queryableFields)whenskipNonQueryableFields: true.
skipNonQueryableFields: only expose explicitly declared query fields
By default, findAllDto is:
- Entity fields minus:
@NotColumn- TypeORM relations
@NotQueryable- fields that require GetMutator but don’t have one
- Plus
PageSettingsDto’s pagination fields (pageCount,recordsPerPage).
If you want GET queries to accept only fields that have @QueryEqual() / @QueryLike() / @QueryIn() etc, use:
const UserFactory = new RestfulFactory(User, {
relations: [],
skipNonQueryableFields: true,
});Effects:
findAllDtokeeps only fields that:- have a
QueryCondition(i.e. some@QueryXXX()decorator), - and are not in the omit list (
NotQueryable,NotColumn, missing mutator).
- have a
- Swagger query params = exactly those queryable fields.
- At runtime,
findAllParam()runsPickPipe(queryableFields), so stray query params are dropped.
Mental model:
“If you want a field to be filterable in GET
/users, you must explicitly add a@QueryXXX()decorator. Otherwise it’s invisible.”
Recommended:
- For admin / multi-tenant APIs → turn
skipNonQueryableFields: trueON. - For internal tools / quick debugging → you can leave it OFF for convenience.
Binding Context (Data Binding & Multi-Tenant Isolation)
In real systems, you often need to isolate data by context:
- current user
- current tenant / app
- current organization / project
Typical rules:
- A user can only see their own rows.
- Updates/deletes must be scoped by ownership.
- You don’t want to copy-paste
qb.andWhere('userId = :id', ...)everywhere.
NICOT provides BindingColumn / BindingValue / useBinding / beforeSuper on top of CrudBase so that
multi-tenant isolation becomes part of the entity contract, not scattered per-controller logic.
BindingColumn — declare “this field must be bound”
Use @BindingColumn on entity fields that should be filled and filtered by the backend context,
instead of coming from the client payload.
@Entity()
export class Article extends IdBase() {
@BindingColumn() // default bindingKey: "default"
@IntColumn('int', { unsigned: true })
userId: number;
@BindingColumn('app') // bindingKey: "app"
@IntColumn('int', { unsigned: true })
appId: number;
}NICOT will:
- on
create:- write binding values into
userId/appId(if provided)
- write binding values into
- on
findAll:- automatically add
WHERE userId = :value/appId = :value
- automatically add
- on
update/delete:- add the same binding conditions, preventing cross-tenant access
Effectively: binding columns are your “ownership / tenant” fields.
BindingValue — where the binding values come from
@BindingValue is placed on service properties or methods that provide the actual binding values.
@Injectable()
class ArticleService extends CrudService(Article) {
constructor(@InjectRepository(Article) repo: Repository<Article>) {
super(repo);
}
@BindingValue() // for BindingColumn()
get currentUserId() {
return this.ctx.userId;
}
@BindingValue('app') // for BindingColumn('app')
get currentAppId() {
return this.ctx.appId;
}
}At runtime, NICOT will:
- collect all
BindingValuemetadata - build a partial entity
{ userId, appId, ... } - use it to:
- fill fields on
create - add
WHEREconditions onfindAll,update,delete
- fill fields on
If both client payload and BindingValue provide a value, BindingValue wins for binding columns.
You can use:
- properties (sync)
- getters
- methods (sync)
- async methods
> NICOT will await async BindingValues when necessary.
Request-scoped context provider (recommended)
The “canonical” way to provide binding values in a web app is:
- Extract context (user, app, tenant, etc.) from the incoming request.
- Put it into a request-scoped provider.
- Have
@BindingValuesimply read from that provider.
This keeps:
- context lifetime = request lifetime
- services as singletons
- binding logic centralized and testable
1) Define a request-scoped binding context
Using createProvider from nesties, you can declare a strongly-typed request-scoped provider:
export const BindingContextProvider = createProvider(
{
provide: 'BindingContext',
scope: Scope.REQUEST, // ⭐ one instance per HTTP request
inject: [REQUEST, AuthService] as const,
},
async (req, auth) => {
const user = await auth.getUserFromRequest(req);
return {
userId: user.id,
appId: Number(req.headers['x-app-id']),
};
},
);Key points:
scope: Scope.REQUEST→ each request has its own context instance.inject: [REQUEST, AuthService]→ you can pull anything you need to compute bindings.createProviderinfers(req, auth)types automatically.
2) Inject the context into your service and expose BindingValues
@Injectable()
class ArticleService extends CrudService(Article) {
constructor(
@InjectRepository(Article) repo: Repository<Article>,
@Inject('BindingContext')
private readonly ctx: { userId: number; appId: number },
) {
super(repo);
}
@BindingValue()
get currentUserId() {
return this.ctx.userId;
}
@BindingValue('app')
get currentAppId() {
return this.ctx.appId;
}
}With this setup:
- each request gets its own
{ userId, appId }context @BindingValuesimply reads from that contextCrudBaseapplies bindings for create / findAll / update / delete automatically- controllers do not need to repeat
userIdconditions
This is the recommended way to use binding in a NestJS HTTP app.
useBinding — override binding per call
For tests, scripts, or some internal flows, you may want to override binding values per call
instead of relying on @BindingValue.
Use useBinding for this:
// create with explicit binding
const res = await articleService
.useBinding(7) // bindingKey: "default"
.useBinding(44, 'app') // bindingKey: "app"
.create({ name: 'Article 1' });
// query in the same binding scope
const list = await articleService
.useBinding(7)
.useBinding(44, 'app')
.findAll({});Key properties:
- override is per call, not global
- multiple concurrent calls with different
useBindingvalues are isolated - merges with
@BindingValue(explicituseBindingcan override default BindingValue)
This is particularly handy in unit tests and CLI scripts.
beforeSuper — safe overrides with async logic (advanced)
CrudService subclasses are singletons, but bindings are per call.
If you override findAll / update / delete and add await before calling super,
you can accidentally mess with binding order / concurrency.
NICOT offers beforeSuper as a small helper:
@Injectable()
class SlowArticleService extends ArticleService {
override async findAll(
...args: Parameters<typeof ArticleService.prototype.findAll>
) {
await this.beforeSuper(async () => {
// any async work before delegating to CrudBase
await new Promise((resolve) => setTimeout(resolve, 100));
});
return super.findAll(...args);
}
}What beforeSuper ensures:
- capture (freeze) current binding state
- run your async pre-logic
- restore binding state
- continue into
CrudBasewith the correct bindings
This is an advanced hook; most users don’t need it. For typical per-request isolation, prefer request-scoped context + @BindingValue.
How Binding works inside CrudBase
On each CRUD operation, NICOT does roughly:
- collect
BindingValuefrom the service (properties / getters / methods / async methods) - merge with
useBinding(...)overlays - build a “binding partial entity”
- apply it to:
create: force binding fieldsfindAll/update/delete: add binding-basedWHEREconditions
- continue with:
beforeGet/beforeUpdate/beforeCreate- query decorators (
@QueryXXX) - pagination
- relations
You can think of Binding as “automatic ownership filters” configured declaratively on:
- entities (
@BindingColumn) - services (
@BindingValue,useBinding,beforeSuper, request-scoped context)
Pagination
Offset pagination (default)
Every findAll() uses offset pagination via PageSettingsDto:
- Query fields:
pageCount(1-based)recordsPerPage(default 25)
- Internally:
- Applies
.take(recordsPerPage).skip((pageCount - 1) * recordsPerPage)
- Applies
If your entity extends PageSettingsDto, it can control defaults by overriding methods like getRecordsPerPage().
You can also effectively “disable” pagination for specific entities by returning a large value:
@Entity()
export class LogEntry extends IdBase() {
// ...
getRecordsPerPage() {
return this.recordsPerPage || 99999;
}
}Cursor pagination
NICOT also supports cursor-based pagination via:
CrudBase.findAllCursorPaginated()RestfulFactory.findAllCursorPaginatedDtoentityCursorPaginationReturnMessageDto
Usage sketch:
class FindAllUserCursorDto extends UserFactory.findAllCursorPaginatedDto {}
@UserFactory.findAllCursorPaginated()
async findAll(
@UserFactory.findAllParam() dto: FindAllUserCursorDto,
) {
return this.service.findAllCursorPaginated(dto);
}Notes:
- Offset vs cursor pagination share the same query decorators and entity metadata.
- You choose one mode per controller route (
paginateType: 'offset' | 'cursor' | 'none'inbaseController()). - Cursor payload and multi-column sorting behavior are documented in more detail in the API reference.
CrudBase & CrudService
CrudBase<T> holds the core CRUD and query logic:
create(ent, beforeCreate?)findOne(id, extraQuery?)findAll(dto?, extraQuery?)findAllCursorPaginated(dto?, extraQuery?)update(id, dto, cond?)delete(id, cond?)importEntities(entities, extraChecking?)exists(id)onModuleInit()(full-text index loader for Postgres)
It honors:
- Relations configuration (
relations→ joins + DTO shape) NotInResult/outputFieldsToOmitin responses (cleanEntityNotInResultFields())- Lifecycle hooks on the entity:
beforeCreate/afterCreatebeforeGet/afterGetbeforeUpdate/afterUpdateisValidInCreate/isValidInUpdate(return a string = validation error)
You usually don’t subclass CrudBase directly; instead you use:
export function CrudService<T extends ValidCrudEntity<T>>(
entityClass: ClassType<T>,
crudOptions: CrudOptions<T> = {},
) {
return class CrudServiceImpl extends CrudBase<T> {
constructor(repo: Repository<T>) {
super(entityClass, repo, crudOptions);
}
};
}And let RestfulFactory call this for you via factory.crudService().
You can still use TypeORM’s repository methods directly in custom business methods, but when you do, entity lifecycle hooks (
beforeGet(),afterGet(), etc.) are not automatically applied. For NICOT-managed resources, prefer going throughCrudBasewhen you want its behavior.
RestfulFactory: DTO & Controller generator
RestfulFactory<T> is the heart of “entity → DTOs → controller decorators” mapping.
Options
interface RestfulFactoryOptions<T> {
fieldsToOmit?: (keyof T)[];
writeFieldsToOmit?: (keyof T)[];
createFieldsToOmit?: (keyof T)[];
updateFieldsToOmit?: (keyof T)[];
findAllFieldsToOmit?: (keyof T)[];
outputFieldsToOmit?: (keyof T)[];
prefix?: string;
keepEntityVersioningDates?: boolean;
entityClassName?: string;
relations?: (string | RelationDef)[];
skipNonQueryableFields?: boolean;
}Key ideas:
- relations: both for:
- which relations are eager-loaded and exposed in DTO,
- and which joins are added to queries.
- outputFieldsToOmit: extra fields to drop from response DTOs (in addition to
@NotInResult). - prefix: extra path prefix for controller decorators (e.g.
v1/users). - skipNonQueryableFields: described above.
Auto-generated DTOs
For a factory:
export const UserFactory = new RestfulFactory(User, { relations: [] });NICOT gives you:
UserFactory.createDtoUserFactory.updateDtoUserFactory.findAllDtoUserFactory.findAllCursorPaginatedDtoUserFactory.entityResultDtoUserFactory.entityCreateResultDtoUserFactory.entityReturnMessageDtoUserFactory.entityCreateReturnMessageDtoUserFactory.entityArrayReturnMessageDtoUserFactory.entityCursorPaginationReturnMessageDto
Recommended usage:
export class CreateUserDto extends UserFactory.createDto {}
export class UpdateUserDto extends UserFactory.updateDto {}
export class FindAllUserDto extends UserFactory.findAllDto {}
export class UserResultDto extends UserFactory.entityResultDto {}This keeps types stable and easy to re-use in custom endpoints or guards.
Controller decorators & params
Each factory exposes decorators that match CRUD methods:
create()+createParam()findOne()+idParam()findAll()/findAllCursorPaginated()+findAllParam()update()+updateParam()delete()import()(POST /import)
These decorators stack:
- HTTP method + path (optionally prefixed)
- Swagger operation and response schemas (using the generated DTOs)
- Validation & transform pipes (through DataPipe / OptionalDataPipe / OmitPipe / MutatorPipe)
Example (revised):
// post.factory.ts
export const PostFactory = new RestfulFactory(Post, {
relations: [], // no relations for this resource
});
// post.service.ts
@Injectable()
export class PostService extends PostFactory.crudService() {
constructor(@InjectRepository(Post) repo: Repository<Post>) {
super(repo);
}
}
// post.controller.ts
import { PutUser } from '../common/put-user.decorator';
export class FindAllPostDto extends PostFactory.findAllDto {}
export class CreatePostDto extends PostFactory.createDto {}
@Controller('posts')
export class PostController {
constructor(private readonly service: PostService) {}
@PostFactory.findAll()
async findAll(
@PostFactory.findAllParam() dto: FindAllPostDto,
@PutUser() user: User,
) {
return this.service.findAll(dto, qb => {
qb.andWhere('post.userId = :uid', { uid: user.id });
});
}
@PostFactory.create()
async create(
@PostFactory.createParam() dto: CreatePostDto,
@PutUser() user: User,
) {
dto.userId = user.id;
return this.service.create(dto);
}
}baseController() shortcut
If you don’t have extra logic, you can generate a full controller class:
@Controller('users')
export class UserController extends UserFactory.baseController({
paginateType: 'offset', // 'offset' | 'cursor' | 'none'
globalMethodDecorators: [],
routes: {
import: { enabled: false }, // disable /import
},
}) {
constructor(service: UserService) {
super(service);
}
}- If any route in
routeshasenabled: true, then only explicitly enabled routes are generated. - Otherwise, all routes are generated except ones marked
enabled: false.
This is useful for quickly bootstrapping admin APIs, then selectively disabling / overriding certain endpoints.
Relations & RelationComputed
Relations are controlled by:
- TypeORM decorators on the entity:
@ManyToOne,@OneToMany, etc. - NICOT’s
relationswhitelist in:RestfulFactoryoptionsCrudOptionsforCrudService/CrudBase
Example:
@Entity()
export class User extends IdBase() {
@OneToMany(() => Article, article => article.user)
articles: Article[];
}
@Entity()
export class Article extends IdBase() {
@ManyToOne(() => User, user => user.articles)
user: User;
}If you configure:
export const UserFactory = new RestfulFactory(User, {
relations: ['articles'],
});Then:
UserResultDtoincludesarticlesbut notarticles.user(no recursive explosion).- Query joins
user.articleswhen usingfindOne/findAll.
Virtual relation: RelationComputed
Sometimes you want a computed field that conceptually depends on relations, but is not itself a DB column.
Example:
@Entity()
export class Match extends IdBase() {
@ManyToOne(() => Participant, p => p.matches1)
player1: Participant;
@ManyToOne(() => Participant, p => p.matches2)
player2: Participant;
@NotColumn()
@RelationComputed(() => Participant)
players: Participant[];
async afterGet() {
this.players = [this.player1, this.player2].filter(Boolean);
}
}
export const MatchFactory = new RestfulFactory(Match, {
relations: ['player1', 'player2', 'players'],
});NICOT will:
- Treat
playersas a “computed relation” for pruning rules. - Include
playersin the result DTO, but not recursively include all fields fromParticipant.matches1/matches2etc. - This keeps DTOs from blowing up due to cyclic relations.
Unified response shape
NICOT uses a uniform wrapper for all responses:
{
statusCode: number;
success: boolean;
message: string;
timestamp?: string;
data?: any;
}Types are built via generics:
ReturnMessageDto(Entity)— single payloadPaginatedReturnMessageDto(Entity)— withtotal,totalPages, etc.CursorPaginationReturnMessageDto(Entity)— withnextCursor,previousCursorBlankReturnMessageDto— for responses with no data
And correspondingly in RestfulFactory:
entityReturnMessageDtoentityCreateReturnMessageDtoentityArrayReturnMessageDtoentityCursorPaginationReturnMessageDto
You can still build custom endpoints and return these wrappers manually if needed.
Best practices
- One factory per entity, in its own
*.factory.tsfile.- Keeps entity, factory, service, controller decoupled but aligned.
- Let entities own the contract:
- Column types
- Validation
- Access control (
@NotWritable,@NotInResult,@NotQueryable) - Query capabilities (
@QueryXXX)
- For list APIs, strongly consider:
skipNonQueryableFields: true@QueryXXXonly on fields you really want public filtering on.
- Prefer
CrudService/CrudBasefor NICOT-managed resources, so:- lifecycle hooks are honored,
- relations + “not in result” logic stay consistent.
- Use raw TypeORM repository methods only for clearly separated custom flows, and treat them as “outside NICOT”.
License
MIT
