UNPKG

@elsikora/nestjs-crud-automator

Version:

A library for automating the creation of CRUD operations in NestJS.

1,213 lines (992 loc) 61.7 kB
<p align="center"> <img src="https://6jft62zmy9nx2oea.public.blob.vercel-storage.com/nestjs-crud-automator-8vS2Gox6Qf3xgQazi0Uf8j4ML1surI.png" width="500" alt="project-logo"> </p> <h1 align="center">NestJS-Crud-Automator 🚀</h1> <p align="center"><em>A powerful library for automating CRUD operations in NestJS applications</em></p> <p align="center"> <a aria-label="ElsiKora logo" href="https://elsikora.com"> <img src="https://img.shields.io/badge/MADE%20BY%20ElsiKora-333333.svg?style=for-the-badge" alt="ElsiKora"> </a> <img src="https://img.shields.io/badge/npm-blue.svg?style=for-the-badge&logo=npm&logoColor=white" alt="npm"> <img src="https://img.shields.io/badge/typescript-blue.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="typescript"> <img src="https://img.shields.io/badge/nestjs-red.svg?style=for-the-badge&logo=nestjs&logoColor=white" alt="nestjs"> <img src="https://img.shields.io/badge/swagger-green.svg?style=for-the-badge&logo=swagger&logoColor=white" alt="swagger"> <img src="https://img.shields.io/badge/license-blue.svg?style=for-the-badge&logo=license&logoColor=white" alt="license"> <img src="https://img.shields.io/npm/v/@elsikora/nestjs-crud-automator.svg?style=for-the-badge&logo=npm&logoColor=white" alt="npm version"> </p> ## 📚 Table of Contents - [Description](#-description) - [Features](#-features) - [Installation](#-installation) - [Usage](#-usage) - [Basic Usage](#basic-usage) - [Advanced Usage](#advanced-usage) - [Subscriber System (Hooks)](#subscriber-system-hooks-intercepting-and-extending-logic) - [Swagger Documentation](#swagger-documentation) - [Migrating to 3.0](#-migrating-to-30) - [Current Status](#-current-status) - [AI Agent Skill](#-ai-agent-skill) - [FAQ](#-faq) - [License](#-license) ## 📖 Description NestJS-Crud-Automator is a comprehensive library designed to eliminate repetitive code when building RESTful APIs with NestJS. It provides a suite of decorators, utilities, and validation tools that automatically generate controllers, DTOs, and service methods for handling Create, Read, Update, and Delete operations. This library significantly reduces development time by providing a declarative approach to API development. By simply describing your entity properties once, the library auto-generates all the necessary boilerplate code including Swagger documentation, validation rules, and transformation logic. Perfect for developers working on data-heavy applications who want to focus on business logic rather than repetitive CRUD implementation. The core philosophy of this library is built on four pillars: being **Declarative** (describe your API, don't code it), writing **Minimum Code** (drastically reduce boilerplate), ensuring **Flexibility** (override or extend any automated behavior), and guaranteeing **Type-Safety** (leverage TypeScript to prevent errors). It achieves this through real-time in-memory code generation, a heavy reliance on decorators for configuration, and smart conventions to reduce setup. ## 🚀 Features - ✨ **🏗️ Automatic generation of controllers, DTOs, and service methods for CRUD operations** - ✨ **📝 Comprehensive Swagger/OpenAPI documentation generation for all endpoints** - ✨ **✅ Built-in validation rules with class-validator integration** - ✨ **🔄 Data transformation with class-transformer for request/response handling** - ✨ **🧩 Type-safe decorators for entity properties with rich metadata support** - ✨ **🔒 Authentication and authorization guards integration** - ✨ **🔍 Advanced filtering, sorting, and pagination for list operations** - ✨ **📚 Support for object relations with include-driven loading** - ✨ **⚡ Performance optimized with TypeORM integration for database operations** - ✨ **🌐 Full support for TypeScript with strong typing throughout the library** - ✨ **Hooks and Subscriber System:** Intercept and extend business logic at both the controller and service level. - ✨ **Dynamic and Polymorphic DTOs:** Generate DTOs on-the-fly based on discriminator fields. - ✨ **Guard-Based Field Exposure:** Generate route-specific response DTOs based on configured guard classes. - ✨ **🔐 Declarative Authorization Policies:** Subscriber-style policies with automatic guard wiring, scopes, and response transforms. - ✨ **Request Tracing:** Built-in `CorrelationIDResponseBodyInterceptor` to correlate requests and logs. - ✨ **Convention over Configuration:** Smart defaults for service and DTO naming to reduce boilerplate. ## 🛠 Installation Install NestJS-Crud-Automator using your preferred package manager: ```bash # Using npm npm install @elsikora/nestjs-crud-automator # Using yarn yarn add @elsikora/nestjs-crud-automator # Using pnpm pnpm add @elsikora/nestjs-crud-automator ``` Plain installation currently resolves the published 2.10 line. The source-tree examples describe the unreleased 3.0 contract. Do not treat registry `latest` as 3.0 until an exact candidate exists; pin that published version and follow the [3.0 migration guide](https://elsikora.com/docs/nestjs-crud-automator/guides/migrating-to-3-0). ### Prerequisites Make sure you have the following dependencies installed in your NestJS project: - NestJS `@nestjs/common` and `@nestjs/core` `>=11.1.24` - `@nestjs/passport` `>=11.0.5` - `@nestjs/platform-fastify` `>=11.1.24` - `@nestjs/swagger` `>=11.0.3` - `@nestjs/throttler` `>=6.5.0` - TypeORM `>=0.3.20` - Fastify `>=5.8.5` - `class-validator` `>=0.15.1` - `class-transformer` `>=0.5.1` - `lodash` `>=4.18.1` You might need to install these peer dependencies if they're not already in your project: ```bash npm install @nestjs/common @nestjs/core @nestjs/passport @nestjs/platform-fastify @nestjs/swagger @nestjs/throttler typeorm class-transformer class-validator fastify lodash ``` ## 💡 Usage ## Basic Usage ### 1. Define Your Entity First, define your entity with the `ApiPropertyDescribe` decorators to provide metadata for CRUD generation: ```typescript import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; import { ApiPropertyDescribe, EApiPropertyDescribeType, EApiPropertyStringType, EApiPropertyDateIdentifier, EApiPropertyDateType, GetDefaultStringFormatProperties } from "@elsikora/nestjs-crud-automator"; @Entity("users") export class UserEntity { @PrimaryGeneratedColumn("uuid") @ApiPropertyDescribe({ type: EApiPropertyDescribeType.UUID, description: "User unique identifier", }) id: string; @Column() @ApiPropertyDescribe({ type: EApiPropertyDescribeType.STRING, description: "User name", format: EApiPropertyStringType.STRING, minLength: 3, maxLength: 50, pattern: "/^[a-zA-Z0-9_-]+$/", exampleValue: "john_doe", }) username: string; @Column() @ApiPropertyDescribe({ type: EApiPropertyDescribeType.STRING, description: "User email", format: EApiPropertyStringType.EMAIL, ...GetDefaultStringFormatProperties(EApiPropertyStringType.EMAIL), }) email: string; @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) @ApiPropertyDescribe({ type: EApiPropertyDescribeType.DATE, identifier: EApiPropertyDateIdentifier.CREATED_AT, format: EApiPropertyDateType.DATE_TIME, }) createdAt: Date; @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP", onUpdate: "CURRENT_TIMESTAMP" }) @ApiPropertyDescribe({ type: EApiPropertyDescribeType.DATE, identifier: EApiPropertyDateIdentifier.UPDATED_AT, format: EApiPropertyDateType.DATE_TIME, }) updatedAt: Date; } ``` `CREATED_AT`, `UPDATED_AT`, and `RECEIVED_AT` identify server-owned infrastructure timestamps. Generated CREATE, UPDATE, and PARTIAL_UPDATE body DTOs omit them while response DTOs retain them. Exclusion follows the semantic identifier, not the property name; use `DATE` for a writable business date. ### 2. Create a Service Create a service with the `ApiService` decorator to add CRUD operations: ```typescript import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { ApiService, ApiServiceBase } from "@elsikora/nestjs-crud-automator"; import { UserEntity } from "./user.entity"; @Injectable() @ApiService<UserEntity>({ entity: UserEntity, }) export class UserService extends ApiServiceBase<UserEntity> { constructor( @InjectRepository(UserEntity) public repository: Repository<UserEntity>, ) { super(); } // You can add custom methods here that go beyond basic CRUD async findByEmail(email: string): Promise<UserEntity | null> { return this.repository.findOne({ where: { email } }); } } ``` ### 3. Create a Controller Create a controller with the `ApiController` decorator to generate all CRUD endpoints: ```typescript import { ApiController, EApiAuthenticationType, EApiRouteType } from "@elsikora/nestjs-crud-automator"; import { UserEntity } from "./user.entity"; import { UserService } from "./user.service"; import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; @ApiController<UserEntity>({ entity: UserEntity, name: "Users", path: "users", routes: { [EApiRouteType.CREATE]: { security: { authentication: { type: EApiAuthenticationType.USER, guard: JwtAuthGuard, securityRequirements: [ { bearerStrategies: ["jwt"], }, ], }, }, response: { headers: { "X-Request-Id": { description: "Request correlation id.", schema: { type: "string" }, }, }, }, }, [EApiRouteType.UPDATE]: { security: { authentication: { type: EApiAuthenticationType.USER, guard: JwtAuthGuard, securityRequirements: [ { bearerStrategies: ["jwt"], }, ], }, }, }, [EApiRouteType.DELETE]: { security: { authentication: { type: EApiAuthenticationType.USER, guard: JwtAuthGuard, securityRequirements: [ { bearerStrategies: ["jwt"], }, ], }, }, }, [EApiRouteType.PARTIAL_UPDATE]: {}, [EApiRouteType.GET]: {}, [EApiRouteType.GET_LIST]: {}, }, }) export class UserController { constructor(public service: UserService) {} } ``` Use `response.headers` for Swagger/OpenAPI response header documentation only. Authentication `securityRequirements` map to OpenAPI route requirements: one object means all listed schemes are required together, while multiple objects are alternatives. The scheme names must match the names registered in your app-owned `DocumentBuilder` configuration. Top-level `authentication.bearerStrategies` and `authentication.securityStrategies` are not supported; put scheme names inside `authentication.securityRequirements`. ## Advanced Usage ### Custom Validation Add custom validators to your DTOs: ```typescript import { ApiController, EApiRouteType, EApiDtoType, AllOrNoneOfListedPropertiesValidator } from "@elsikora/nestjs-crud-automator"; @ApiController<UserEntity>({ entity: UserEntity, name: "Users", routes: { [EApiRouteType.CREATE]: { autoDto: { [EApiDtoType.BODY]: { validators: [ { constraintClass: AllOrNoneOfListedPropertiesValidator, options: ["firstName", "lastName"], }, ], }, }, }, }, }) export class UserController { constructor(public service: UserService) {} } ``` ### Request Transformation Automatically transform request data: ```typescript import { ApiController, EApiControllerRequestTarget, EApiControllerRequestTransformerType, EApiRouteType, TRANSFORMER_VALUE_DTO_CONSTANT } from "@elsikora/nestjs-crud-automator"; @ApiController<UserEntity>({ entity: UserEntity, name: "Users", routes: { [EApiRouteType.CREATE]: { request: { [EApiControllerRequestTarget.BODY]: { transformers: [ { key: "createdBy", type: EApiControllerRequestTransformerType.DYNAMIC, value: TRANSFORMER_VALUE_DTO_CONSTANT.AUTHORIZED_ENTITY, shouldSetValueEvenIfMissing: true, }, ], }, }, }, }, }) export class UserController { constructor(public service: UserService) {} } ``` ### Handling Relations Configure include-driven relation loading: ```typescript import { ApiController, EApiControllerRelationReferenceShape, EApiFunctionTransactionMode, EApiRouteType } from "@elsikora/nestjs-crud-automator"; @ApiController<PostEntity>({ entity: PostEntity, name: "Posts", routes: { [EApiRouteType.CREATE]: { transaction: { mode: EApiFunctionTransactionMode.REQUIRED }, relations: { request: { reference: { shape: EApiControllerRelationReferenceShape.SCALAR }, load: { include: { author: true }, locks: { author: { mode: "pessimistic_read" }, }, }, }, response: { reference: { shape: EApiControllerRelationReferenceShape.OBJECT, key: "id" }, load: { include: { author: true, comments: true }, relationLoadStrategy: "query", }, }, }, }, }, }) export class PostController { constructor( public service: PostService, public authorService: UserService, ) {} } ``` Request relation `relations.request.load` enables hydration, and `relations.request.load.include` selects the direct request body relations to hydrate. Omitted service mappings use `${relationName}Service`; `relations.request.load.services` only overrides those controller property names. Nested request include objects are passed to the direct relation service as TypeORM `relations`; nested request references are not recursively hydrated. `load.relationLoadStrategy` can be used on request or response load configs to choose TypeORM `"join"` or `"query"` loading. `relations.request.load.locks` applies native TypeORM `pessimistic_read` or `pessimistic_write` locks to configured direct relation service `get()` calls. Locks require an active Automator transaction and are acquired in `load.include` declaration order. Locked loads disable implicit TypeORM eager-relation joins. A locked direct relation with explicit nested includes must use `relationLoadStrategy: "query"` so the nested loads share the manager without inheriting the row lock. Service inputs remain entity-based: scalar relation references are an HTTP/controller hydration contract, not a widened service method contract. ### Custom DTOs Use custom DTOs instead of auto-generated ones: ```typescript import { ApiController, EApiDtoType, EApiRouteType } from "@elsikora/nestjs-crud-automator"; import { CreateUserDto } from "./dto/create-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; import { UserResponseDto } from "./dto/user-response.dto"; @ApiController<UserEntity>({ entity: UserEntity, name: "Users", routes: { [EApiRouteType.CREATE]: { dto: { [EApiDtoType.BODY]: CreateUserDto, [EApiDtoType.RESPONSE]: UserResponseDto, }, }, [EApiRouteType.UPDATE]: { dto: { [EApiDtoType.BODY]: UpdateUserDto, [EApiDtoType.RESPONSE]: UserResponseDto, }, }, }, }) export class UserController { constructor(public service: UserService) {} } ``` For `GET_LIST`, response DTOs support two explicit modes: - `[EApiDtoType.RESPONSE]: PublicUserListResponseDto` when the app owns the whole list wrapper. - `[EApiDtoType.RESPONSE]: { itemType: PublicUserResponseDto }` when the framework owns the list wrapper and the app owns each item shape. Use the item DTO mode when you only need to narrow each returned item: ```typescript import { Expose } from "class-transformer"; import { ApiController, EApiDtoType, EApiRouteType } from "@elsikora/nestjs-crud-automator"; class PublicUserResponseDto { @Expose() id!: string; @Expose() username!: string; } @ApiController<UserEntity>({ entity: UserEntity, routes: { [EApiRouteType.GET_LIST]: { dto: { [EApiDtoType.RESPONSE]: { itemType: PublicUserResponseDto, name: "PublicUserListResponseDto", }, }, }, }, }) export class UserController { constructor(public service: UserService) {} } ``` The generated response keeps `count`, `currentPage`, `items`, `totalCount`, and `totalPages`, while `items` are serialized with `PublicUserResponseDto`. ### Authorization Authorization now has two first-class modes: - `hooks`: auto-discovered `@ApiAuthorizationPolicy({ entity })` classes - `iam`: attachment/document-based IAM evaluation with optional boundaries `@ApiControllerSecurable()` is marker-only. It turns on the authorization pipeline, but mode selection and all authorization configuration live in `@ApiController({ authorization: ... })`. Each route uses exactly one mode, and route config can override the controller default with `routes[routeType].security.authorization.mode`. #### Runtime authorization actions `@ApiMethod(...)` uses one route action identity: `metadata.resource.action`. Generated CRUD routes also carry `metadata.route.type` for built-in CRUD/IAM mapping. Auto-generated CRUD routes receive built-in runtime actions automatically: - `create` - `delete` - `get` - `getList` - `partialUpdate` - `update` Custom secured routes should declare their own domain-specific action strings: ```typescript @ApiMethod<UserEntity>({ metadata: { resource: { action: "update.promote", entity: UserEntity, }, route: { method: RequestMethod.POST, path: ":id/promote", }, security: { authorization: { mode: EApiAuthorizationMode.HOOKS }, }, response: { status: HttpStatus.OK, type: UserResponseDto, }, }, }) public promote(@Param("id") id: string) { return this.service.promote(id); } ``` The same `metadata.resource.action` value is what hooks receive as `context.action` and what IAM turns into a namespaced action such as `admin:user:update.promote`. Use `@ApiRouteCustom(...)` when a custom controller route should also participate in the custom route runtime: request/response transformers, route subscribers, relation loading/projection, authorization result transforms, and response serialization. Custom route request relation hydration reads the method body payload; response relation reload requires a controller `service` extending `ApiServiceBase` and response values with an `id`. `@ApiMethod(...)` remains the low-level metadata/decorator composer. ```typescript @ApiRouteCustom<UserEntity>({ resource: { action: "update.promote", entity: UserEntity }, route: { method: RequestMethod.POST, path: ":id/promote" }, security: { authorization: { mode: EApiAuthorizationMode.HOOKS }, }, response: { status: HttpStatus.OK, type: UserResponseDto, serialization: { isEnabled: true }, }, }) public promote(@Param("id") id: string) { return this.service.promote(id); } ``` Custom routes also support flat root-level discriminated request bodies and responses. Use this when the entire body or response is a command/result union; keep using `ApiPropertyObject` when only a nested property is polymorphic. ```typescript @ApiRouteCustom<UserEntity>({ resource: { action: "registration.create", entity: UserEntity }, route: { method: RequestMethod.POST, path: "registrations" }, dto: { body: { type: [EmailRegistrationBodyDto, UsernameRegistrationBodyDto], validatorOptions: { skipMissingProperties: true, }, discriminator: { propertyName: "channel", shouldKeepDiscriminatorProperty: true, mapping: { email: EmailRegistrationBodyDto, username: UsernameRegistrationBodyDto, }, }, }, }, response: { status: HttpStatus.CREATED, type: [VerificationResponseDto, SessionResponseDto], discriminator: { propertyName: "mode", shouldKeepDiscriminatorProperty: true, mapping: { verification: VerificationResponseDto, session: SessionResponseDto, }, }, serialization: { isEnabled: true }, }, }) public register(@Body() body: EmailRegistrationBodyDto | UsernameRegistrationBodyDto) { return this.service.register(body); } ``` Swagger emits `oneOf` plus OpenAPI discriminator metadata for both sides. Request bodies are transformed and validated as the selected DTO variant before the controller method runs. Serialized responses select the declared response DTO variant by discriminator and still strip fields that are not exposed. Existing single DTO configs (`dto.body: SomeDto`, `response.type: SomeDto`, and `response.type: undefined`) and generated CRUD routes keep their existing behavior. Declare the discriminator field, such as `channel` or `mode`, on every variant DTO with the usual property decorators. If `shouldKeepDiscriminatorProperty` is `false`, make that DTO property optional so Swagger can still document the input contract while the transformed instance omits the field. Root-level discriminator selection runs before Nest can infer a concrete DTO class, so global `ValidationPipe` options are not applied automatically to the selected variant. Add `validatorOptions` or `transformOptions` to the discriminated body config when a route needs specific validation or transformation settings. Generated routes can opt into a route-owned transaction by reusing `EApiFunctionTransactionMode`: ```typescript import { ApiController, EApiFunctionTransactionMode, EApiRouteType } from "@elsikora/nestjs-crud-automator"; @ApiController<UserEntity>({ entity: UserEntity, routes: { [EApiRouteType.CREATE]: { transaction: { mode: EApiFunctionTransactionMode.REQUIRED }, }, }, }) export class UserController { constructor(public service: UserService) {} } ``` Omitted route transaction config and explicit `SUPPORTS` preserve generated-route behavior without opening a route transaction. `REQUIRED` opens a root transaction through the controller service repository when no Automator transaction is active; `MANDATORY` requires an active owner; `NONE` rejects an active transaction. Request transformation and validation run before a route-owned transaction. Request relation hydration, the generated service operation, and response relation reload then share one manager. When the route opens and owns the transaction, commit and post-commit lifecycle finish before response transformation, route-after subscribers, authorization result handling, and serialization; hydration, operation, or reload failure rolls back, while a later route-after failure cannot roll back the committed work. When `REQUIRED` joins an outer owner, that owner commits later. Custom routes continue to own transactions through custom functions or steps. Generated service functions and explicit function decorators use the same transaction modes without exposing `EntityManager` as a public method argument: ```typescript import { ApiService, ApiServiceBase, EApiFunctionTransactionMode, EApiFunctionType } from "@elsikora/nestjs-crud-automator"; @ApiService<UserEntity>({ entity: UserEntity, functions: { [EApiFunctionType.CREATE]: { transaction: { mode: EApiFunctionTransactionMode.REQUIRED }, }, }, }) export class UserService extends ApiServiceBase<UserEntity> {} ``` The `functions` map accepts generated function types (`CREATE`, `UPDATE`, `DELETE`, `GET`, `GET_LIST`, `GET_MANY`). Omitted entries keep the default `SUPPORTS` mode. `CUSTOM` is configured separately with `@ApiFunctionCustom`. ```typescript import { ApiFunctionCustom, EApiFunctionTransactionMode } from "@elsikora/nestjs-crud-automator"; @ApiFunctionCustom<UserEntity>({ action: "bulkPromote", entity: UserEntity, transaction: { mode: EApiFunctionTransactionMode.REQUIRED }, }) async bulkPromote(ids: Array<string>): Promise<Array<UserEntity>> { const context = this.getApiFunctionContext<UserEntity>(); return await Promise.all(ids.map(async (id) => await context.operations.update({ id }, { role: "admin" }))); } ``` Use `@ApiFunctionStep` for internal helper methods that need ApiFunction transaction semantics and context but should not become standalone custom actions. Steps can also be called directly when the selected transaction mode permits: ```typescript import { ApiFunctionStep, EApiFunctionTransactionMode } from "@elsikora/nestjs-crud-automator"; @ApiFunctionStep<UserEntity>({ entity: UserEntity, transaction: { mode: EApiFunctionTransactionMode.MANDATORY }, }) private async recordPromotionAudit(user: UserEntity): Promise<void> { const context = this.getApiFunctionStepContext<UserEntity>(); await context.repository.save(user); } ``` Steps do not dispatch function subscribers, create route metadata, or define Swagger/authorization action identities. Step context intentionally exposes only `eventManager`, `repository`, and `getRepository`; use `@ApiFunctionCustom` plus `getApiFunctionContext()` when you need `operations` or lifecycle hooks. `REQUIRED` opens one Automator-owned `QueryRunner` transaction when none exists. Nested `REQUIRED`, `MANDATORY`, and transactional `SUPPORTS` calls join the same manager and register ordered transaction events; only the outer route, function, or named scope owner commits, rolls back, and flushes post-transaction subscribers. `NONE` and nontransactional `SUPPORTS` do not produce post-transaction lifecycle work. Before `onBeforeUpdate` runs, `ApiFunctionUpdate` performs one ordinary decorated GET through the active transaction manager when present. Update subscribers receive the incoming patch in `context.result`, the active manager repository when a transaction exists (otherwise the service base repository) in `context.DATA.repository`, and a top-level detached and frozen snapshot in `context.DATA.currentEntity: Readonly<TEntity>`. The snapshot is shallow: nested relations, arrays, JSON, dates, buffers, and lazy values retain aliases to the internal loaded entity, so mutating them can affect persistence. Automator merges the subscriber-returned patch into its internal loaded entity and does not issue a second explicit GET. A missing row keeps the GET error lifecycle, skips `onBeforeUpdate`, and then enters the UPDATE error lifecycle. Use a named scope when application code, rather than a decorated function, must own the transaction: ```typescript import { ApiFunctionTransactionScope } from "@elsikora/nestjs-crud-automator"; await ApiFunctionTransactionScope.runWithDataSource(dataSource, { name: "register-user" }, async (entityManager) => { await userService.create(body); await entityManager.getRepository(AuditEntity).save(entry); }); ``` The callback receives the exact manager owned by the named scope. Decorated operations join it automatically; use the argument for transaction-bound raw repository or migration work. The scope name is trimmed and must be non-empty. `runWithEntityManager()` is join-only: it accepts only the manager already bound to an active Automator transaction and never opens a second transaction. Function subscribers can react once after the outer commit or rollback. The observed service must use `@ApiServiceObservable()`, and the subscriber must be registered as a Nest provider: ```typescript import type { IApiSubscriberFunctionTransactionContext } from "@elsikora/nestjs-crud-automator"; import { ApiFunctionSubscriber, ApiFunctionSubscriberBase } from "@elsikora/nestjs-crud-automator"; @ApiFunctionSubscriber({ entity: UserEntity }) export class UserSubscriber extends ApiFunctionSubscriberBase<UserEntity> { async onAfterCommit(context: IApiSubscriberFunctionTransactionContext): Promise<void> { await recordCommittedEventCount(context.DATA.matchedEvents.length); } async onAfterRollback(context: IApiSubscriberFunctionTransactionContext): Promise<void> { await discardPendingWork(context.DATA.transaction.id); } } ``` `context.DATA` is readonly and contains the transaction UUID, immutable outer owner, all ordered events, and only the events matched by that subscriber. Events contain identity and status only; arguments, request bodies, entities, and results are never retained. Steps appear in `events` with trace type `STEP`, but they do not select or independently invoke subscribers. Post-commit and post-rollback hooks run sequentially by subscriber priority and registration order. Hook failures do not stop later hooks. A committed transaction with failing hooks throws `ApiFunctionTransactionPostCommitException` with outcome `COMMITTED`; a failed database commit throws `ApiFunctionTransactionCommitUnknownOutcomeException` with outcome `UNKNOWN`; rollback handling failures throw `ApiFunctionTransactionRollbackException` while retaining the original operation error as the cause. ```typescript // app.module.ts import type { IApiAuthorizationPrincipal, IApiHookPermissionSource, IApiPolicyAttachmentSource, IApiPolicyDocumentSource, IApiResolvedPolicyAttachments } from "@elsikora/nestjs-crud-automator"; import { Module } from "@nestjs/common"; import { ApiAuthorizationModule, EApiAuthorizationCacheMode, EApiAuthorizationPrincipalType, EApiPolicyEffect, EApiPolicySourceType, AuthorizationResolveDefaultPrincipal } from "@elsikora/nestjs-crud-automator"; const hookPermissionSource: IApiHookPermissionSource = { async getPermissions(principal: IApiAuthorizationPrincipal): Promise<ReadonlyArray<string>> { const permissions = principal.claims?.permissions; return Array.isArray(permissions) ? permissions.filter((value): value is string => typeof value === "string") : []; }, }; const iamAttachmentSource: IApiPolicyAttachmentSource = { async getAttachments(principal): Promise<IApiResolvedPolicyAttachments> { return { attachments: [ { policyId: "user-items", principalId: principal.id, principalType: principal.type, }, ], boundaries: [], }; }, }; const iamDocumentSource: IApiPolicyDocumentSource = { async getDocumentsByIds(ids) { return ids.map((id) => ({ document: { Statement: [ { Action: ["admin:item:list", "admin:item:read"], Condition: { StringEquals: { "resource.operatorId": "operator-1", }, }, Effect: EApiPolicyEffect.ALLOW, Resource: ["gameport:admin:item/*"], Sid: "AllowOperatorItems", }, ], Version: "2012-10-17", }, id, namespace: "admin:item", sourceType: EApiPolicySourceType.MANAGED, version: "2026-03-14", })); }, }; @Module({ imports: [ ApiAuthorizationModule.forRoot({ cache: { mode: EApiAuthorizationCacheMode.SOURCE_FIRST, }, hookPermissionSources: [hookPermissionSource], iam: { attachmentSources: [iamAttachmentSource], documentSources: [iamDocumentSource], }, principalResolver: { resolve(user) { if (!user || typeof user !== "object" || !("account" in user)) { return AuthorizationResolveDefaultPrincipal(user); } const payload = user as { account: { id: string; operatorId: string }; access: { permissions: Array<string>; roles: Array<string> }; }; return { attributes: { operatorId: payload.account.operatorId }, claims: { permissions: payload.access.permissions }, id: payload.account.id, roles: payload.access.roles, type: EApiAuthorizationPrincipalType.USER, }; }, }, }), ], }) export class AppModule {} ``` Authorization resolver caches default to `EApiAuthorizationCacheMode.SOURCE_FIRST`, so every hooks permission, IAM attachment, and IAM document evaluation reads its configured authoritative sources. This mode does not read or populate cross-request resolver maps, and source errors propagate without a stale cached fallback. The explicit setting above is optional, but documents the intended multi-instance contract. `MEMORY` is an explicit single-process optimization. It requires both a positive `ttlMs` and `maxEntries`; the limit applies independently to the hook permission, IAM attachment, and IAM document cache: ```typescript ApiAuthorizationModule.forRoot({ cache: { maxEntries: 1_000, mode: EApiAuthorizationCacheMode.MEMORY, ttlMs: 30_000, }, // sources... }); ``` Memory entries expire from insertion time, the oldest entry is evicted at the configured limit, and backing-data changes still require `ApiAuthorizationCacheInvalidationService` when immediate visibility is needed. In-memory mode is local to one application process; it is not a multi-instance consistency mechanism. Hooks policy-rule caching remains a separate, default-disabled `ApiAuthorizationPolicyRegistry` option. Use `ApiAuthorizationModule.forRootAsync(...)` when the resolver or IAM sources must be real Nest providers with `Repository`, `DataSource`, or service dependencies. The module supports `imports`, `inject`, `useFactory`, `useClass`, and `useExisting`. ```typescript // authorization.module.ts import { Injectable, Module } from "@nestjs/common"; import { ApiAuthorizationModule, AuthorizationResolveDefaultPrincipal } from "@elsikora/nestjs-crud-automator"; @Injectable() class DbPrincipalResolver { resolve(user: unknown) { return AuthorizationResolveDefaultPrincipal(user); } } @Injectable() class DbAttachmentSource { // Inject Repository / DataSource / services here } @Injectable() class DbDocumentSource { // Inject Repository / DataSource / services here } @Module({ exports: [DbAttachmentSource, DbDocumentSource, DbPrincipalResolver], providers: [DbAttachmentSource, DbDocumentSource, DbPrincipalResolver], }) class AuthorizationSourcesModule {} @Module({ imports: [ AuthorizationSourcesModule, ApiAuthorizationModule.forRootAsync({ imports: [AuthorizationSourcesModule], inject: [DbAttachmentSource, DbDocumentSource, DbPrincipalResolver], useFactory: (attachmentSource, documentSource, principalResolver) => ({ iam: { attachmentSources: [attachmentSource], documentSources: [documentSource], }, principalResolver, }), }), ], }) export class AppModule {} ``` Use the controller `authorization` block to pick the mode: ```typescript // user.controller.ts import { EApiAuthorizationMode, ApiController, ApiControllerSecurable } from "@elsikora/nestjs-crud-automator"; @ApiControllerSecurable() @ApiController<UserEntity>({ authorization: { defaultMode: EApiAuthorizationMode.HOOKS, }, entity: UserEntity, path: "users", routes: {}, }) export class UserController { constructor(public service: UserService) {} } ``` Hooks mode keeps entity-based policy autodiscovery: ```typescript // policies/user-hooks.policy.ts import type { IApiAuthorizationRuleContext, IApiAuthorizationScope, TApiAuthorizationPolicyBeforeGetListContext, TApiAuthorizationPolicyBeforeUpdateContext } from "@elsikora/nestjs-crud-automator"; import { EApiAuthorizationPermissionMatch, ApiAuthorizationPolicy, ApiAuthorizationPolicyBase } from "@elsikora/nestjs-crud-automator"; @ApiAuthorizationPolicy<UserEntity>({ entity: UserEntity, priority: 200 }) export class UserHooksPolicy extends ApiAuthorizationPolicyBase<UserEntity> { private getOperatorId(principal: IApiAuthorizationRuleContext<UserEntity>["principal"]): string | undefined { return principal.attributes.operatorId as string | undefined; } private scopeToOperator(context: IApiAuthorizationRuleContext<UserEntity>): IApiAuthorizationScope<UserEntity> { return { where: { operatorId: this.getOperatorId(context.principal), }, }; } public onBeforeGetList(context: TApiAuthorizationPolicyBeforeGetListContext<UserEntity>) { const requestedOperatorId = context.query.operatorId as string | undefined; if (requestedOperatorId && requestedOperatorId !== this.getOperatorId(context.principal)) { return []; } return [ ...this.allowForRoles(["platform-admin"]), ...this.allowForPermissions(["admin.user.read", "admin.user.list"], { match: EApiAuthorizationPermissionMatch.ALL, scope: (ruleContext: IApiAuthorizationRuleContext<UserEntity>): IApiAuthorizationScope<UserEntity> => this.scopeToOperator(ruleContext), }), ]; } public onBeforeUpdate(_context: TApiAuthorizationPolicyBeforeUpdateContext<UserEntity>) { return [ ...this.allowForPermissions(["admin.user.update"], { scope: (ruleContext: IApiAuthorizationRuleContext<UserEntity>): IApiAuthorizationScope<UserEntity> => this.scopeToOperator(ruleContext), }), ...this.denyForPermissions(["admin.user.update"], { condition: ({ principal }: IApiAuthorizationRuleContext<UserEntity>): boolean => Boolean(principal.attributes.isOperatorLocked), priority: 1000, }), ]; } } ``` Register hooks policies as Nest providers; discovery only sees provider instances: ```typescript @Module({ providers: [UserHooksPolicy], }) export class UserModule {} ``` Generated CRUD routes dispatch to CRUD hooks such as `onBeforeGetList` or `onBeforeUpdate` using the internal `routeType`. Custom `@ApiMethod(...)` routes do not use CRUD hook names; handle them in `getCustomActionRule(action, context)` instead: ```typescript public getCustomActionRule(action: string) { if (action === "update.promote") { return this.allowForPermissions(["admin.user.promote"]); } return []; } ``` IAM mode stays storage-agnostic. Attachments and documents come from your configured sources, while the controller defines the resource model used for action/resource matching and safe query planning: ```typescript @ApiControllerSecurable() @ApiController<ItemEntity>({ authorization: { defaultMode: EApiAuthorizationMode.IAM, policyNamespace: "admin:item", resourceDefinition: { entity: ItemEntity, fields: [ { isFilterable: true, path: "resource.id", queryPath: "id", }, { isFilterable: true, path: "resource.operatorId", queryPath: "operator.id", }, ], namespace: "admin:item", resourcePath: "gameport:admin:item/{id}", resourceType: "gameport:admin:item", }, }, entity: ItemEntity, path: "items", routes: {}, }) export class ItemController { constructor(public service: ItemService) {} } ``` Generated CRUD actions are normalized to IAM-friendly names inside the configured namespace: - `get` -> `<policyNamespace>:read` - `getList` -> `<policyNamespace>:list` - `create` -> `<policyNamespace>:create` - `update` / `partialUpdate` -> `<policyNamespace>:update` - `delete` -> `<policyNamespace>:delete` Custom `@ApiMethod(...)` actions pass through unchanged after the namespace. For example, `resource.action: "update.promote"` becomes `admin:item:update.promote` when `policyNamespace` is `admin:item`. The runtime resolves a `principal`, dispatches to the selected mode, and stores a unified `authorizationDecision` on the request. Hooks mode traces matched rules and resolved permissions; IAM mode traces attachments, documents, statements, boundaries, and final decision type. For out-of-band checks, inject `ApiAuthorizationSimulator` and call `evaluate(...)` with the same controller authorization metadata you use at runtime. Important IAM details from the current implementation: - `resource.id` and `resource.operatorId` are safe planner-friendly paths for `GET` and `GET_LIST` when declared in `resourceDefinition.fields` - `queryPath` may be nested, for example `operator.id`, when your repository where-shape uses relations - route filters and authorization scopes are merged with logical `AND`, not overwrite semantics - impossible conflicts collapse to a match-nothing branch instead of rewriting the requested filter - relation payloads can be raw UUID strings, so create/update conditions like `request.body.operator = "${principal.attributes.operatorId}"` work without hooks fallback - `{id}` placeholders belong to `resourceDefinition.resourcePath`; policy document `Resource` entries should use concrete strings or wildcards such as `gameport:admin:item/*` ### `CorrelationIDResponseBodyInterceptor`: Request Tracing To simplify debugging and request tracing in complex systems, the library provides the `CorrelationIDResponseBodyInterceptor`. This interceptor should be registered globally in your `main.ts`. **What it does:** 1. Intercepts all exceptions in the application (`HttpException` and others). 2. Looks for the `x-correlation-id` header in the incoming request headers. 3. If the header is found, its value is added to the body of the error response. 4. If the header is not found, a new `UUID` is generated, which is added to both the response and the logs (if `LoggerUtility` is used). 5. Adds a `timestamp` field to the error response body. This allows you to link a specific client request with the logs on the server, which is invaluable when investigating incidents. **Registration:** `main.ts` ```typescript import { CorrelationIDResponseBodyInterceptor } from "@elsikora/nestjs-crud-automator"; async function bootstrap() { const app = await NestFactory.create(AppModule); // ... app.useGlobalInterceptors(new CorrelationIDResponseBodyInterceptor()); // ... await app.listen(3000); } ``` ### Subscriber System (Hooks): Intercepting and Extending Logic This is the most powerful feature for extending the default behavior. It allows you to "subscribe" to events in the CRUD request lifecycle and execute your code before, after, or in case of an error in the main operation. This is an ideal solution for tasks such as: - Auditing. - Sending notifications. - Complex, context-dependent validation. - Data enrichment before saving. - Custom error handling. #### Enabling the Subscriber System To get the subscriber system working, you need to follow **four mandatory steps**: 1. **Import `ApiSubscriberModule`**: This module provides the `ApiSubscriberDiscoveryService`, which is responsible for discovering your subscribers. You need to import it into the root module of your application. `app.module.ts` ```typescript import { ApiSubscriberModule } from "@elsikora/nestjs-crud-automator"; @Module({ imports: [ // ... other modules ApiSubscriberModule, // <--- IMPORTANT ], // ... }) export class AppModule {} ``` 2. **Make the controller "observable"**: Add the `@ApiControllerObservable()` decorator to the controller class whose events you want to monitor. ```typescript import { ApiController, ApiControllerObservable } from "@elsikora/nestjs-crud-automator"; @ApiController({ entity: PostEntity, path: "posts", routes: {}, }) @ApiControllerObservable() // <--- IMPORTANT export class PostController { /* ... */ } ``` 3. **Make the service "observable"**: Similarly, add the `@ApiServiceObservable()` decorator to the service class. ```typescript import { ApiService, ApiServiceBase, ApiServiceObservable } from "@elsikora/nestjs-crud-automator"; @Injectable() @ApiService({ entity: PostEntity, }) @ApiServiceObservable() // <--- IMPORTANT export class PostService extends ApiServiceBase<PostEntity> { /* ... */ } ``` 4. **Register subscriber classes as Nest providers**: discovery only sees provider instances. Without these steps, your subscriber classes will simply not be discovered and called. #### Two Levels of Interception There are two types of subscribers that operate at different levels of abstraction: 1. **`ApiRouteSubscriberBase`** (Controller Level): Intercepts data at the highest level. Ideal for working with the HTTP context: headers, IP address, authenticated user (`request.user`). The hooks of this subscriber are called before and after the main logic of the _controller_. 2. **`ApiFunctionSubscriberBase`** (Service Level): Intercepts decorated function execution, including the pre-UPDATE GET lifecycle and post-transaction completion when a transaction owner exists. Ideal for persistence-aware validation, enrichment, auditing, and confirmed-commit side effects. #### Lifecycle and Execution Order Understanding the order in which hooks are called is critically important: 1. **Incoming Request** 2. Route `onBefore...` hooks. 3. Request transformation, validation, and typed GET_LIST parsing. 4. Optional generated-route transaction resolution and request relation hydration. 5. Decorated function lifecycle: UPDATE first runs one decorated GET and exposes `currentEntity`, then function `onBefore...`, repository work, and function `onAfter...` run. 6. If the function opened the transaction, it commits and runs matching `onAfterCommit` before returning. A function that joined a route or scope owner leaves that outer transaction open. 7. Generated response reload when applicable; it remains inside the transaction only when the route owns that boundary. 8. If the route opened the transaction, it now commits and runs matching `onAfterCommit`. A joined scope owner commits later when its outer callback completes. 9. Response transformation, route `onAfter...`, authorization result handling, and serialization. 10. **The response is sent to the client.** Operation failures invoke the matching function or route error lifecycle, and an owning boundary performs confirmed rollback plus `onAfterRollback`. A post-commit hook failure is different: the database is already committed and Automator throws `ApiFunctionTransactionPostCommitException` with outcome `COMMITTED`, never a rollback result. #### Example 1: Auditing with `ApiRouteSubscriberBase` **Task**: Log which user created which post. 1. **Create the subscriber:** `post-audit.subscriber.ts` ```typescript import { Injectable } from "@nestjs/common"; import { ApiRouteSubscriber, ApiRouteSubscriberBase, TApiSubscriberRouteAfterCreateContext } from "@elsikora/nestjs-crud-automator"; import { Post } from "./post.entity"; @Injectable() @ApiRouteSubscriber({ entity: Post, priority: 10 }) export class PostAuditSubscriber extends ApiRouteSubscriberBase<Post> { // Hook is called AFTER a post is successfully created in the controller async onAfterCreate(context: TApiSubscriberRouteAfterCreateContext<Post>): Promise<Post> { const createdPost = context.result; // Fully typed access to authentication and request data const currentUser = context.DATA.authenticationRequest?.user as { id: string } | undefined; const clientIp = context.DATA.ip; if (createdPost && currentUser) { console.log(`AUDIT: User ${currentUser.id} created Post ${createdPost.id} ` + `with title "${createdPost.title}" from IP ${clientIp}`); } return createdPost; } } ``` For before-route subscribers that only observe routes guaranteed to be authenticated and authorized, opt into type-only authorization narrowing: ```typescript import { ApiRouteSubscriber, ApiRouteSubscriberBase, EApiRouteSubscriberAuthorizationExpectation, TApiSubscriberRouteBeforeCreateContext } from "@elsikora/nestjs-crud-automator"; @ApiRouteSubscriber({ entity: Post, authorization: { expectation: EApiRouteSubscriberAuthorizationExpectation.REQUIRED }, }) export class PostBeforeAuditSubscriber extends ApiRouteSubscriberBase<Post, EApiRouteSubscriberAuthorizationExpectation.REQUIRED> { async onBeforeCreate(context: TApiSubscriberRouteBeforeCreateContext<Post, EApiRouteSubscriberAuthorizationExpectation.REQUIRED>): Promise<TApiSubscriberRouteBeforeCreateContext<Post, EApiRouteSubscriberAuthorizationExpectation.REQUIRED>["result"]> { const principal = context.result.authenticationRequest.authorizationDecision.principal; console.log(`AUDIT: Principal ${principal.id} is creating a post`); return context.result; } } ``` This metadata does not add runtime guards; it only narrows `authenticationRequest.authorizationDecision` in the matching route before-context types. 2. **Register the subscriber:** Add `PostAuditSubscriber` to the `providers` of your module. #### Example 2: Data Enrichment with `ApiFunctionSubscriberBase` **Task**: When creating a post, automatically generate a `slug` from the `title` before saving it to the database. 1. **Create the subscriber:** `post-slug.subscriber.ts` ```typescript import { Injectable } from "@nestjs/common"; import { ApiFunctionSubscriber, ApiFunctionSubscriberBase, TApiSubscriberFunctionBeforeCreateContext, TApiFunctionCreateProperties } from "@elsikora/nestjs-crud-automator"; import { Post } from "./post.entity"; import slugify from "slugify"; @Injectable() @ApiFunctionSubscriber({ entity: Post }) export class PostSlugSubscriber extends ApiFunctionSubscriberBase<Post> { // Hook is called BEFORE repository.save() is called async onBeforeCreate(context: TApiSubscriberFunctionBeforeCreateContext<Post>): Promise<TApiFunctionCreateProperties<Post>> { // Fully typed access to transaction manager and repository const manager = context.DATA.eventManager; const repository = context.DATA.repository; if (context.result.title) { // Modify the object, adding the slug context.result.slug = slugify(context.result.title, { lower: true, strict: true, }); console.log(`ENRICHMENT: Generated slug: ${context.result.slug}`); } // Return the modified object, which will be saved return context.result; } } ``` 2. **Register the subscriber:** Add `PostSlugSubscriber` to the module's `providers`. ```typescript // ... providers: [ UserService, PostService, PostSlugSubscriber, // <-- Register our subscriber as a provider ], // ... ``` #### Helper Types for Simplified Usage The library provides helper types that require only the Entity generic parameter, making subscriber implementation cleaner: ```typescript // Instead of this: IApiSubscriberFunctionExecutionContext<User, TApiFunctionCreateProperties<User>, IApiSubscriberFunctionExecutionContextData<User>>; // Use this: TApiSubscriberFunctionBeforeCreateContext<User>; ``` Before function helper types also accept an optional `Result` generic for advanced subscribers that work with a stricter application-defined service payload. Define this payload type yourself to match the service input shape; the generic only narrows TypeScript's `context.result` type and does not change validation, transformation, or runtime