@macss/modular-api 0.3.0 → 0.4.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 +68 -7
- package/dist/core/modular_api.js +5 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +7 -4
- package/dist/openapi/openapi.d.ts +18 -0
- package/dist/openapi/openapi.js +137 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,9 @@ curl -X POST http://localhost:8080/api/greetings/hello \
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
**Docs** → `http://localhost:8080/docs`
|
|
39
|
-
**Health** → `http://localhost:8080/health`
|
|
39
|
+
**Health** → `http://localhost:8080/health`
|
|
40
|
+
**OpenAPI JSON** → `http://localhost:8080/openapi.json` _(also /openapi.yaml)_
|
|
41
|
+
**Metrics** → `http://localhost:8080/metrics` _(opt-in)_
|
|
40
42
|
|
|
41
43
|
See `example/example.ts` for the full implementation including Input, Output, UseCase with `validate()`, and the builder.
|
|
42
44
|
|
|
@@ -49,9 +51,10 @@ See `example/example.ts` for the full implementation including Input, Output, Us
|
|
|
49
51
|
- `Output.statusCode` — custom HTTP status codes per response
|
|
50
52
|
- `UseCaseException` — structured error handling (status code, message, error code, details)
|
|
51
53
|
- `ModularApi` + `ModuleBuilder` — module registration and routing
|
|
52
|
-
-
|
|
54
|
+
- Constructor-based unit testing with fake dependency injection
|
|
53
55
|
- `cors()` middleware — built-in CORS support
|
|
54
56
|
- Swagger UI at `/docs` — auto-generated from registered use cases
|
|
57
|
+
- OpenAPI spec at `/openapi.json` and `/openapi.yaml` — raw spec download
|
|
55
58
|
- Health check at `GET /health` — [IETF Health Check Response Format](doc/health_check_guide.md)
|
|
56
59
|
- Prometheus metrics at `GET /metrics` — [Prometheus exposition format](doc/metrics_guide.md)
|
|
57
60
|
- Structured JSON logging — Loki/Grafana compatible, [request-scoped with trace_id](doc/logger_guide.md)
|
|
@@ -92,16 +95,74 @@ async execute() {
|
|
|
92
95
|
|
|
93
96
|
## Testing
|
|
94
97
|
|
|
98
|
+
Write true unit tests by injecting fake dependencies directly through the constructor.
|
|
99
|
+
No HTTP server or real infrastructure needed.
|
|
100
|
+
|
|
95
101
|
```ts
|
|
96
|
-
import {
|
|
102
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
103
|
+
import { UseCaseException } from 'modular_api';
|
|
104
|
+
|
|
105
|
+
// ─── Fake ────────────────────────────────────────────────────
|
|
106
|
+
class FakeGreetingRepository implements GreetingRepository {
|
|
107
|
+
saved: string[] = [];
|
|
108
|
+
|
|
109
|
+
async save(name: string): Promise<void> {
|
|
110
|
+
this.saved.push(name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Tests ───────────────────────────────────────────────────
|
|
115
|
+
describe('SayHello', () => {
|
|
116
|
+
let fakeRepo: FakeGreetingRepository;
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
fakeRepo = new FakeGreetingRepository();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('greets correctly', async () => {
|
|
123
|
+
const usecase = new SayHello(new SayHelloInput('World'), { repository: fakeRepo });
|
|
124
|
+
|
|
125
|
+
expect(usecase.validate()).toBeNull();
|
|
126
|
+
|
|
127
|
+
const output = await usecase.execute();
|
|
97
128
|
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
expect(output.message).toBe('Hello, World!');
|
|
130
|
+
expect(fakeRepo.saved).toContain('World');
|
|
131
|
+
});
|
|
100
132
|
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
it('rejects empty name', () => {
|
|
134
|
+
const usecase = new SayHello(new SayHelloInput(''), { repository: fakeRepo });
|
|
135
|
+
|
|
136
|
+
expect(usecase.validate()).not.toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('throws UseCaseException when repo fails', async () => {
|
|
140
|
+
const failingRepo = {
|
|
141
|
+
save: async () => {
|
|
142
|
+
throw new Error('DB error');
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const usecase = new SayHello(new SayHelloInput('World'), { repository: failingRepo });
|
|
147
|
+
|
|
148
|
+
await expect(usecase.execute()).rejects.toThrow(UseCaseException);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
103
151
|
```
|
|
104
152
|
|
|
153
|
+
For integration tests against real infrastructure, use `UseCase.fromJson()` directly
|
|
154
|
+
(no helper wrapper needed):
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
it('integration — end to end with real DB', async () => {
|
|
158
|
+
const usecase = SayHello.fromJson({ name: 'World' });
|
|
159
|
+
await usecase.execute();
|
|
160
|
+
expect(usecase.output.message).toBe('Hello, World!');
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
See [doc/testing_guide.md](doc/testing_guide.md) for the full guide.
|
|
165
|
+
|
|
105
166
|
---
|
|
106
167
|
|
|
107
168
|
## Architecture
|
package/dist/core/modular_api.js
CHANGED
|
@@ -175,9 +175,14 @@ class ModularApi {
|
|
|
175
175
|
// Swagger / OpenAPI docs
|
|
176
176
|
const spec = (0, openapi_1.buildOpenApiSpec)({ title: this.title, port });
|
|
177
177
|
this.app.use('/docs', swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(spec));
|
|
178
|
+
// Raw spec endpoints
|
|
179
|
+
this.app.get('/openapi.json', (0, openapi_1.openApiJsonHandler)(spec));
|
|
180
|
+
this.app.get('/openapi.yaml', (0, openapi_1.openApiYamlHandler)(spec));
|
|
178
181
|
const server = this.app.listen(port, host, () => {
|
|
179
182
|
console.log(`Docs → http://localhost:${port}/docs`);
|
|
180
183
|
console.log(`Health → http://localhost:${port}/health`);
|
|
184
|
+
console.log(`OpenAPI JSON → http://localhost:${port}/openapi.json`);
|
|
185
|
+
console.log(`OpenAPI YAML → http://localhost:${port}/openapi.yaml`);
|
|
181
186
|
if (this.metricsEnabled) {
|
|
182
187
|
console.log(`Metrics → http://localhost:${port}${this.metricsPath}`);
|
|
183
188
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,6 @@ export { ModularApi } from './core/modular_api';
|
|
|
5
5
|
export type { ModularApiOptions } from './core/modular_api';
|
|
6
6
|
export { ModuleBuilder } from './core/module_builder';
|
|
7
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
8
|
export { cors } from './middlewares/cors';
|
|
11
9
|
export type { CorsOptions } from './middlewares/cors';
|
|
12
10
|
export { HealthCheck, HealthCheckResult } from './core/health/health_check';
|
|
@@ -21,3 +19,4 @@ export { LogLevel, RequestScopedLogger } from './core/logger/logger';
|
|
|
21
19
|
export type { ModularLogger } from './core/logger/logger';
|
|
22
20
|
export { loggingMiddleware, LOGGER_LOCALS_KEY } from './core/logger/logging_middleware';
|
|
23
21
|
export type { LoggingMiddlewareOptions } from './core/logger/logging_middleware';
|
|
22
|
+
export { buildOpenApiSpec, jsonToYaml, openApiJsonHandler, openApiYamlHandler, } from './openapi/openapi';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// import { ModularApi, UseCase, Input, Output } from 'modular_api'
|
|
6
6
|
// ============================================================
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.LOGGER_LOCALS_KEY = exports.loggingMiddleware = exports.RequestScopedLogger = exports.LogLevel = exports.metricsHandler = exports.metricsMiddleware = exports.MetricsRegistrar = exports.MetricRegistry = exports.healthHandler = exports.HealthResponse = exports.HealthService = exports.HealthCheckResult = exports.HealthCheck = exports.cors = exports.
|
|
8
|
+
exports.openApiYamlHandler = exports.openApiJsonHandler = exports.jsonToYaml = exports.buildOpenApiSpec = exports.LOGGER_LOCALS_KEY = exports.loggingMiddleware = exports.RequestScopedLogger = exports.LogLevel = exports.metricsHandler = exports.metricsMiddleware = exports.MetricsRegistrar = exports.MetricRegistry = exports.healthHandler = exports.HealthResponse = exports.HealthService = exports.HealthCheckResult = exports.HealthCheck = exports.cors = exports.ModuleBuilder = exports.ModularApi = exports.UseCaseException = exports.UseCase = exports.Output = exports.Input = void 0;
|
|
9
9
|
// Core abstractions
|
|
10
10
|
var usecase_1 = require("./core/usecase");
|
|
11
11
|
Object.defineProperty(exports, "Input", { enumerable: true, get: function () { return usecase_1.Input; } });
|
|
@@ -20,9 +20,6 @@ Object.defineProperty(exports, "ModularApi", { enumerable: true, get: function (
|
|
|
20
20
|
// Module builder (exposed for advanced / manual usage)
|
|
21
21
|
var module_builder_1 = require("./core/module_builder");
|
|
22
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
23
|
// Middlewares
|
|
27
24
|
var cors_1 = require("./middlewares/cors");
|
|
28
25
|
Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
|
|
@@ -49,3 +46,9 @@ Object.defineProperty(exports, "RequestScopedLogger", { enumerable: true, get: f
|
|
|
49
46
|
var logging_middleware_1 = require("./core/logger/logging_middleware");
|
|
50
47
|
Object.defineProperty(exports, "loggingMiddleware", { enumerable: true, get: function () { return logging_middleware_1.loggingMiddleware; } });
|
|
51
48
|
Object.defineProperty(exports, "LOGGER_LOCALS_KEY", { enumerable: true, get: function () { return logging_middleware_1.LOGGER_LOCALS_KEY; } });
|
|
49
|
+
// OpenAPI — Raw spec endpoints
|
|
50
|
+
var openapi_1 = require("./openapi/openapi");
|
|
51
|
+
Object.defineProperty(exports, "buildOpenApiSpec", { enumerable: true, get: function () { return openapi_1.buildOpenApiSpec; } });
|
|
52
|
+
Object.defineProperty(exports, "jsonToYaml", { enumerable: true, get: function () { return openapi_1.jsonToYaml; } });
|
|
53
|
+
Object.defineProperty(exports, "openApiJsonHandler", { enumerable: true, get: function () { return openapi_1.openApiJsonHandler; } });
|
|
54
|
+
Object.defineProperty(exports, "openApiYamlHandler", { enumerable: true, get: function () { return openapi_1.openApiYamlHandler; } });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
1
2
|
interface OpenApiOptions {
|
|
2
3
|
title: string;
|
|
3
4
|
port: number;
|
|
@@ -19,4 +20,21 @@ interface OpenApiOptions {
|
|
|
19
20
|
* - summary and tags from UseCaseDocMeta
|
|
20
21
|
*/
|
|
21
22
|
export declare function buildOpenApiSpec(options: OpenApiOptions): Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Converts a JSON-decoded value to a YAML string.
|
|
25
|
+
* Zero dependencies — handles objects, arrays, strings, numbers, bools, null.
|
|
26
|
+
*/
|
|
27
|
+
export declare function jsonToYaml(value: unknown, indent?: number, isRoot?: boolean): string;
|
|
28
|
+
/**
|
|
29
|
+
* Creates an Express handler that returns the OpenAPI spec as JSON.
|
|
30
|
+
*
|
|
31
|
+
* @param spec — the pre-built OpenAPI specification object
|
|
32
|
+
*/
|
|
33
|
+
export declare function openApiJsonHandler(spec: Record<string, unknown>): RequestHandler;
|
|
34
|
+
/**
|
|
35
|
+
* Creates an Express handler that returns the OpenAPI spec as YAML.
|
|
36
|
+
*
|
|
37
|
+
* @param spec — the pre-built OpenAPI specification object
|
|
38
|
+
*/
|
|
39
|
+
export declare function openApiYamlHandler(spec: Record<string, unknown>): RequestHandler;
|
|
22
40
|
export {};
|
package/dist/openapi/openapi.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// ============================================================
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.buildOpenApiSpec = buildOpenApiSpec;
|
|
9
|
+
exports.jsonToYaml = jsonToYaml;
|
|
10
|
+
exports.openApiJsonHandler = openApiJsonHandler;
|
|
11
|
+
exports.openApiYamlHandler = openApiYamlHandler;
|
|
9
12
|
const registry_1 = require("../core/registry");
|
|
10
13
|
/**
|
|
11
14
|
* Builds a full OpenAPI 3.0 specification object from all registered use
|
|
@@ -92,3 +95,137 @@ function _schemaToQueryParams(schema) {
|
|
|
92
95
|
schema: propSchema,
|
|
93
96
|
}));
|
|
94
97
|
}
|
|
98
|
+
// ============================================================
|
|
99
|
+
// JSON-to-YAML converter (zero dependencies)
|
|
100
|
+
// ============================================================
|
|
101
|
+
/** YAML reserved words that need quoting */
|
|
102
|
+
const YAML_RESERVED = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', 'y', 'n']);
|
|
103
|
+
/** Characters that require quoting in a YAML string value */
|
|
104
|
+
const YAML_SPECIAL_RE = /[:{}\[\],&*?|>!%#@`"\\]/;
|
|
105
|
+
/**
|
|
106
|
+
* Determines if a string needs quoting in YAML.
|
|
107
|
+
*/
|
|
108
|
+
function needsQuoting(s) {
|
|
109
|
+
if (s.length === 0)
|
|
110
|
+
return true;
|
|
111
|
+
if (YAML_RESERVED.has(s.toLowerCase()))
|
|
112
|
+
return true;
|
|
113
|
+
if (YAML_SPECIAL_RE.test(s))
|
|
114
|
+
return true;
|
|
115
|
+
if (/^[-? ]/.test(s))
|
|
116
|
+
return true;
|
|
117
|
+
if (!isNaN(Number(s)) && s.trim().length > 0)
|
|
118
|
+
return true;
|
|
119
|
+
if (s.includes('\n'))
|
|
120
|
+
return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
/** Formats a YAML key, quoting if necessary. */
|
|
124
|
+
function yamlKey(key) {
|
|
125
|
+
if (needsQuoting(key))
|
|
126
|
+
return `'${key.replace(/'/g, "''")}'`;
|
|
127
|
+
return key;
|
|
128
|
+
}
|
|
129
|
+
/** Writes a scalar YAML value. */
|
|
130
|
+
function yamlScalar(value) {
|
|
131
|
+
if (value === null || value === undefined)
|
|
132
|
+
return 'null';
|
|
133
|
+
if (typeof value === 'boolean')
|
|
134
|
+
return value ? 'true' : 'false';
|
|
135
|
+
if (typeof value === 'number')
|
|
136
|
+
return String(value);
|
|
137
|
+
const s = String(value);
|
|
138
|
+
if (needsQuoting(s))
|
|
139
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Converts a JSON-decoded value to a YAML string.
|
|
144
|
+
* Zero dependencies — handles objects, arrays, strings, numbers, bools, null.
|
|
145
|
+
*/
|
|
146
|
+
function jsonToYaml(value, indent = 0, isRoot = true) {
|
|
147
|
+
const pad = ' '.repeat(indent);
|
|
148
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
149
|
+
const obj = value;
|
|
150
|
+
const keys = Object.keys(obj);
|
|
151
|
+
if (keys.length === 0)
|
|
152
|
+
return '{}\n';
|
|
153
|
+
let result = isRoot ? '' : '\n';
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
const v = obj[key];
|
|
156
|
+
if (v !== null && typeof v === 'object') {
|
|
157
|
+
result += `${pad}${yamlKey(key)}:${jsonToYaml(v, indent + 1, false)}`;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
result += `${pad}${yamlKey(key)}: ${yamlScalar(v)}\n`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
if (value.length === 0)
|
|
167
|
+
return '[]\n';
|
|
168
|
+
let result = isRoot ? '' : '\n';
|
|
169
|
+
for (const item of value) {
|
|
170
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
171
|
+
const entries = Object.entries(item);
|
|
172
|
+
if (entries.length > 0) {
|
|
173
|
+
let first = true;
|
|
174
|
+
for (const [k, v] of entries) {
|
|
175
|
+
if (first) {
|
|
176
|
+
result += `${pad}- ${yamlKey(k)}:`;
|
|
177
|
+
first = false;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
result += `${pad} ${yamlKey(k)}:`;
|
|
181
|
+
}
|
|
182
|
+
if (v !== null && typeof v === 'object') {
|
|
183
|
+
result += jsonToYaml(v, indent + 2, false);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
result += ` ${yamlScalar(v)}\n`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
result += `${pad}- {}\n`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (Array.isArray(item)) {
|
|
195
|
+
result += `${pad}- ${jsonToYaml(item, indent + 1, false)}`;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
result += `${pad}- ${yamlScalar(item)}\n`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
return `${yamlScalar(value)}\n`;
|
|
204
|
+
}
|
|
205
|
+
// ============================================================
|
|
206
|
+
// Express handlers for /openapi.json and /openapi.yaml
|
|
207
|
+
// ============================================================
|
|
208
|
+
/**
|
|
209
|
+
* Creates an Express handler that returns the OpenAPI spec as JSON.
|
|
210
|
+
*
|
|
211
|
+
* @param spec — the pre-built OpenAPI specification object
|
|
212
|
+
*/
|
|
213
|
+
function openApiJsonHandler(spec) {
|
|
214
|
+
const json = JSON.stringify(spec, null, 2);
|
|
215
|
+
return (_req, res) => {
|
|
216
|
+
res.setHeader('Content-Type', 'application/json');
|
|
217
|
+
res.send(json);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Creates an Express handler that returns the OpenAPI spec as YAML.
|
|
222
|
+
*
|
|
223
|
+
* @param spec — the pre-built OpenAPI specification object
|
|
224
|
+
*/
|
|
225
|
+
function openApiYamlHandler(spec) {
|
|
226
|
+
const yaml = jsonToYaml(spec);
|
|
227
|
+
return (_req, res) => {
|
|
228
|
+
res.setHeader('Content-Type', 'application/x-yaml');
|
|
229
|
+
res.send(yaml);
|
|
230
|
+
};
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@macss/modular-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|