@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 +131 -0
- package/dist/core/modular_api.d.ts +74 -0
- package/dist/core/modular_api.js +108 -0
- package/dist/core/module_builder.d.ts +48 -0
- package/dist/core/module_builder.js +94 -0
- package/dist/core/registry.d.ts +37 -0
- package/dist/core/registry.js +22 -0
- package/dist/core/use_case_exception.d.ts +36 -0
- package/dist/core/use_case_exception.js +55 -0
- package/dist/core/usecase.d.ts +113 -0
- package/dist/core/usecase.js +83 -0
- package/dist/core/usecase_handler.d.ts +22 -0
- package/dist/core/usecase_handler.js +60 -0
- package/dist/core/usecase_test_handler.d.ts +24 -0
- package/dist/core/usecase_test_handler.js +56 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +28 -0
- package/dist/middlewares/cors.d.ts +23 -0
- package/dist/middlewares/cors.js +39 -0
- package/dist/openapi/openapi.d.ts +22 -0
- package/dist/openapi/openapi.js +96 -0
- package/package.json +37 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|