@macss/modular-api 0.1.0

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 ADDED
@@ -0,0 +1,131 @@
1
+ # modular_api
2
+
3
+ Use-case centric toolkit for building modular APIs with Express.
4
+ Define `UseCase` classes (input → validate → execute → output), connect them to HTTP routes, and get automatic Swagger/OpenAPI documentation.
5
+
6
+ > TypeScript port of [modular_api](https://pub.dev/packages/modular_api) (Dart/Shelf)
7
+
8
+ ---
9
+
10
+ ## Quick start
11
+
12
+ ```ts
13
+ import { ModularApi, ModuleBuilder } from 'modular_api';
14
+
15
+ // ─── Module builder (separate file in real projects) ──────────
16
+ function buildGreetingsModule(m: ModuleBuilder): void {
17
+ m.usecase('hello', HelloWorld.fromJson);
18
+ }
19
+
20
+ // ─── Server ───────────────────────────────────────────────────
21
+ const api = new ModularApi({ basePath: '/api' });
22
+
23
+ api.module('greetings', buildGreetingsModule);
24
+
25
+ api.serve({ port: 8080 });
26
+ ```
27
+
28
+ ```bash
29
+ curl -X POST http://localhost:8080/api/greetings/hello \
30
+ -H "Content-Type: application/json" \
31
+ -d '{"name":"World"}'
32
+ ```
33
+
34
+ ```json
35
+ {"message":"Hello, World!"}
36
+ ```
37
+
38
+ **Docs** → `http://localhost:8080/docs`
39
+ **Health** → `http://localhost:8080/health`
40
+
41
+ See `example/example.ts` for the full implementation including Input, Output, UseCase with `validate()`, and the builder.
42
+
43
+ ---
44
+
45
+ ## Features
46
+
47
+ - `UseCase<I, O>` — pure business logic, no HTTP concerns
48
+ - `Input` / `Output` — DTOs with `toJson()` and `toSchema()` for automatic OpenAPI
49
+ - `Output.statusCode` — custom HTTP status codes per response
50
+ - `UseCaseException` — structured error handling (status code, message, error code, details)
51
+ - `ModularApi` + `ModuleBuilder` — module registration and routing
52
+ - `useCaseTestHandler` — unit test helper (no HTTP server needed)
53
+ - `cors()` middleware — built-in CORS support
54
+ - Swagger UI at `/docs` — auto-generated from registered use cases
55
+ - Health check at `GET /health`
56
+ - All endpoints default to `POST` (configurable per use case)
57
+ - Full TypeScript declarations (`.d.ts`) included
58
+
59
+ ---
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ npm install modular_api
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Error handling
70
+
71
+ ```ts
72
+ async execute() {
73
+ const user = await repository.findById(this.input.userId);
74
+ if (!user) {
75
+ throw new UseCaseException({
76
+ statusCode: 404,
77
+ message: 'User not found',
78
+ errorCode: 'USER_NOT_FOUND',
79
+ });
80
+ }
81
+ this.output = new GetUserOutput(user);
82
+ }
83
+ ```
84
+
85
+ ```json
86
+ {"error": "USER_NOT_FOUND", "message": "User not found"}
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Testing
92
+
93
+ ```ts
94
+ import { useCaseTestHandler } from 'modular_api';
95
+
96
+ const handler = useCaseTestHandler(HelloWorld.fromJson);
97
+ const response = await handler({ name: 'World' });
98
+
99
+ console.log(response.statusCode); // 200
100
+ console.log(response.body); // { message: 'Hello, World!' }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Architecture
106
+
107
+ ```
108
+ HTTP Request → ModularApi → Module → UseCase → Business Logic → Output → HTTP Response
109
+ ```
110
+
111
+ - **UseCase layer** — pure logic, independent of HTTP
112
+ - **HTTP adapter** — turns a UseCase into an Express RequestHandler
113
+ - **Middlewares** — cross-cutting concerns (CORS, logging)
114
+ - **Swagger UI** — documentation served automatically
115
+
116
+ ---
117
+
118
+ ## Dart version
119
+
120
+ This is the TypeScript port. The original Dart version is available at:
121
+
122
+ - **pub.dev**: [modular_api](https://pub.dev/packages/modular_api)
123
+ - **GitHub**: [macss-dev/modular_api](https://github.com/macss-dev/modular_api)
124
+
125
+ Both SDKs share the same architecture and API surface at v0.1.0.
126
+
127
+ ---
128
+
129
+ ## License
130
+
131
+ MIT © [ccisne.dev](https://ccisne.dev)
@@ -0,0 +1,74 @@
1
+ import { type RequestHandler } from 'express';
2
+ import { ModuleBuilder } from './module_builder';
3
+ export interface ModularApiOptions {
4
+ /** Base path prefix for all module routes. Default: '/api' */
5
+ basePath?: string;
6
+ /** API title shown in Swagger UI. Default: 'API' */
7
+ title?: string;
8
+ }
9
+ /**
10
+ * Main entry point for modular_api.
11
+ *
12
+ * Dart equivalent:
13
+ * ```dart
14
+ * final api = ModularApi(basePath: '/api');
15
+ * api.module('greetings', (m) => m.usecase('hello', SayHello.fromJson));
16
+ * await api.serve(port: 8080);
17
+ * ```
18
+ *
19
+ * TypeScript equivalent:
20
+ * ```ts
21
+ * const api = new ModularApi({ basePath: '/api' });
22
+ * api.module('greetings', (m) => m.usecase('hello', SayHello.fromJson));
23
+ * await api.serve({ port: 8080 });
24
+ * ```
25
+ *
26
+ * Auto-mounted endpoints:
27
+ * GET /health → 200 "ok"
28
+ * GET /docs → Swagger UI
29
+ */
30
+ export declare class ModularApi {
31
+ private readonly app;
32
+ private readonly rootRouter;
33
+ private readonly basePath;
34
+ private readonly title;
35
+ private readonly middlewares;
36
+ constructor(options?: ModularApiOptions);
37
+ /**
38
+ * Registers a group of use cases under a named module.
39
+ * Returns `this` for method chaining.
40
+ *
41
+ * ```ts
42
+ * api
43
+ * .module('users', (m) => {
44
+ * m.usecase('create', CreateUser.fromJson);
45
+ * m.usecase('list', ListUsers.fromJson, { method: 'GET' });
46
+ * })
47
+ * .module('products', buildProductsModule);
48
+ * ```
49
+ */
50
+ module(name: string, build: (m: ModuleBuilder) => void): this;
51
+ /**
52
+ * Adds an Express middleware to the pipeline.
53
+ * Applied in the order they are registered, before any module handler.
54
+ * Returns `this` for method chaining.
55
+ *
56
+ * ```ts
57
+ * api.use(cors()).use(myAuthMiddleware);
58
+ * ```
59
+ */
60
+ use(middleware: RequestHandler): this;
61
+ /**
62
+ * Starts the Express server on the given port.
63
+ *
64
+ * Auto-mounts:
65
+ * GET /health → 200 "ok"
66
+ * GET /docs → Swagger UI (built from registered use cases)
67
+ *
68
+ * @returns The Node.js http.Server instance
69
+ */
70
+ serve(options: {
71
+ port: number;
72
+ host?: string;
73
+ }): Promise<import('http').Server>;
74
+ }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/modular_api.ts
4
+ // ModularApi — main orchestrator.
5
+ // Mirror of ModularApi in Dart.
6
+ // ============================================================
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ModularApi = void 0;
12
+ const express_1 = __importDefault(require("express"));
13
+ const module_builder_1 = require("./module_builder");
14
+ const openapi_1 = require("../openapi/openapi");
15
+ const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
16
+ /**
17
+ * Main entry point for modular_api.
18
+ *
19
+ * Dart equivalent:
20
+ * ```dart
21
+ * final api = ModularApi(basePath: '/api');
22
+ * api.module('greetings', (m) => m.usecase('hello', SayHello.fromJson));
23
+ * await api.serve(port: 8080);
24
+ * ```
25
+ *
26
+ * TypeScript equivalent:
27
+ * ```ts
28
+ * const api = new ModularApi({ basePath: '/api' });
29
+ * api.module('greetings', (m) => m.usecase('hello', SayHello.fromJson));
30
+ * await api.serve({ port: 8080 });
31
+ * ```
32
+ *
33
+ * Auto-mounted endpoints:
34
+ * GET /health → 200 "ok"
35
+ * GET /docs → Swagger UI
36
+ */
37
+ class ModularApi {
38
+ constructor(options = {}) {
39
+ this.middlewares = [];
40
+ this.basePath = options.basePath ?? '/api';
41
+ this.title = options.title ?? 'API';
42
+ this.app = (0, express_1.default)();
43
+ this.app.use(express_1.default.json());
44
+ this.rootRouter = express_1.default.Router();
45
+ this.app.use(this.rootRouter);
46
+ }
47
+ /**
48
+ * Registers a group of use cases under a named module.
49
+ * Returns `this` for method chaining.
50
+ *
51
+ * ```ts
52
+ * api
53
+ * .module('users', (m) => {
54
+ * m.usecase('create', CreateUser.fromJson);
55
+ * m.usecase('list', ListUsers.fromJson, { method: 'GET' });
56
+ * })
57
+ * .module('products', buildProductsModule);
58
+ * ```
59
+ */
60
+ module(name, build) {
61
+ const builder = new module_builder_1.ModuleBuilder(this.basePath, name, this.rootRouter);
62
+ build(builder);
63
+ builder._mount();
64
+ return this;
65
+ }
66
+ /**
67
+ * Adds an Express middleware to the pipeline.
68
+ * Applied in the order they are registered, before any module handler.
69
+ * Returns `this` for method chaining.
70
+ *
71
+ * ```ts
72
+ * api.use(cors()).use(myAuthMiddleware);
73
+ * ```
74
+ */
75
+ use(middleware) {
76
+ this.middlewares.push(middleware);
77
+ return this;
78
+ }
79
+ /**
80
+ * Starts the Express server on the given port.
81
+ *
82
+ * Auto-mounts:
83
+ * GET /health → 200 "ok"
84
+ * GET /docs → Swagger UI (built from registered use cases)
85
+ *
86
+ * @returns The Node.js http.Server instance
87
+ */
88
+ serve(options) {
89
+ const { port, host = '0.0.0.0' } = options;
90
+ return new Promise((resolve) => {
91
+ // Register middlewares before routes
92
+ for (const mw of this.middlewares) {
93
+ this.app.use(mw);
94
+ }
95
+ // Health endpoint
96
+ this.app.get('/health', (_req, res) => res.status(200).send('ok'));
97
+ // Swagger / OpenAPI docs
98
+ const spec = (0, openapi_1.buildOpenApiSpec)({ title: this.title, port });
99
+ this.app.use('/docs', swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(spec));
100
+ const server = this.app.listen(port, host, () => {
101
+ console.log(`Docs → http://localhost:${port}/docs`);
102
+ console.log(`Health → http://localhost:${port}/health`);
103
+ resolve(server);
104
+ });
105
+ });
106
+ }
107
+ }
108
+ exports.ModularApi = ModularApi;
@@ -0,0 +1,48 @@
1
+ import { Router } from 'express';
2
+ import type { Input, Output, UseCaseFactory } from './usecase';
3
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
4
+ export interface UseCaseOptions {
5
+ /** HTTP method. Defaults to POST (same as Dart version). */
6
+ method?: HttpMethod;
7
+ summary?: string;
8
+ description?: string;
9
+ /** Override input schema for OpenAPI (if fromJson fails with empty data) */
10
+ inputSchema?: Record<string, unknown>;
11
+ /** Override output schema for OpenAPI (if fromJson fails with empty data) */
12
+ outputSchema?: Record<string, unknown>;
13
+ }
14
+ /**
15
+ * Fluent builder that registers use cases on a module-scoped Express Router.
16
+ * Returned and used inside the callback of `ModularApi.module()`.
17
+ *
18
+ * Dart equivalent:
19
+ * api.module('users', (m) {
20
+ * m.usecase('create', CreateUser.fromJson);
21
+ * });
22
+ *
23
+ * TypeScript:
24
+ * api.module('users', (m) => {
25
+ * m.usecase('create', CreateUser.fromJson);
26
+ * });
27
+ */
28
+ export declare class ModuleBuilder {
29
+ private readonly basePath;
30
+ private readonly moduleName;
31
+ private readonly rootRouter;
32
+ private readonly router;
33
+ constructor(basePath: string, moduleName: string, rootRouter: Router);
34
+ /**
35
+ * Registers a use case as an HTTP endpoint.
36
+ *
37
+ * @param name Route segment, e.g. 'create' → POST /api/users/create
38
+ * @param factory The static `fromJson` of your UseCase class
39
+ * @param options Optional HTTP method, summary and description for OpenAPI
40
+ */
41
+ usecase<I extends Input, O extends Output>(name: string, factory: UseCaseFactory<I, O>, options?: UseCaseOptions): this;
42
+ /** Try to get schemas from a dummy factory call. Fails gracefully. */
43
+ private _extractSchemas;
44
+ /** @internal — called by ModularApi after the builder callback runs */
45
+ _mount(): void;
46
+ private _normalizeBase;
47
+ }
48
+ export {};
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/module_builder.ts
4
+ // ModuleBuilder — collects use cases and mounts them on a Router.
5
+ // Mirror of ModuleBuilder in Dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ModuleBuilder = void 0;
9
+ const express_1 = require("express");
10
+ const usecase_handler_1 = require("./usecase_handler");
11
+ const registry_1 = require("./registry");
12
+ /**
13
+ * Fluent builder that registers use cases on a module-scoped Express Router.
14
+ * Returned and used inside the callback of `ModularApi.module()`.
15
+ *
16
+ * Dart equivalent:
17
+ * api.module('users', (m) {
18
+ * m.usecase('create', CreateUser.fromJson);
19
+ * });
20
+ *
21
+ * TypeScript:
22
+ * api.module('users', (m) => {
23
+ * m.usecase('create', CreateUser.fromJson);
24
+ * });
25
+ */
26
+ class ModuleBuilder {
27
+ constructor(basePath, moduleName, rootRouter) {
28
+ this.basePath = basePath;
29
+ this.moduleName = moduleName;
30
+ this.rootRouter = rootRouter;
31
+ this.router = (0, express_1.Router)();
32
+ }
33
+ /**
34
+ * Registers a use case as an HTTP endpoint.
35
+ *
36
+ * @param name Route segment, e.g. 'create' → POST /api/users/create
37
+ * @param factory The static `fromJson` of your UseCase class
38
+ * @param options Optional HTTP method, summary and description for OpenAPI
39
+ */
40
+ usecase(name, factory, options = {}) {
41
+ const { method = 'POST', summary, description, inputSchema, outputSchema } = options;
42
+ // Normalize name: trim and remove leading slash
43
+ const cleanName = name.trim().replace(/^\//, '');
44
+ const subPath = `/${cleanName}`;
45
+ const methodL = method.toLowerCase();
46
+ // Mount the Express handler
47
+ this.router[methodL](subPath, (0, usecase_handler_1.useCaseHandler)(factory));
48
+ // Try to capture schemas via dummy factory call, or use overrides
49
+ const extracted = this._extractSchemas(factory);
50
+ const schemas = {
51
+ input: inputSchema ?? extracted.input,
52
+ output: outputSchema ?? extracted.output,
53
+ };
54
+ // Register in the global registry for OpenAPI generation
55
+ registry_1.apiRegistry.routes.push({
56
+ module: this.moduleName,
57
+ name: cleanName,
58
+ method: method,
59
+ path: `${this._normalizeBase(this.basePath)}/${this.moduleName}/${cleanName}`,
60
+ factory: factory,
61
+ schemas,
62
+ doc: {
63
+ summary: summary ?? `Use case ${cleanName} in module ${this.moduleName}`,
64
+ description: description ?? `Auto-generated documentation for ${cleanName}`,
65
+ tags: [this.moduleName],
66
+ },
67
+ });
68
+ return this;
69
+ }
70
+ /** Try to get schemas from a dummy factory call. Fails gracefully. */
71
+ _extractSchemas(factory) {
72
+ try {
73
+ const instance = factory({});
74
+ return {
75
+ input: instance.input.toSchema(),
76
+ output: instance.output?.toSchema?.() ?? {},
77
+ };
78
+ }
79
+ catch {
80
+ return { input: {}, output: {} };
81
+ }
82
+ }
83
+ /** @internal — called by ModularApi after the builder callback runs */
84
+ _mount() {
85
+ const mountPath = `${this._normalizeBase(this.basePath)}/${this.moduleName}`;
86
+ this.rootRouter.use(mountPath, this.router);
87
+ }
88
+ _normalizeBase(p) {
89
+ if (!p)
90
+ return '';
91
+ return p.startsWith('/') ? p : `/${p}`;
92
+ }
93
+ }
94
+ exports.ModuleBuilder = ModuleBuilder;
@@ -0,0 +1,37 @@
1
+ import type { UseCaseFactory } from './usecase';
2
+ import type { Input, Output } from './usecase';
3
+ export interface UseCaseDocMeta {
4
+ summary?: string;
5
+ description?: string;
6
+ /** Tags for Swagger grouping — typically the module name */
7
+ tags?: string[];
8
+ /** Override for the Input JSON Schema (captured at registration time) */
9
+ inputSchema?: Record<string, unknown>;
10
+ /** Override for the Output JSON Schema (captured at registration time) */
11
+ outputSchema?: Record<string, unknown>;
12
+ }
13
+ export interface UseCaseRegistration {
14
+ module: string;
15
+ name: string;
16
+ /** HTTP method in uppercase: "POST" | "GET" | "PUT" | "PATCH" | "DELETE" */
17
+ method: string;
18
+ /** Full path e.g. "/api/users/create" */
19
+ path: string;
20
+ factory: UseCaseFactory<Input, Output>;
21
+ doc?: UseCaseDocMeta;
22
+ /** Schemas captured at registration time via dummy factory call */
23
+ schemas: {
24
+ input: Record<string, unknown>;
25
+ output: Record<string, unknown>;
26
+ };
27
+ }
28
+ /**
29
+ * Singleton registry — holds all registered routes for OpenAPI generation.
30
+ * Populated by ModuleBuilder.usecase() at startup.
31
+ */
32
+ declare class ApiRegistry {
33
+ readonly routes: UseCaseRegistration[];
34
+ clear(): void;
35
+ }
36
+ export declare const apiRegistry: ApiRegistry;
37
+ export {};
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/registry.ts
4
+ // In-memory registry of all registered use cases.
5
+ // Used by the OpenAPI generator to build the spec automatically.
6
+ // Mirror of _ApiRegistry + UseCaseRegistration in Dart.
7
+ // ============================================================
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.apiRegistry = void 0;
10
+ /**
11
+ * Singleton registry — holds all registered routes for OpenAPI generation.
12
+ * Populated by ModuleBuilder.usecase() at startup.
13
+ */
14
+ class ApiRegistry {
15
+ constructor() {
16
+ this.routes = [];
17
+ }
18
+ clear() {
19
+ this.routes.length = 0;
20
+ }
21
+ }
22
+ exports.apiRegistry = new ApiRegistry();
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Throw this inside `execute()` to return a specific HTTP status code
3
+ * and a structured JSON error body — instead of a generic 500.
4
+ *
5
+ * Dart equivalent:
6
+ * throw UseCaseException(statusCode: 404, message: 'Not found');
7
+ *
8
+ * TypeScript usage:
9
+ * ```ts
10
+ * throw new UseCaseException({
11
+ * statusCode: 404,
12
+ * message: 'User not found',
13
+ * errorCode: 'USER_NOT_FOUND',
14
+ * });
15
+ * ```
16
+ *
17
+ * HTTP response body:
18
+ * ```json
19
+ * { "error": "USER_NOT_FOUND", "message": "User not found" }
20
+ * ```
21
+ */
22
+ export declare class UseCaseException extends Error {
23
+ readonly statusCode: number;
24
+ readonly message: string;
25
+ readonly errorCode?: string;
26
+ readonly details?: Record<string, unknown>;
27
+ constructor(params: {
28
+ statusCode: number;
29
+ message: string;
30
+ errorCode?: string;
31
+ details?: Record<string, unknown>;
32
+ });
33
+ /** Serializes to the JSON body sent in the HTTP error response. */
34
+ toJson(): Record<string, unknown>;
35
+ toString(): string;
36
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/use_case_exception.ts
4
+ // Structured exception for controlled HTTP error responses.
5
+ // Mirror of UseCaseException in Dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.UseCaseException = void 0;
9
+ /**
10
+ * Throw this inside `execute()` to return a specific HTTP status code
11
+ * and a structured JSON error body — instead of a generic 500.
12
+ *
13
+ * Dart equivalent:
14
+ * throw UseCaseException(statusCode: 404, message: 'Not found');
15
+ *
16
+ * TypeScript usage:
17
+ * ```ts
18
+ * throw new UseCaseException({
19
+ * statusCode: 404,
20
+ * message: 'User not found',
21
+ * errorCode: 'USER_NOT_FOUND',
22
+ * });
23
+ * ```
24
+ *
25
+ * HTTP response body:
26
+ * ```json
27
+ * { "error": "USER_NOT_FOUND", "message": "User not found" }
28
+ * ```
29
+ */
30
+ class UseCaseException extends Error {
31
+ constructor(params) {
32
+ super(params.message);
33
+ this.name = 'UseCaseException';
34
+ this.statusCode = params.statusCode;
35
+ this.message = params.message;
36
+ this.errorCode = params.errorCode;
37
+ this.details = params.details;
38
+ }
39
+ /** Serializes to the JSON body sent in the HTTP error response. */
40
+ toJson() {
41
+ const body = {
42
+ error: this.errorCode ?? 'error',
43
+ message: this.message,
44
+ };
45
+ if (this.details !== undefined) {
46
+ body['details'] = this.details;
47
+ }
48
+ return body;
49
+ }
50
+ toString() {
51
+ const code = this.errorCode ? ` [${this.errorCode}]` : '';
52
+ return `UseCaseException(${this.statusCode}): ${this.message}${code}`;
53
+ }
54
+ }
55
+ exports.UseCaseException = UseCaseException;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * **Contract** — use `implements Input`.
3
+ *
4
+ * Pure interface: all members must be provided by the implementor.
5
+ * No default behavior is inherited — every Input is self-contained.
6
+ *
7
+ * ```ts
8
+ * class HelloInput implements Input {
9
+ * constructor(readonly name: string) {}
10
+ * toJson() { return { name: this.name }; }
11
+ * toSchema() { return { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }; }
12
+ * }
13
+ * ```
14
+ */
15
+ export declare abstract class Input {
16
+ abstract toJson(): Record<string, unknown>;
17
+ /**
18
+ * Returns an OpenAPI-compatible JSON Schema describing this input.
19
+ * Used to auto-generate Swagger documentation.
20
+ */
21
+ abstract toSchema(): Record<string, unknown>;
22
+ }
23
+ /**
24
+ * **Contract** — use `implements Output`.
25
+ *
26
+ * Pure interface: all members must be provided by the implementor.
27
+ * The implementor must define `statusCode` explicitly — this forces
28
+ * developers to think about HTTP status codes for every response.
29
+ *
30
+ * ```ts
31
+ * class HelloOutput implements Output {
32
+ * constructor(readonly message: string) {}
33
+ * get statusCode() { return 200; }
34
+ * toJson() { return { message: this.message }; }
35
+ * toSchema() { return { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] }; }
36
+ * }
37
+ * ```
38
+ */
39
+ export declare abstract class Output {
40
+ abstract toJson(): Record<string, unknown>;
41
+ abstract toSchema(): Record<string, unknown>;
42
+ /**
43
+ * HTTP status code for the response.
44
+ * Must be implemented explicitly (e.g. 200, 201, 400, 404).
45
+ */
46
+ abstract get statusCode(): number;
47
+ }
48
+ /**
49
+ * Factory function type — the signature every UseCase class must expose
50
+ * as a static method `fromJson`.
51
+ *
52
+ * Dart equivalent:
53
+ * static MyUseCase fromJson(Map<String, dynamic> json) { ... }
54
+ */
55
+ export type UseCaseFactory<I extends Input = Input, O extends Output = Output> = (json: Record<string, unknown>) => UseCase<I, O>;
56
+ /**
57
+ * **Contract** — use `implements UseCase<I, O>`.
58
+ *
59
+ * Pure interface: all members must be provided by the implementor.
60
+ * This mirrors the Dart version where UseCase is 100% abstract.
61
+ *
62
+ * Lifecycle (handled by the framework):
63
+ * 1. `fromJson(json)` — static factory, builds the use case
64
+ * 2. `validate()` — return error string or null
65
+ * 3. `execute()` — run business logic, set `this.output`
66
+ * 4. `output.toJson()` — serialize and return to HTTP client
67
+ *
68
+ * ```ts
69
+ * class SayHello implements UseCase<HelloInput, HelloOutput> {
70
+ * input: HelloInput;
71
+ * output!: HelloOutput;
72
+ *
73
+ * constructor(input: HelloInput) { this.input = input; }
74
+ *
75
+ * static fromJson(json: Record<string, unknown>) {
76
+ * return new SayHello(HelloInput.fromJson(json));
77
+ * }
78
+ *
79
+ * validate(): string | null {
80
+ * if (!this.input.name) return 'name is required';
81
+ * return null;
82
+ * }
83
+ *
84
+ * async execute(): Promise<void> {
85
+ * this.output = new HelloOutput(`Hello, ${this.input.name}!`);
86
+ * }
87
+ *
88
+ * toJson() { return this.output.toJson(); }
89
+ * }
90
+ * ```
91
+ */
92
+ export declare abstract class UseCase<I extends Input, O extends Output> {
93
+ /** Input DTO — set in constructor. */
94
+ abstract readonly input: I;
95
+ /** Output DTO — set in execute(). */
96
+ abstract output: O;
97
+ /**
98
+ * Synchronous validation.
99
+ * Return a human-readable error string to abort execution with HTTP 400.
100
+ * Return null to proceed.
101
+ */
102
+ abstract validate(): string | null;
103
+ /**
104
+ * Business logic. Must set `this.output` before returning.
105
+ * Keep this method free of HTTP concerns.
106
+ */
107
+ abstract execute(): Promise<void>;
108
+ /**
109
+ * Serializes the output DTO to a plain object for the HTTP response.
110
+ * Typically implemented as `return this.output.toJson();`
111
+ */
112
+ abstract toJson(): Record<string, unknown>;
113
+ }
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/usecase.ts
4
+ // Base classes: Input, Output, UseCase<I, O>
5
+ // Mirror of the Dart abstract classes in usecase.dart
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.UseCase = exports.Output = exports.Input = void 0;
9
+ /**
10
+ * **Contract** — use `implements Input`.
11
+ *
12
+ * Pure interface: all members must be provided by the implementor.
13
+ * No default behavior is inherited — every Input is self-contained.
14
+ *
15
+ * ```ts
16
+ * class HelloInput implements Input {
17
+ * constructor(readonly name: string) {}
18
+ * toJson() { return { name: this.name }; }
19
+ * toSchema() { return { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }; }
20
+ * }
21
+ * ```
22
+ */
23
+ class Input {
24
+ }
25
+ exports.Input = Input;
26
+ /**
27
+ * **Contract** — use `implements Output`.
28
+ *
29
+ * Pure interface: all members must be provided by the implementor.
30
+ * The implementor must define `statusCode` explicitly — this forces
31
+ * developers to think about HTTP status codes for every response.
32
+ *
33
+ * ```ts
34
+ * class HelloOutput implements Output {
35
+ * constructor(readonly message: string) {}
36
+ * get statusCode() { return 200; }
37
+ * toJson() { return { message: this.message }; }
38
+ * toSchema() { return { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] }; }
39
+ * }
40
+ * ```
41
+ */
42
+ class Output {
43
+ }
44
+ exports.Output = Output;
45
+ /**
46
+ * **Contract** — use `implements UseCase<I, O>`.
47
+ *
48
+ * Pure interface: all members must be provided by the implementor.
49
+ * This mirrors the Dart version where UseCase is 100% abstract.
50
+ *
51
+ * Lifecycle (handled by the framework):
52
+ * 1. `fromJson(json)` — static factory, builds the use case
53
+ * 2. `validate()` — return error string or null
54
+ * 3. `execute()` — run business logic, set `this.output`
55
+ * 4. `output.toJson()` — serialize and return to HTTP client
56
+ *
57
+ * ```ts
58
+ * class SayHello implements UseCase<HelloInput, HelloOutput> {
59
+ * input: HelloInput;
60
+ * output!: HelloOutput;
61
+ *
62
+ * constructor(input: HelloInput) { this.input = input; }
63
+ *
64
+ * static fromJson(json: Record<string, unknown>) {
65
+ * return new SayHello(HelloInput.fromJson(json));
66
+ * }
67
+ *
68
+ * validate(): string | null {
69
+ * if (!this.input.name) return 'name is required';
70
+ * return null;
71
+ * }
72
+ *
73
+ * async execute(): Promise<void> {
74
+ * this.output = new HelloOutput(`Hello, ${this.input.name}!`);
75
+ * }
76
+ *
77
+ * toJson() { return this.output.toJson(); }
78
+ * }
79
+ * ```
80
+ */
81
+ class UseCase {
82
+ }
83
+ exports.UseCase = UseCase;
@@ -0,0 +1,22 @@
1
+ import type { RequestHandler } from 'express';
2
+ import type { UseCaseFactory, Input, Output } from './usecase';
3
+ /**
4
+ * Wraps any UseCase factory into an Express RequestHandler.
5
+ *
6
+ * Lifecycle (mirrors Dart useCaseHttpHandler):
7
+ * 1. Parse body (POST/PUT/PATCH) or query params (GET/DELETE)
8
+ * 2. Build UseCase via factory(json)
9
+ * 3. Call validate() — return 400 if error string returned
10
+ * 4. Call execute()
11
+ * 5. Return output.toJson() with output.statusCode
12
+ *
13
+ * Errors:
14
+ * - UseCaseException → statusCode from exception, structured JSON body
15
+ * - Any other Error → 500 Internal Server Error
16
+ *
17
+ * Usage:
18
+ * ```ts
19
+ * router.post('/hello', useCaseHandler(SayHello.fromJson));
20
+ * ```
21
+ */
22
+ export declare function useCaseHandler<I extends Input, O extends Output>(factory: UseCaseFactory<I, O>): RequestHandler;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/usecase_handler.ts
4
+ // Express RequestHandler adapter for any UseCase.
5
+ // Mirror of useCaseHttpHandler() in Dart (Shelf).
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.useCaseHandler = useCaseHandler;
9
+ const use_case_exception_1 = require("./use_case_exception");
10
+ const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8' };
11
+ /**
12
+ * Wraps any UseCase factory into an Express RequestHandler.
13
+ *
14
+ * Lifecycle (mirrors Dart useCaseHttpHandler):
15
+ * 1. Parse body (POST/PUT/PATCH) or query params (GET/DELETE)
16
+ * 2. Build UseCase via factory(json)
17
+ * 3. Call validate() — return 400 if error string returned
18
+ * 4. Call execute()
19
+ * 5. Return output.toJson() with output.statusCode
20
+ *
21
+ * Errors:
22
+ * - UseCaseException → statusCode from exception, structured JSON body
23
+ * - Any other Error → 500 Internal Server Error
24
+ *
25
+ * Usage:
26
+ * ```ts
27
+ * router.post('/hello', useCaseHandler(SayHello.fromJson));
28
+ * ```
29
+ */
30
+ function useCaseHandler(factory) {
31
+ return async (req, res) => {
32
+ try {
33
+ // 1. Extract payload
34
+ const data = req.method.toUpperCase() === 'GET' || req.method.toUpperCase() === 'DELETE'
35
+ ? { ...req.query, ...req.params }
36
+ : req.body ?? {};
37
+ // 2. Build use case
38
+ const useCase = factory(data);
39
+ // 3. Validate
40
+ const validationError = useCase.validate();
41
+ if (validationError !== null) {
42
+ res.status(400).set(JSON_HEADERS).json({ error: validationError });
43
+ return;
44
+ }
45
+ // 4. Execute
46
+ await useCase.execute();
47
+ // 5. Respond
48
+ res.status(useCase.output.statusCode).set(JSON_HEADERS).json(useCase.toJson());
49
+ }
50
+ catch (err) {
51
+ if (err instanceof use_case_exception_1.UseCaseException) {
52
+ console.error('UseCaseException:', err.toString());
53
+ res.status(err.statusCode).set(JSON_HEADERS).json(err.toJson());
54
+ return;
55
+ }
56
+ console.error('useCaseHandler unexpected error:', err);
57
+ res.status(500).set(JSON_HEADERS).json({ error: 'Internal server error' });
58
+ }
59
+ };
60
+ }
@@ -0,0 +1,24 @@
1
+ import type { UseCaseFactory, Input, Output } from './usecase';
2
+ export interface TestResponse {
3
+ statusCode: number;
4
+ body: Record<string, unknown>;
5
+ }
6
+ /**
7
+ * Executes a UseCase directly from a plain JSON object, without Express.
8
+ * Ideal for unit tests — no HTTP server needed.
9
+ *
10
+ * Mirrors the Dart `useCaseTestHandler` pattern.
11
+ *
12
+ * Returns a `TestResponse` with `statusCode` and `body` so you can assert
13
+ * on both the HTTP status and the JSON payload.
14
+ *
15
+ * Usage:
16
+ * ```ts
17
+ * import { useCaseTestHandler } from 'modular_api';
18
+ *
19
+ * const response = await useCaseTestHandler(SayHello.fromJson, { name: 'World' });
20
+ * expect(response.statusCode).toBe(200);
21
+ * expect(response.body).toEqual({ message: 'Hello, World!' });
22
+ * ```
23
+ */
24
+ export declare function useCaseTestHandler<I extends Input, O extends Output>(factory: UseCaseFactory<I, O>, input?: Record<string, unknown>): Promise<TestResponse>;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/usecase_test_handler.ts
4
+ // Test helper — runs a UseCase without an HTTP server.
5
+ // Mirror of useCaseTestHandler() in Dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.useCaseTestHandler = useCaseTestHandler;
9
+ const use_case_exception_1 = require("./use_case_exception");
10
+ /**
11
+ * Executes a UseCase directly from a plain JSON object, without Express.
12
+ * Ideal for unit tests — no HTTP server needed.
13
+ *
14
+ * Mirrors the Dart `useCaseTestHandler` pattern.
15
+ *
16
+ * Returns a `TestResponse` with `statusCode` and `body` so you can assert
17
+ * on both the HTTP status and the JSON payload.
18
+ *
19
+ * Usage:
20
+ * ```ts
21
+ * import { useCaseTestHandler } from 'modular_api';
22
+ *
23
+ * const response = await useCaseTestHandler(SayHello.fromJson, { name: 'World' });
24
+ * expect(response.statusCode).toBe(200);
25
+ * expect(response.body).toEqual({ message: 'Hello, World!' });
26
+ * ```
27
+ */
28
+ async function useCaseTestHandler(factory, input = {}) {
29
+ try {
30
+ const useCase = factory(input);
31
+ const validationError = useCase.validate();
32
+ if (validationError !== null) {
33
+ return {
34
+ statusCode: 400,
35
+ body: { error: validationError },
36
+ };
37
+ }
38
+ await useCase.execute();
39
+ return {
40
+ statusCode: useCase.output.statusCode,
41
+ body: useCase.toJson(),
42
+ };
43
+ }
44
+ catch (err) {
45
+ if (err instanceof use_case_exception_1.UseCaseException) {
46
+ return {
47
+ statusCode: err.statusCode,
48
+ body: err.toJson(),
49
+ };
50
+ }
51
+ return {
52
+ statusCode: 500,
53
+ body: { error: 'Internal server error' },
54
+ };
55
+ }
56
+ }
@@ -0,0 +1,11 @@
1
+ export { Input, Output, UseCase } from './core/usecase';
2
+ export type { UseCaseFactory } from './core/usecase';
3
+ export { UseCaseException } from './core/use_case_exception';
4
+ export { ModularApi } from './core/modular_api';
5
+ export type { ModularApiOptions } from './core/modular_api';
6
+ export { ModuleBuilder } from './core/module_builder';
7
+ export type { UseCaseOptions } from './core/module_builder';
8
+ export { useCaseTestHandler } from './core/usecase_test_handler';
9
+ export type { TestResponse } from './core/usecase_test_handler';
10
+ export { cors } from './middlewares/cors';
11
+ export type { CorsOptions } from './middlewares/cors';
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // index.ts — Public API barrel export
4
+ // This is the single entry point users import from:
5
+ // import { ModularApi, UseCase, Input, Output } from 'modular_api'
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.cors = exports.useCaseTestHandler = exports.ModuleBuilder = exports.ModularApi = exports.UseCaseException = exports.UseCase = exports.Output = exports.Input = void 0;
9
+ // Core abstractions
10
+ var usecase_1 = require("./core/usecase");
11
+ Object.defineProperty(exports, "Input", { enumerable: true, get: function () { return usecase_1.Input; } });
12
+ Object.defineProperty(exports, "Output", { enumerable: true, get: function () { return usecase_1.Output; } });
13
+ Object.defineProperty(exports, "UseCase", { enumerable: true, get: function () { return usecase_1.UseCase; } });
14
+ // Controlled error responses
15
+ var use_case_exception_1 = require("./core/use_case_exception");
16
+ Object.defineProperty(exports, "UseCaseException", { enumerable: true, get: function () { return use_case_exception_1.UseCaseException; } });
17
+ // Main orchestrator
18
+ var modular_api_1 = require("./core/modular_api");
19
+ Object.defineProperty(exports, "ModularApi", { enumerable: true, get: function () { return modular_api_1.ModularApi; } });
20
+ // Module builder (exposed for advanced / manual usage)
21
+ var module_builder_1 = require("./core/module_builder");
22
+ Object.defineProperty(exports, "ModuleBuilder", { enumerable: true, get: function () { return module_builder_1.ModuleBuilder; } });
23
+ // Test helper — use in unit tests without an HTTP server
24
+ var usecase_test_handler_1 = require("./core/usecase_test_handler");
25
+ Object.defineProperty(exports, "useCaseTestHandler", { enumerable: true, get: function () { return usecase_test_handler_1.useCaseTestHandler; } });
26
+ // Middlewares
27
+ var cors_1 = require("./middlewares/cors");
28
+ Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
@@ -0,0 +1,23 @@
1
+ import type { RequestHandler } from 'express';
2
+ export interface CorsOptions {
3
+ /** Allowed origins. Default: '*' */
4
+ origin?: string | string[];
5
+ /** Allowed methods. Default: 'GET,POST,PUT,PATCH,DELETE,OPTIONS' */
6
+ methods?: string;
7
+ /** Allowed headers. Default: 'Content-Type,Authorization' */
8
+ allowedHeaders?: string;
9
+ }
10
+ /**
11
+ * Returns an Express middleware that sets CORS headers on every response.
12
+ *
13
+ * Dart equivalent: api.use(cors())
14
+ * TypeScript: api.use(cors())
15
+ *
16
+ * Usage:
17
+ * ```ts
18
+ * const api = new ModularApi();
19
+ * api.use(cors());
20
+ * api.use(cors({ origin: 'https://myapp.com' }));
21
+ * ```
22
+ */
23
+ export declare function cors(options?: CorsOptions): RequestHandler;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // middlewares/cors.ts
4
+ // Simple CORS middleware — no external dependencies.
5
+ // Mirror of cors() in Dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.cors = cors;
9
+ /**
10
+ * Returns an Express middleware that sets CORS headers on every response.
11
+ *
12
+ * Dart equivalent: api.use(cors())
13
+ * TypeScript: api.use(cors())
14
+ *
15
+ * Usage:
16
+ * ```ts
17
+ * const api = new ModularApi();
18
+ * api.use(cors());
19
+ * api.use(cors({ origin: 'https://myapp.com' }));
20
+ * ```
21
+ */
22
+ function cors(options = {}) {
23
+ const origin = Array.isArray(options.origin)
24
+ ? options.origin.join(', ')
25
+ : (options.origin ?? '*');
26
+ const methods = options.methods ?? 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
27
+ const allowedHeaders = options.allowedHeaders ?? 'Content-Type,Authorization';
28
+ return (req, res, next) => {
29
+ res.setHeader('Access-Control-Allow-Origin', origin);
30
+ res.setHeader('Access-Control-Allow-Methods', methods);
31
+ res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
32
+ // Respond immediately to pre-flight OPTIONS requests
33
+ if (req.method === 'OPTIONS') {
34
+ res.status(204).end();
35
+ return;
36
+ }
37
+ next();
38
+ };
39
+ }
@@ -0,0 +1,22 @@
1
+ interface OpenApiOptions {
2
+ title: string;
3
+ port: number;
4
+ version?: string;
5
+ description?: string;
6
+ servers?: Array<{
7
+ url: string;
8
+ description?: string;
9
+ }>;
10
+ }
11
+ /**
12
+ * Builds a full OpenAPI 3.0 specification object from all registered use
13
+ * cases in `apiRegistry`. Called by ModularApi.serve() automatically.
14
+ *
15
+ * Each use case contributes:
16
+ * - A path entry (e.g. POST /api/users/create)
17
+ * - requestBody schema from Input.toSchema()
18
+ * - response schema from Output.toSchema()
19
+ * - summary and tags from UseCaseDocMeta
20
+ */
21
+ export declare function buildOpenApiSpec(options: OpenApiOptions): Record<string, unknown>;
22
+ export {};
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // openapi/openapi.ts
4
+ // Builds an OpenAPI 3.0 spec from the global registry.
5
+ // Mirror of OpenApi.init() / OpenApi.docs in Dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.buildOpenApiSpec = buildOpenApiSpec;
9
+ const registry_1 = require("../core/registry");
10
+ /**
11
+ * Builds a full OpenAPI 3.0 specification object from all registered use
12
+ * cases in `apiRegistry`. Called by ModularApi.serve() automatically.
13
+ *
14
+ * Each use case contributes:
15
+ * - A path entry (e.g. POST /api/users/create)
16
+ * - requestBody schema from Input.toSchema()
17
+ * - response schema from Output.toSchema()
18
+ * - summary and tags from UseCaseDocMeta
19
+ */
20
+ function buildOpenApiSpec(options) {
21
+ const { title, port, version = '0.1.0', description = 'Auto-generated by modular-api', servers, } = options;
22
+ const defaultServers = [
23
+ { url: `http://localhost:${port}`, description: 'Local' },
24
+ ];
25
+ const paths = {};
26
+ for (const route of registry_1.apiRegistry.routes) {
27
+ const method = route.method.toLowerCase();
28
+ const path = route.path;
29
+ // Use schemas captured at registration time
30
+ const inputSchema = route.schemas.input;
31
+ const outputSchema = route.schemas.output;
32
+ if (!paths[path]) {
33
+ paths[path] = {};
34
+ }
35
+ paths[path][method] = {
36
+ summary: route.doc?.summary,
37
+ description: route.doc?.description,
38
+ tags: route.doc?.tags ?? [route.module],
39
+ ...(method !== 'get' && method !== 'delete'
40
+ ? {
41
+ requestBody: {
42
+ required: true,
43
+ content: {
44
+ 'application/json': {
45
+ schema: inputSchema,
46
+ },
47
+ },
48
+ },
49
+ }
50
+ : {
51
+ parameters: _schemaToQueryParams(inputSchema),
52
+ }),
53
+ responses: {
54
+ '200': {
55
+ description: 'Successful response',
56
+ content: {
57
+ 'application/json': {
58
+ schema: outputSchema,
59
+ },
60
+ },
61
+ },
62
+ '400': {
63
+ description: 'Validation error',
64
+ content: {
65
+ 'application/json': {
66
+ schema: {
67
+ type: 'object',
68
+ properties: { error: { type: 'string' } },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ '500': {
74
+ description: 'Internal server error',
75
+ },
76
+ },
77
+ };
78
+ }
79
+ return {
80
+ openapi: '3.0.0',
81
+ info: { title, version, description },
82
+ servers: servers ?? defaultServers,
83
+ paths,
84
+ };
85
+ }
86
+ /** Converts a JSON Schema object properties to OpenAPI query parameters */
87
+ function _schemaToQueryParams(schema) {
88
+ const properties = schema['properties'] ?? {};
89
+ const required = schema['required'] ?? [];
90
+ return Object.entries(properties).map(([name, propSchema]) => ({
91
+ name,
92
+ in: 'query',
93
+ required: required.includes(name),
94
+ schema: propSchema,
95
+ }));
96
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@macss/modular-api",
3
+ "version": "0.1.0",
4
+ "description": "Use-case-centric toolkit for building modular APIs with Express. Define UseCase classes (input → validate → execute → output), connect them to HTTP routes, and expose Swagger/OpenAPI documentation automatically.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc --project tsconfig.build.json",
14
+ "dev": "ts-node src/index.ts",
15
+ "test": "echo \"No tests yet\" && exit 0"
16
+ },
17
+ "keywords": [
18
+ "api",
19
+ "usecase",
20
+ "openapi",
21
+ "express",
22
+ "modular"
23
+ ],
24
+ "author": "ccisne.dev",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "express": "^4.22.1",
28
+ "swagger-ui-express": "^5.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "^5.0.6",
32
+ "@types/node": "^22.19.11",
33
+ "@types/swagger-ui-express": "^4.1.8",
34
+ "ts-node": "^10.9.2",
35
+ "typescript": "^5.9.3"
36
+ }
37
+ }