adorn-api 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -491,6 +491,80 @@ export class UserController {
491
491
 
492
492
  ### Migration Guide (Breaking)
493
493
 
494
+ ### CRUD Controller Factory (`createCrudController`)
495
+
496
+ When your controller only wires DTOs + service calls, you can generate the full CRUD controller and remove decorator boilerplate.
497
+
498
+ ```typescript
499
+ // user.controller.ts
500
+ import { createCrudController } from "adorn-api";
501
+ import { userCrudDtos } from "./user.dtos";
502
+ import { UserCrudService } from "./user.service";
503
+
504
+ export const UserController = createCrudController({
505
+ path: "/users",
506
+ service: UserCrudService, // class or instance
507
+ dtos: userCrudDtos, // result of createMetalCrudDtoClasses(...)
508
+ entityName: "User", // used by parseIdOrThrow messages
509
+ withOptionsRoute: true,
510
+ withReplace: true,
511
+ withPatch: true,
512
+ withDelete: true
513
+ });
514
+ ```
515
+
516
+ Generated routes:
517
+ - `GET /`
518
+ - `GET /options` (optional)
519
+ - `GET /:id`
520
+ - `POST /`
521
+ - `PUT /:id` (optional)
522
+ - `PATCH /:id` (optional)
523
+ - `DELETE /:id` (optional)
524
+
525
+ The factory applies the correct `@Query/@Body/@Params/@Returns` schemas and also propagates `dtos.errors` to all `/:id` routes.
526
+
527
+ Before (manual, repeated decorators/status/schema wiring):
528
+
529
+ ```typescript
530
+ @Controller("/users")
531
+ class UserController {
532
+ @Get("/")
533
+ @Query(UserQueryDto)
534
+ @Returns(UserPagedResponseDto)
535
+ async list(ctx: RequestContext<unknown, UserQueryDto>) { ... }
536
+
537
+ @Get("/:id")
538
+ @Params(UserParamsDto)
539
+ @Returns(UserDto)
540
+ @UserErrors
541
+ async getById(ctx: RequestContext<unknown, undefined, UserParamsDto>) { ... }
542
+
543
+ @Post("/")
544
+ @Body(CreateUserDto)
545
+ @Returns({ status: 201, schema: UserDto })
546
+ async create(ctx: RequestContext<CreateUserDto>) { ... }
547
+
548
+ // put/patch/delete/options...
549
+ }
550
+ ```
551
+
552
+ After (factory + service):
553
+
554
+ ```typescript
555
+ export const UserController = createCrudController({
556
+ path: "/users",
557
+ service: new UserCrudService(),
558
+ dtos: userCrudDtos,
559
+ entityName: "User"
560
+ });
561
+ ```
562
+
563
+ When to use factory vs manual controller:
564
+ - Use `createCrudController` when routes follow standard CRUD and behavior lives in a service.
565
+ - Use a manual controller when route contracts diverge (custom status/body shape, non-standard params, upload/stream/raw endpoints, or route-level auth/doc decorators not shared by all CRUD routes).
566
+ - For extra endpoints, keep the generated CRUD controller and add a second manual controller for custom routes on the same base path.
567
+
494
568
  Before (duplicated config):
495
569
 
496
570
  ```typescript
@@ -0,0 +1,16 @@
1
+ import type { DtoConstructor } from "../../core/types";
2
+ import type { RequestContext } from "../express/types";
3
+ import type { CreateCrudControllerOptions, MetalCrudDtoClasses } from "./types";
4
+ type DtoInstance<TDto extends DtoConstructor> = InstanceType<TDto>;
5
+ export declare function createCrudController<TDtos extends MetalCrudDtoClasses<any>>(options: CreateCrudControllerOptions<TDtos>): {
6
+ new (): {
7
+ list(ctx: RequestContext<unknown, DtoInstance<TDtos["queryDto"]>>): Promise<DtoInstance<TDtos["pagedResponseDto"]>>;
8
+ options(ctx: RequestContext<unknown, DtoInstance<TDtos["optionsQueryDto"]>>): Promise<DtoInstance<TDtos["optionsDto"]>>;
9
+ getById(ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>): Promise<DtoInstance<TDtos["response"]>>;
10
+ create(ctx: RequestContext<DtoInstance<TDtos["create"]>>): Promise<DtoInstance<TDtos["response"]>>;
11
+ replace(ctx: RequestContext<DtoInstance<TDtos["replace"]>, undefined, DtoInstance<TDtos["params"]>>): Promise<DtoInstance<TDtos["response"]>>;
12
+ update(ctx: RequestContext<DtoInstance<TDtos["update"]>, undefined, DtoInstance<TDtos["params"]>>): Promise<DtoInstance<TDtos["response"]>>;
13
+ delete(ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>): Promise<void>;
14
+ };
15
+ };
16
+ export {};
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
3
+ var useValue = arguments.length > 2;
4
+ for (var i = 0; i < initializers.length; i++) {
5
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
6
+ }
7
+ return useValue ? value : void 0;
8
+ };
9
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
10
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
11
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
12
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
13
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
14
+ var _, done = false;
15
+ for (var i = decorators.length - 1; i >= 0; i--) {
16
+ var context = {};
17
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
18
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
19
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
20
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
21
+ if (kind === "accessor") {
22
+ if (result === void 0) continue;
23
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
24
+ if (_ = accept(result.get)) descriptor.get = _;
25
+ if (_ = accept(result.set)) descriptor.set = _;
26
+ if (_ = accept(result.init)) initializers.unshift(_);
27
+ }
28
+ else if (_ = accept(result)) {
29
+ if (kind === "field") initializers.unshift(_);
30
+ else descriptor[key] = _;
31
+ }
32
+ }
33
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
34
+ done = true;
35
+ };
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.createCrudController = createCrudController;
38
+ const decorators_1 = require("../../core/decorators");
39
+ const utils_1 = require("./utils");
40
+ function createCrudController(options) {
41
+ const withOptionsRoute = options.withOptionsRoute ?? true;
42
+ const withReplace = options.withReplace ?? true;
43
+ const withPatch = options.withPatch ?? true;
44
+ const withDelete = options.withDelete ?? true;
45
+ const service = resolveService(options.service);
46
+ const routeErrorsDecorator = options.dtos.errors;
47
+ let GeneratedCrudController = (() => {
48
+ let _classDecorators = [(0, decorators_1.Controller)({ path: options.path, tags: options.tags })];
49
+ let _classDescriptor;
50
+ let _classExtraInitializers = [];
51
+ let _classThis;
52
+ let _instanceExtraInitializers = [];
53
+ let _list_decorators;
54
+ let _options_decorators;
55
+ let _getById_decorators;
56
+ let _create_decorators;
57
+ let _replace_decorators;
58
+ let _update_decorators;
59
+ let _delete_decorators;
60
+ var GeneratedCrudController = class {
61
+ static { _classThis = this; }
62
+ static {
63
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
64
+ _list_decorators = [(0, decorators_1.Get)("/"), (0, decorators_1.Query)(options.dtos.queryDto), (0, decorators_1.Returns)(options.dtos.pagedResponseDto)];
65
+ _options_decorators = [when(withOptionsRoute, (0, decorators_1.Get)("/options")), when(withOptionsRoute, (0, decorators_1.Query)(options.dtos.optionsQueryDto)), when(withOptionsRoute, (0, decorators_1.Returns)(options.dtos.optionsDto))];
66
+ _getById_decorators = [(0, decorators_1.Get)("/:id"), (0, decorators_1.Params)(options.dtos.params), (0, decorators_1.Returns)(options.dtos.response), applyRouteErrors(routeErrorsDecorator)];
67
+ _create_decorators = [(0, decorators_1.Post)("/"), (0, decorators_1.Body)(options.dtos.create), (0, decorators_1.Returns)({ status: 201, schema: options.dtos.response })];
68
+ _replace_decorators = [when(withReplace, (0, decorators_1.Put)("/:id")), when(withReplace, (0, decorators_1.Params)(options.dtos.params)), when(withReplace, (0, decorators_1.Body)(options.dtos.replace)), when(withReplace, (0, decorators_1.Returns)(options.dtos.response)), when(withReplace, applyRouteErrors(routeErrorsDecorator))];
69
+ _update_decorators = [when(withPatch, (0, decorators_1.Patch)("/:id")), when(withPatch, (0, decorators_1.Params)(options.dtos.params)), when(withPatch, (0, decorators_1.Body)(options.dtos.update)), when(withPatch, (0, decorators_1.Returns)(options.dtos.response)), when(withPatch, applyRouteErrors(routeErrorsDecorator))];
70
+ _delete_decorators = [when(withDelete, (0, decorators_1.Delete)("/:id")), when(withDelete, (0, decorators_1.Params)(options.dtos.params)), when(withDelete, (0, decorators_1.Returns)({ status: 204, description: "No Content" })), when(withDelete, applyRouteErrors(routeErrorsDecorator))];
71
+ __esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
72
+ __esDecorate(this, null, _options_decorators, { kind: "method", name: "options", static: false, private: false, access: { has: obj => "options" in obj, get: obj => obj.options }, metadata: _metadata }, null, _instanceExtraInitializers);
73
+ __esDecorate(this, null, _getById_decorators, { kind: "method", name: "getById", static: false, private: false, access: { has: obj => "getById" in obj, get: obj => obj.getById }, metadata: _metadata }, null, _instanceExtraInitializers);
74
+ __esDecorate(this, null, _create_decorators, { kind: "method", name: "create", static: false, private: false, access: { has: obj => "create" in obj, get: obj => obj.create }, metadata: _metadata }, null, _instanceExtraInitializers);
75
+ __esDecorate(this, null, _replace_decorators, { kind: "method", name: "replace", static: false, private: false, access: { has: obj => "replace" in obj, get: obj => obj.replace }, metadata: _metadata }, null, _instanceExtraInitializers);
76
+ __esDecorate(this, null, _update_decorators, { kind: "method", name: "update", static: false, private: false, access: { has: obj => "update" in obj, get: obj => obj.update }, metadata: _metadata }, null, _instanceExtraInitializers);
77
+ __esDecorate(this, null, _delete_decorators, { kind: "method", name: "delete", static: false, private: false, access: { has: obj => "delete" in obj, get: obj => obj.delete }, metadata: _metadata }, null, _instanceExtraInitializers);
78
+ __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
79
+ GeneratedCrudController = _classThis = _classDescriptor.value;
80
+ if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
81
+ __runInitializers(_classThis, _classExtraInitializers);
82
+ }
83
+ async list(ctx) {
84
+ return await service.list(ctx);
85
+ }
86
+ async options(ctx) {
87
+ assertServiceMethod(service, "options");
88
+ return await service.options(ctx);
89
+ }
90
+ async getById(ctx) {
91
+ const id = parseContextId(ctx, options.entityName);
92
+ return await service.getById(id, ctx);
93
+ }
94
+ async create(ctx) {
95
+ return await service.create(ctx.body, ctx);
96
+ }
97
+ async replace(ctx) {
98
+ assertServiceMethod(service, "replace");
99
+ const id = parseContextId(ctx, options.entityName);
100
+ return await service.replace(id, ctx.body, ctx);
101
+ }
102
+ async update(ctx) {
103
+ assertServiceMethod(service, "update");
104
+ const id = parseContextId(ctx, options.entityName);
105
+ return await service.update(id, ctx.body, ctx);
106
+ }
107
+ async delete(ctx) {
108
+ assertServiceMethod(service, "delete");
109
+ const id = parseContextId(ctx, options.entityName);
110
+ await service.delete(id, ctx);
111
+ }
112
+ constructor() {
113
+ __runInitializers(this, _instanceExtraInitializers);
114
+ }
115
+ };
116
+ return GeneratedCrudController = _classThis;
117
+ })();
118
+ Object.defineProperty(GeneratedCrudController, "name", {
119
+ value: `${options.entityName}CrudController`,
120
+ configurable: true
121
+ });
122
+ return GeneratedCrudController;
123
+ }
124
+ function resolveService(input) {
125
+ if (typeof input === "function") {
126
+ return new input();
127
+ }
128
+ return input;
129
+ }
130
+ function when(enabled, decorator) {
131
+ return (value, context) => {
132
+ if (enabled) {
133
+ decorator(value, context);
134
+ }
135
+ };
136
+ }
137
+ function applyRouteErrors(decorator) {
138
+ return (value, context) => {
139
+ decorator?.(value, context);
140
+ };
141
+ }
142
+ function parseContextId(ctx, entityName) {
143
+ const params = (ctx.params ?? {});
144
+ return (0, utils_1.parseIdOrThrow)(toIdValue(params.id), entityName);
145
+ }
146
+ function toIdValue(value) {
147
+ if (typeof value === "string" || typeof value === "number") {
148
+ return value;
149
+ }
150
+ return "";
151
+ }
152
+ function assertServiceMethod(service, method) {
153
+ if (typeof service[method] !== "function") {
154
+ throw new Error(`CRUD service is missing "${method}" method required by enabled route.`);
155
+ }
156
+ }
@@ -4,9 +4,10 @@ export { parseFilter, createFilterMappings } from "./filters";
4
4
  export { parseSort } from "./sort";
5
5
  export { createPagedQueryDtoClass, createPagedResponseDtoClass, createPagedFilterQueryDtoClass } from "./paged-dtos";
6
6
  export { createMetalCrudDtos, createMetalCrudDtoClasses, createNestedCreateDtoClass } from "./crud-dtos";
7
+ export { createCrudController } from "./crud-controller";
7
8
  export { createMetalTreeDtoClasses } from "./tree-dtos";
8
9
  export { createMetalDtoOverrides, type CreateMetalDtoOverridesOptions } from "./convention-overrides";
9
10
  export { createErrorDtoClass, StandardErrorDto, SimpleErrorDto, BasicErrorDto } from "./error-dtos";
10
11
  export { withSession, parseIdOrThrow, compactUpdates, applyInput, getEntityOrThrow } from "./utils";
11
12
  export { validateEntityMetadata, hasValidEntityMetadata } from "./field-builder";
12
- export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
13
+ export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, CrudControllerService, CrudControllerServiceInput, CreateCrudControllerOptions, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.hasValidEntityMetadata = exports.validateEntityMetadata = exports.getEntityOrThrow = exports.applyInput = exports.compactUpdates = exports.parseIdOrThrow = exports.withSession = exports.BasicErrorDto = exports.SimpleErrorDto = exports.StandardErrorDto = exports.createErrorDtoClass = exports.createMetalDtoOverrides = exports.createMetalTreeDtoClasses = exports.createNestedCreateDtoClass = exports.createMetalCrudDtoClasses = exports.createMetalCrudDtos = exports.createPagedFilterQueryDtoClass = exports.createPagedResponseDtoClass = exports.createPagedQueryDtoClass = exports.parseSort = exports.createFilterMappings = exports.parseFilter = exports.parsePagination = exports.MetalDto = void 0;
3
+ exports.hasValidEntityMetadata = exports.validateEntityMetadata = exports.getEntityOrThrow = exports.applyInput = exports.compactUpdates = exports.parseIdOrThrow = exports.withSession = exports.BasicErrorDto = exports.SimpleErrorDto = exports.StandardErrorDto = exports.createErrorDtoClass = exports.createMetalDtoOverrides = exports.createMetalTreeDtoClasses = exports.createCrudController = exports.createNestedCreateDtoClass = exports.createMetalCrudDtoClasses = exports.createMetalCrudDtos = exports.createPagedFilterQueryDtoClass = exports.createPagedResponseDtoClass = exports.createPagedQueryDtoClass = exports.parseSort = exports.createFilterMappings = exports.parseFilter = exports.parsePagination = exports.MetalDto = void 0;
4
4
  // Ensure standard decorator metadata is available for metal-orm transformers.
5
5
  const symbolMetadata = Symbol.metadata;
6
6
  if (!symbolMetadata) {
@@ -23,6 +23,8 @@ var crud_dtos_1 = require("./crud-dtos");
23
23
  Object.defineProperty(exports, "createMetalCrudDtos", { enumerable: true, get: function () { return crud_dtos_1.createMetalCrudDtos; } });
24
24
  Object.defineProperty(exports, "createMetalCrudDtoClasses", { enumerable: true, get: function () { return crud_dtos_1.createMetalCrudDtoClasses; } });
25
25
  Object.defineProperty(exports, "createNestedCreateDtoClass", { enumerable: true, get: function () { return crud_dtos_1.createNestedCreateDtoClass; } });
26
+ var crud_controller_1 = require("./crud-controller");
27
+ Object.defineProperty(exports, "createCrudController", { enumerable: true, get: function () { return crud_controller_1.createCrudController; } });
26
28
  var tree_dtos_1 = require("./tree-dtos");
27
29
  Object.defineProperty(exports, "createMetalTreeDtoClasses", { enumerable: true, get: function () { return tree_dtos_1.createMetalTreeDtoClasses; } });
28
30
  var convention_overrides_1 = require("./convention-overrides");
@@ -2,6 +2,7 @@ import type { BelongsToReference, HasManyCollection, HasOneReference, ManyToMany
2
2
  import type { DtoOptions, ErrorResponseOptions, FieldOverride } from "../../core/decorators";
3
3
  import type { SchemaNode } from "../../core/schema";
4
4
  import type { DtoConstructor } from "../../core/types";
5
+ import type { RequestContext } from "../express/types";
5
6
  /**
6
7
  * Metal ORM DTO modes.
7
8
  */
@@ -392,6 +393,49 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
392
393
  /** Ready-to-use config for list/query endpoints (combines filters, sort, pagination defaults) */
393
394
  listConfig: ListConfig<T>;
394
395
  }
396
+ /**
397
+ * Awaitable helper for CRUD service methods.
398
+ */
399
+ export type Awaitable<T> = T | Promise<T>;
400
+ /**
401
+ * Input for CRUD controller service: class or ready instance.
402
+ */
403
+ export type CrudControllerServiceInput<TDtos extends MetalCrudDtoClasses<any>> = CrudControllerService<TDtos> | (new () => CrudControllerService<TDtos>);
404
+ /**
405
+ * CRUD controller service contract used by createCrudController.
406
+ */
407
+ export interface CrudControllerService<TDtos extends MetalCrudDtoClasses<any>> {
408
+ list(ctx: RequestContext<unknown, InstanceType<TDtos["queryDto"]>>): Awaitable<InstanceType<TDtos["pagedResponseDto"]>>;
409
+ options?(ctx: RequestContext<unknown, InstanceType<TDtos["optionsQueryDto"]>>): Awaitable<InstanceType<TDtos["optionsDto"]>>;
410
+ getById(id: number, ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
411
+ create(body: InstanceType<TDtos["create"]>, ctx: RequestContext<InstanceType<TDtos["create"]>>): Awaitable<InstanceType<TDtos["response"]>>;
412
+ replace?(id: number, body: InstanceType<TDtos["replace"]>, ctx: RequestContext<InstanceType<TDtos["replace"]>, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
413
+ update?(id: number, body: InstanceType<TDtos["update"]>, ctx: RequestContext<InstanceType<TDtos["update"]>, undefined, InstanceType<TDtos["params"]>>): Awaitable<InstanceType<TDtos["response"]>>;
414
+ delete?(id: number, ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>): Awaitable<void>;
415
+ }
416
+ /**
417
+ * createCrudController options.
418
+ */
419
+ export interface CreateCrudControllerOptions<TDtos extends MetalCrudDtoClasses<any>> {
420
+ /** Controller path. */
421
+ path: string;
422
+ /** Service instance or class (new () => service). */
423
+ service: CrudControllerServiceInput<TDtos>;
424
+ /** DTO bundle produced by createMetalCrudDtoClasses. */
425
+ dtos: TDtos;
426
+ /** Entity label used by parseIdOrThrow messages. */
427
+ entityName: string;
428
+ /** Generate GET /options route (default: true). */
429
+ withOptionsRoute?: boolean;
430
+ /** Generate PUT /:id route (default: true). */
431
+ withReplace?: boolean;
432
+ /** Generate PATCH /:id route (default: true). */
433
+ withPatch?: boolean;
434
+ /** Generate DELETE /:id route (default: true). */
435
+ withDelete?: boolean;
436
+ /** Optional OpenAPI tags for generated controller. */
437
+ tags?: string[];
438
+ }
395
439
  /**
396
440
  * Metal Tree DTO class names.
397
441
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adorn-api",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Decorator-first web framework with OpenAPI 3.1 schema generation.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,188 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Delete,
5
+ Get,
6
+ Params,
7
+ Patch,
8
+ Post,
9
+ Put,
10
+ Query,
11
+ Returns
12
+ } from "../../core/decorators";
13
+ import type { DtoConstructor } from "../../core/types";
14
+ import type { RequestContext } from "../express/types";
15
+ import type {
16
+ CreateCrudControllerOptions,
17
+ CrudControllerService,
18
+ CrudControllerServiceInput,
19
+ MetalCrudDtoClasses,
20
+ RouteErrorsDecorator
21
+ } from "./types";
22
+ import { parseIdOrThrow } from "./utils";
23
+
24
+ type DtoInstance<TDto extends DtoConstructor> = InstanceType<TDto>;
25
+ type MethodDecorator = (
26
+ value: unknown,
27
+ context: ClassMethodDecoratorContext
28
+ ) => void;
29
+
30
+ export function createCrudController<TDtos extends MetalCrudDtoClasses<any>>(
31
+ options: CreateCrudControllerOptions<TDtos>
32
+ ) {
33
+ const withOptionsRoute = options.withOptionsRoute ?? true;
34
+ const withReplace = options.withReplace ?? true;
35
+ const withPatch = options.withPatch ?? true;
36
+ const withDelete = options.withDelete ?? true;
37
+ const service = resolveService(options.service);
38
+ const routeErrorsDecorator = options.dtos.errors;
39
+
40
+ @Controller({ path: options.path, tags: options.tags })
41
+ class GeneratedCrudController {
42
+ @Get("/")
43
+ @Query(options.dtos.queryDto)
44
+ @Returns(options.dtos.pagedResponseDto)
45
+ async list(
46
+ ctx: RequestContext<unknown, DtoInstance<TDtos["queryDto"]>>
47
+ ): Promise<DtoInstance<TDtos["pagedResponseDto"]>> {
48
+ return await service.list(ctx);
49
+ }
50
+
51
+ @when(withOptionsRoute, Get("/options"))
52
+ @when(withOptionsRoute, Query(options.dtos.optionsQueryDto))
53
+ @when(withOptionsRoute, Returns(options.dtos.optionsDto))
54
+ async options(
55
+ ctx: RequestContext<unknown, DtoInstance<TDtos["optionsQueryDto"]>>
56
+ ): Promise<DtoInstance<TDtos["optionsDto"]>> {
57
+ assertServiceMethod(service, "options");
58
+ return await service.options(ctx);
59
+ }
60
+
61
+ @Get("/:id")
62
+ @Params(options.dtos.params)
63
+ @Returns(options.dtos.response)
64
+ @applyRouteErrors(routeErrorsDecorator)
65
+ async getById(
66
+ ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>
67
+ ): Promise<DtoInstance<TDtos["response"]>> {
68
+ const id = parseContextId(ctx, options.entityName);
69
+ return await service.getById(id, ctx);
70
+ }
71
+
72
+ @Post("/")
73
+ @Body(options.dtos.create)
74
+ @Returns({ status: 201, schema: options.dtos.response })
75
+ async create(
76
+ ctx: RequestContext<DtoInstance<TDtos["create"]>>
77
+ ): Promise<DtoInstance<TDtos["response"]>> {
78
+ return await service.create(ctx.body, ctx);
79
+ }
80
+
81
+ @when(withReplace, Put("/:id"))
82
+ @when(withReplace, Params(options.dtos.params))
83
+ @when(withReplace, Body(options.dtos.replace))
84
+ @when(withReplace, Returns(options.dtos.response))
85
+ @when(withReplace, applyRouteErrors(routeErrorsDecorator))
86
+ async replace(
87
+ ctx: RequestContext<
88
+ DtoInstance<TDtos["replace"]>,
89
+ undefined,
90
+ DtoInstance<TDtos["params"]>
91
+ >
92
+ ): Promise<DtoInstance<TDtos["response"]>> {
93
+ assertServiceMethod(service, "replace");
94
+ const id = parseContextId(ctx, options.entityName);
95
+ return await service.replace(id, ctx.body, ctx);
96
+ }
97
+
98
+ @when(withPatch, Patch("/:id"))
99
+ @when(withPatch, Params(options.dtos.params))
100
+ @when(withPatch, Body(options.dtos.update))
101
+ @when(withPatch, Returns(options.dtos.response))
102
+ @when(withPatch, applyRouteErrors(routeErrorsDecorator))
103
+ async update(
104
+ ctx: RequestContext<
105
+ DtoInstance<TDtos["update"]>,
106
+ undefined,
107
+ DtoInstance<TDtos["params"]>
108
+ >
109
+ ): Promise<DtoInstance<TDtos["response"]>> {
110
+ assertServiceMethod(service, "update");
111
+ const id = parseContextId(ctx, options.entityName);
112
+ return await service.update(id, ctx.body, ctx);
113
+ }
114
+
115
+ @when(withDelete, Delete("/:id"))
116
+ @when(withDelete, Params(options.dtos.params))
117
+ @when(withDelete, Returns({ status: 204, description: "No Content" }))
118
+ @when(withDelete, applyRouteErrors(routeErrorsDecorator))
119
+ async delete(
120
+ ctx: RequestContext<unknown, undefined, DtoInstance<TDtos["params"]>>
121
+ ): Promise<void> {
122
+ assertServiceMethod(service, "delete");
123
+ const id = parseContextId(ctx, options.entityName);
124
+ await service.delete(id, ctx);
125
+ }
126
+ }
127
+
128
+ Object.defineProperty(GeneratedCrudController, "name", {
129
+ value: `${options.entityName}CrudController`,
130
+ configurable: true
131
+ });
132
+
133
+ return GeneratedCrudController;
134
+ }
135
+
136
+ function resolveService<TDtos extends MetalCrudDtoClasses<any>>(
137
+ input: CrudControllerServiceInput<TDtos>
138
+ ): CrudControllerService<TDtos> {
139
+ if (typeof input === "function") {
140
+ return new input();
141
+ }
142
+ return input;
143
+ }
144
+
145
+ function when(enabled: boolean, decorator: MethodDecorator): MethodDecorator {
146
+ return (value, context) => {
147
+ if (enabled) {
148
+ decorator(value, context);
149
+ }
150
+ };
151
+ }
152
+
153
+ function applyRouteErrors(
154
+ decorator: RouteErrorsDecorator | undefined
155
+ ): MethodDecorator {
156
+ return (value, context) => {
157
+ decorator?.(value, context);
158
+ };
159
+ }
160
+
161
+ function parseContextId(
162
+ ctx: RequestContext<unknown, undefined, object | undefined>,
163
+ entityName: string
164
+ ): number {
165
+ const params = (ctx.params ?? {}) as Record<string, unknown>;
166
+ return parseIdOrThrow(toIdValue(params.id), entityName);
167
+ }
168
+
169
+ function toIdValue(value: unknown): string | number {
170
+ if (typeof value === "string" || typeof value === "number") {
171
+ return value;
172
+ }
173
+ return "";
174
+ }
175
+
176
+ function assertServiceMethod<
177
+ TDtos extends MetalCrudDtoClasses<any>,
178
+ TMethod extends "options" | "replace" | "update" | "delete"
179
+ >(
180
+ service: CrudControllerService<TDtos>,
181
+ method: TMethod
182
+ ): asserts service is CrudControllerService<TDtos> & Record<TMethod, NonNullable<CrudControllerService<TDtos>[TMethod]>> {
183
+ if (typeof service[method] !== "function") {
184
+ throw new Error(
185
+ `CRUD service is missing "${method}" method required by enabled route.`
186
+ );
187
+ }
188
+ }
@@ -33,6 +33,10 @@ export {
33
33
  createNestedCreateDtoClass
34
34
  } from "./crud-dtos";
35
35
 
36
+ export {
37
+ createCrudController
38
+ } from "./crud-controller";
39
+
36
40
  export {
37
41
  createMetalTreeDtoClasses
38
42
  } from "./tree-dtos";
@@ -96,6 +100,9 @@ export type {
96
100
  MetalCrudDtoClasses,
97
101
  MetalCrudDtoClassNameKey,
98
102
  MetalCrudDtoClassNames,
103
+ CrudControllerService,
104
+ CrudControllerServiceInput,
105
+ CreateCrudControllerOptions,
99
106
  RouteErrorsDecorator,
100
107
  NestedCreateDtoOptions,
101
108
  MetalTreeDtoClassOptions,
@@ -3,6 +3,7 @@ import type { BelongsToReference, HasManyCollection, HasOneReference, ManyToMany
3
3
  import type { DtoOptions, ErrorResponseOptions, FieldOverride } from "../../core/decorators";
4
4
  import type { SchemaNode } from "../../core/schema";
5
5
  import type { DtoConstructor } from "../../core/types";
6
+ import type { RequestContext } from "../express/types";
6
7
 
7
8
  /**
8
9
  * Metal ORM DTO modes.
@@ -475,6 +476,86 @@ export interface MetalCrudDtoClasses<T = Record<string, unknown>> {
475
476
  listConfig: ListConfig<T>;
476
477
  }
477
478
 
479
+ /**
480
+ * Awaitable helper for CRUD service methods.
481
+ */
482
+ export type Awaitable<T> = T | Promise<T>;
483
+
484
+ /**
485
+ * Input for CRUD controller service: class or ready instance.
486
+ */
487
+ export type CrudControllerServiceInput<TDtos extends MetalCrudDtoClasses<any>> =
488
+ CrudControllerService<TDtos>
489
+ | (new () => CrudControllerService<TDtos>);
490
+
491
+ /**
492
+ * CRUD controller service contract used by createCrudController.
493
+ */
494
+ export interface CrudControllerService<TDtos extends MetalCrudDtoClasses<any>> {
495
+ list(
496
+ ctx: RequestContext<unknown, InstanceType<TDtos["queryDto"]>>
497
+ ): Awaitable<InstanceType<TDtos["pagedResponseDto"]>>;
498
+ options?(
499
+ ctx: RequestContext<unknown, InstanceType<TDtos["optionsQueryDto"]>>
500
+ ): Awaitable<InstanceType<TDtos["optionsDto"]>>;
501
+ getById(
502
+ id: number,
503
+ ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>
504
+ ): Awaitable<InstanceType<TDtos["response"]>>;
505
+ create(
506
+ body: InstanceType<TDtos["create"]>,
507
+ ctx: RequestContext<InstanceType<TDtos["create"]>>
508
+ ): Awaitable<InstanceType<TDtos["response"]>>;
509
+ replace?(
510
+ id: number,
511
+ body: InstanceType<TDtos["replace"]>,
512
+ ctx: RequestContext<
513
+ InstanceType<TDtos["replace"]>,
514
+ undefined,
515
+ InstanceType<TDtos["params"]>
516
+ >
517
+ ): Awaitable<InstanceType<TDtos["response"]>>;
518
+ update?(
519
+ id: number,
520
+ body: InstanceType<TDtos["update"]>,
521
+ ctx: RequestContext<
522
+ InstanceType<TDtos["update"]>,
523
+ undefined,
524
+ InstanceType<TDtos["params"]>
525
+ >
526
+ ): Awaitable<InstanceType<TDtos["response"]>>;
527
+ delete?(
528
+ id: number,
529
+ ctx: RequestContext<unknown, undefined, InstanceType<TDtos["params"]>>
530
+ ): Awaitable<void>;
531
+ }
532
+
533
+ /**
534
+ * createCrudController options.
535
+ */
536
+ export interface CreateCrudControllerOptions<
537
+ TDtos extends MetalCrudDtoClasses<any>
538
+ > {
539
+ /** Controller path. */
540
+ path: string;
541
+ /** Service instance or class (new () => service). */
542
+ service: CrudControllerServiceInput<TDtos>;
543
+ /** DTO bundle produced by createMetalCrudDtoClasses. */
544
+ dtos: TDtos;
545
+ /** Entity label used by parseIdOrThrow messages. */
546
+ entityName: string;
547
+ /** Generate GET /options route (default: true). */
548
+ withOptionsRoute?: boolean;
549
+ /** Generate PUT /:id route (default: true). */
550
+ withReplace?: boolean;
551
+ /** Generate PATCH /:id route (default: true). */
552
+ withPatch?: boolean;
553
+ /** Generate DELETE /:id route (default: true). */
554
+ withDelete?: boolean;
555
+ /** Optional OpenAPI tags for generated controller. */
556
+ tags?: string[];
557
+ }
558
+
478
559
  /**
479
560
  * Metal Tree DTO class names.
480
561
  */
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildOpenApi,
4
+ createCrudController,
5
+ createMetalCrudDtoClasses,
6
+ t,
7
+ type RequestContext
8
+ } from "../../src/index";
9
+ import { getControllerMeta } from "../../src/core/metadata";
10
+ import { Column, Entity, PrimaryKey, col } from "metal-orm";
11
+
12
+ @Entity({ tableName: "crud_factory_entities" })
13
+ class CrudFactoryEntity {
14
+ @PrimaryKey(col.autoIncrement(col.int()))
15
+ id!: number;
16
+
17
+ @Column(col.notNull(col.text()))
18
+ nome!: string;
19
+
20
+ @Column(col.notNull(col.boolean()))
21
+ ativo!: boolean;
22
+ }
23
+
24
+ const crudDtos = createMetalCrudDtoClasses(CrudFactoryEntity, {
25
+ mutationExclude: ["id"],
26
+ query: {
27
+ filters: {
28
+ nomeContains: {
29
+ schema: t.string({ minLength: 1 }),
30
+ field: "nome",
31
+ operator: "contains"
32
+ },
33
+ ativo: {
34
+ schema: t.boolean(),
35
+ field: "ativo",
36
+ operator: "equals"
37
+ }
38
+ },
39
+ sortableColumns: {
40
+ id: "id",
41
+ nome: "nome"
42
+ },
43
+ options: {
44
+ labelField: "nome"
45
+ }
46
+ },
47
+ errors: true
48
+ });
49
+
50
+ class CrudFactoryService {
51
+ async list(_ctx: RequestContext<unknown, InstanceType<typeof crudDtos.queryDto>>) {
52
+ return { items: [], total: 0, page: 1, pageSize: 25 } as InstanceType<typeof crudDtos.pagedResponseDto>;
53
+ }
54
+
55
+ async options(_ctx: RequestContext<unknown, InstanceType<typeof crudDtos.optionsQueryDto>>) {
56
+ return { items: [], total: 0, page: 1, pageSize: 25 } as InstanceType<typeof crudDtos.optionsDto>;
57
+ }
58
+
59
+ async getById(
60
+ id: number,
61
+ _ctx: RequestContext<unknown, undefined, InstanceType<typeof crudDtos.params>>
62
+ ) {
63
+ return { id, nome: "Teste", ativo: true } as InstanceType<typeof crudDtos.response>;
64
+ }
65
+
66
+ async create(
67
+ body: InstanceType<typeof crudDtos.create>,
68
+ _ctx: RequestContext<InstanceType<typeof crudDtos.create>>
69
+ ) {
70
+ return { id: 1, ...body } as InstanceType<typeof crudDtos.response>;
71
+ }
72
+
73
+ async replace(
74
+ id: number,
75
+ body: InstanceType<typeof crudDtos.replace>,
76
+ _ctx: RequestContext<InstanceType<typeof crudDtos.replace>, undefined, InstanceType<typeof crudDtos.params>>
77
+ ) {
78
+ return { id, ...body } as InstanceType<typeof crudDtos.response>;
79
+ }
80
+
81
+ async update(
82
+ id: number,
83
+ body: InstanceType<typeof crudDtos.update>,
84
+ _ctx: RequestContext<InstanceType<typeof crudDtos.update>, undefined, InstanceType<typeof crudDtos.params>>
85
+ ) {
86
+ return { id, ...body } as InstanceType<typeof crudDtos.response>;
87
+ }
88
+
89
+ async delete(
90
+ _id: number,
91
+ _ctx: RequestContext<unknown, undefined, InstanceType<typeof crudDtos.params>>
92
+ ) {}
93
+ }
94
+
95
+ const CrudFactoryController = createCrudController({
96
+ path: "/crud-factory",
97
+ service: CrudFactoryService,
98
+ dtos: crudDtos,
99
+ entityName: "CrudFactoryEntity"
100
+ });
101
+
102
+ describe("createCrudController", () => {
103
+ it("registers CRUD routes with expected schemas and statuses", () => {
104
+ const meta = getControllerMeta(CrudFactoryController);
105
+ expect(meta).toBeDefined();
106
+ expect(meta?.basePath).toBe("/crud-factory");
107
+
108
+ const routeKeys = (meta?.routes ?? [])
109
+ .map((route) => `${route.httpMethod} ${route.path}`)
110
+ .sort();
111
+
112
+ expect(routeKeys).toEqual([
113
+ "delete /:id",
114
+ "get /",
115
+ "get /:id",
116
+ "get /options",
117
+ "patch /:id",
118
+ "post /",
119
+ "put /:id"
120
+ ]);
121
+
122
+ const byKey = new Map(
123
+ (meta?.routes ?? []).map((route) => [`${route.httpMethod} ${route.path}`, route] as const)
124
+ );
125
+
126
+ const listRoute = byKey.get("get /");
127
+ expect(listRoute?.query?.schema).toBe(crudDtos.queryDto);
128
+ expect(listRoute?.responses).toEqual(
129
+ expect.arrayContaining([
130
+ expect.objectContaining({ status: 200, schema: crudDtos.pagedResponseDto })
131
+ ])
132
+ );
133
+
134
+ const optionsRoute = byKey.get("get /options");
135
+ expect(optionsRoute?.query?.schema).toBe(crudDtos.optionsQueryDto);
136
+ expect(optionsRoute?.responses).toEqual(
137
+ expect.arrayContaining([
138
+ expect.objectContaining({ status: 200, schema: crudDtos.optionsDto })
139
+ ])
140
+ );
141
+
142
+ const createRoute = byKey.get("post /");
143
+ expect(createRoute?.body?.schema).toBe(crudDtos.create);
144
+ expect(createRoute?.responses).toEqual(
145
+ expect.arrayContaining([
146
+ expect.objectContaining({ status: 201, schema: crudDtos.response })
147
+ ])
148
+ );
149
+
150
+ for (const key of ["get /:id", "put /:id", "patch /:id", "delete /:id"]) {
151
+ const route = byKey.get(key);
152
+ expect(route?.params?.schema).toBe(crudDtos.params);
153
+ expect(route?.responses).toEqual(
154
+ expect.arrayContaining([
155
+ expect.objectContaining({ status: 400, error: true }),
156
+ expect.objectContaining({ status: 404, error: true })
157
+ ])
158
+ );
159
+ }
160
+
161
+ expect(byKey.get("put /:id")?.body?.schema).toBe(crudDtos.replace);
162
+ expect(byKey.get("patch /:id")?.body?.schema).toBe(crudDtos.update);
163
+ expect(byKey.get("delete /:id")?.responses).toEqual(
164
+ expect.arrayContaining([expect.objectContaining({ status: 204 })])
165
+ );
166
+ });
167
+
168
+ it("exposes matching schemas/status in OpenAPI", () => {
169
+ const doc = buildOpenApi({
170
+ info: { title: "Test API", version: "1.0.0" },
171
+ controllers: [CrudFactoryController]
172
+ });
173
+
174
+ const listResponses = (doc.paths["/crud-factory"]?.get as Record<string, any>)?.responses;
175
+ const createResponses = (doc.paths["/crud-factory"]?.post as Record<string, any>)?.responses;
176
+ const getByIdResponses = (doc.paths["/crud-factory/{id}"]?.get as Record<string, any>)?.responses;
177
+ const deleteResponses = (doc.paths["/crud-factory/{id}"]?.delete as Record<string, any>)?.responses;
178
+
179
+ expect(listResponses?.["200"]).toBeDefined();
180
+ expect(createResponses?.["201"]).toBeDefined();
181
+ expect(getByIdResponses?.["200"]).toBeDefined();
182
+ expect(getByIdResponses?.["400"]).toBeDefined();
183
+ expect(getByIdResponses?.["404"]).toBeDefined();
184
+ expect(deleteResponses?.["204"]).toBeDefined();
185
+ });
186
+
187
+ it("supports disabling optional routes with flags", () => {
188
+ const MinimalController = createCrudController({
189
+ path: "/crud-factory-minimal",
190
+ service: new CrudFactoryService(),
191
+ dtos: crudDtos,
192
+ entityName: "CrudFactoryEntity",
193
+ withOptionsRoute: false,
194
+ withReplace: false,
195
+ withPatch: false,
196
+ withDelete: false
197
+ });
198
+
199
+ const meta = getControllerMeta(MinimalController);
200
+ const routeKeys = (meta?.routes ?? [])
201
+ .map((route) => `${route.httpMethod} ${route.path}`)
202
+ .sort();
203
+
204
+ expect(routeKeys).toEqual([
205
+ "get /",
206
+ "get /:id",
207
+ "post /"
208
+ ]);
209
+ });
210
+
211
+ it("uses entityName in parseIdOrThrow messages", async () => {
212
+ const controller = new CrudFactoryController();
213
+ await expect(
214
+ controller.getById({
215
+ params: { id: "invalid" }
216
+ } as any)
217
+ ).rejects.toMatchObject({
218
+ status: 400,
219
+ message: "Invalid CrudFactoryEntity id."
220
+ });
221
+ });
222
+ });