@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 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
- - `useCaseTestHandler` unit test helper (no HTTP server needed)
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 { useCaseTestHandler } from 'modular_api';
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
- const handler = useCaseTestHandler(HelloWorld.fromJson);
99
- const response = await handler({ name: 'World' });
129
+ expect(output.message).toBe('Hello, World!');
130
+ expect(fakeRepo.saved).toContain('World');
131
+ });
100
132
 
101
- console.log(response.statusCode); // 200
102
- console.log(response.body); // { message: 'Hello, World!' }
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
@@ -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.useCaseTestHandler = exports.ModuleBuilder = exports.ModularApi = exports.UseCaseException = exports.UseCase = exports.Output = exports.Input = void 0;
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 {};
@@ -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.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",