@macss/modular-api 0.2.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
@@ -3,7 +3,7 @@
3
3
  Use-case centric toolkit for building modular APIs with Express.
4
4
  Define `UseCase` classes (input → validate → execute → output), connect them to HTTP routes, and get automatic Swagger/OpenAPI documentation.
5
5
 
6
- > TypeScript port of [modular_api](https://pub.dev/packages/modular_api) (Dart/Shelf)
6
+ > Also available in **Dart**: [modular_api](https://pub.dev/packages/modular_api)
7
7
 
8
8
  ---
9
9
 
@@ -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,11 +51,13 @@ 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)
60
+ - Structured JSON logging — Loki/Grafana compatible, [request-scoped with trace_id](doc/logger_guide.md)
57
61
  - All endpoints default to `POST` (configurable per use case)
58
62
  - Full TypeScript declarations (`.d.ts`) included
59
63
 
@@ -62,7 +66,7 @@ See `example/example.ts` for the full implementation including Input, Output, Us
62
66
  ## Installation
63
67
 
64
68
  ```bash
65
- npm install modular_api
69
+ npm install @macss/modular-api
66
70
  ```
67
71
 
68
72
  ---
@@ -91,16 +95,74 @@ async execute() {
91
95
 
92
96
  ## Testing
93
97
 
98
+ Write true unit tests by injecting fake dependencies directly through the constructor.
99
+ No HTTP server or real infrastructure needed.
100
+
94
101
  ```ts
95
- 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();
128
+
129
+ expect(output.message).toBe('Hello, World!');
130
+ expect(fakeRepo.saved).toContain('World');
131
+ });
132
+
133
+ it('rejects empty name', () => {
134
+ const usecase = new SayHello(new SayHelloInput(''), { repository: fakeRepo });
135
+
136
+ expect(usecase.validate()).not.toBeNull();
137
+ });
96
138
 
97
- const handler = useCaseTestHandler(HelloWorld.fromJson);
98
- const response = await handler({ name: 'World' });
139
+ it('throws UseCaseException when repo fails', async () => {
140
+ const failingRepo = {
141
+ save: async () => {
142
+ throw new Error('DB error');
143
+ },
144
+ };
99
145
 
100
- console.log(response.statusCode); // 200
101
- console.log(response.body); // { message: 'Hello, World!' }
146
+ const usecase = new SayHello(new SayHelloInput('World'), { repository: failingRepo });
147
+
148
+ await expect(usecase.execute()).rejects.toThrow(UseCaseException);
149
+ });
150
+ });
151
+ ```
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
+ });
102
162
  ```
103
163
 
164
+ See [doc/testing_guide.md](doc/testing_guide.md) for the full guide.
165
+
104
166
  ---
105
167
 
106
168
  ## Architecture
@@ -116,17 +178,6 @@ HTTP Request → ModularApi → Module → UseCase → Business Logic → Output
116
178
 
117
179
  ---
118
180
 
119
- ## Dart version
120
-
121
- This is the TypeScript port. The original Dart version is available at:
122
-
123
- - **pub.dev**: [modular_api](https://pub.dev/packages/modular_api)
124
- - **GitHub**: [macss-dev/modular_api](https://github.com/macss-dev/modular_api)
125
-
126
- Both SDKs share the same architecture and API surface at v0.1.0.
127
-
128
- ---
129
-
130
181
  ## License
131
182
 
132
183
  MIT © [ccisne.dev](https://ccisne.dev)
@@ -0,0 +1,79 @@
1
+ /**
2
+ * RFC 5424 log levels in descending severity order.
3
+ *
4
+ * Filtering rule: if configured `logLevel = X`, only messages with
5
+ * `value <= X` are emitted. Higher values produce total silence.
6
+ */
7
+ export declare enum LogLevel {
8
+ emergency = 0,// system unusable
9
+ alert = 1,// immediate action required
10
+ critical = 2,// critical condition
11
+ error = 3,// operation error, 5xx
12
+ warning = 4,// abnormal condition, 4xx
13
+ notice = 5,// normal but significant
14
+ info = 6,// normal flow, 2xx/3xx
15
+ debug = 7
16
+ }
17
+ /**
18
+ * Public logger interface exposed to UseCases.
19
+ *
20
+ * Each method corresponds to an RFC 5424 severity level.
21
+ * `fields` is an optional map of structured data attached to the log entry.
22
+ */
23
+ export interface ModularLogger {
24
+ /** Request-scoped trace ID for correlation. */
25
+ readonly traceId: string;
26
+ emergency(msg: string, fields?: Record<string, unknown>): void;
27
+ alert(msg: string, fields?: Record<string, unknown>): void;
28
+ critical(msg: string, fields?: Record<string, unknown>): void;
29
+ error(msg: string, fields?: Record<string, unknown>): void;
30
+ warning(msg: string, fields?: Record<string, unknown>): void;
31
+ notice(msg: string, fields?: Record<string, unknown>): void;
32
+ info(msg: string, fields?: Record<string, unknown>): void;
33
+ debug(msg: string, fields?: Record<string, unknown>): void;
34
+ }
35
+ /** Function signature for the output sink — defaults to `console.log`. */
36
+ export type WriteFn = (line: string) => void;
37
+ /**
38
+ * Per-request logger that carries `traceId` and respects `logLevel` filtering.
39
+ *
40
+ * Created by `loggingMiddleware` for each incoming HTTP request and injected
41
+ * into the UseCase via the `logger` property.
42
+ *
43
+ * Accepts an optional `writeFn` for output — defaults to `console.log`.
44
+ * In tests, pass a capturing function to inspect output without side-effects.
45
+ */
46
+ export declare class RequestScopedLogger implements ModularLogger {
47
+ readonly traceId: string;
48
+ readonly logLevel: LogLevel;
49
+ readonly serviceName: string;
50
+ private readonly writeFn;
51
+ constructor(traceId: string, logLevel: LogLevel, serviceName: string, writeFn?: WriteFn);
52
+ emergency(msg: string, fields?: Record<string, unknown>): void;
53
+ alert(msg: string, fields?: Record<string, unknown>): void;
54
+ critical(msg: string, fields?: Record<string, unknown>): void;
55
+ error(msg: string, fields?: Record<string, unknown>): void;
56
+ warning(msg: string, fields?: Record<string, unknown>): void;
57
+ notice(msg: string, fields?: Record<string, unknown>): void;
58
+ info(msg: string, fields?: Record<string, unknown>): void;
59
+ debug(msg: string, fields?: Record<string, unknown>): void;
60
+ /** Emits a "request received" log at `info` level. */
61
+ logRequest(opts: {
62
+ method: string;
63
+ route: string;
64
+ }): void;
65
+ /** Emits a "request completed" log at the level determined by `statusCode`. */
66
+ logResponse(opts: {
67
+ method: string;
68
+ route: string;
69
+ statusCode: number;
70
+ durationMs: number;
71
+ }): void;
72
+ /** Emits an "unhandled exception" log at `error` level. No stack trace. */
73
+ logUnhandledException(opts: {
74
+ route: string;
75
+ }): void;
76
+ private log;
77
+ /** Maps HTTP status code → RFC 5424 log level. */
78
+ static levelForStatus(status: number): LogLevel;
79
+ }
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/logger/logger.ts
4
+ // LogLevel enum, ModularLogger interface, RequestScopedLogger.
5
+ // Mirror of logger.dart — RFC 5424, JSON to stdout, zero deps.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.RequestScopedLogger = exports.LogLevel = void 0;
9
+ /**
10
+ * RFC 5424 log levels in descending severity order.
11
+ *
12
+ * Filtering rule: if configured `logLevel = X`, only messages with
13
+ * `value <= X` are emitted. Higher values produce total silence.
14
+ */
15
+ var LogLevel;
16
+ (function (LogLevel) {
17
+ LogLevel[LogLevel["emergency"] = 0] = "emergency";
18
+ LogLevel[LogLevel["alert"] = 1] = "alert";
19
+ LogLevel[LogLevel["critical"] = 2] = "critical";
20
+ LogLevel[LogLevel["error"] = 3] = "error";
21
+ LogLevel[LogLevel["warning"] = 4] = "warning";
22
+ LogLevel[LogLevel["notice"] = 5] = "notice";
23
+ LogLevel[LogLevel["info"] = 6] = "info";
24
+ LogLevel[LogLevel["debug"] = 7] = "debug";
25
+ })(LogLevel || (exports.LogLevel = LogLevel = {}));
26
+ /** Human-readable name for each LogLevel value. */
27
+ const LEVEL_NAME = {
28
+ [LogLevel.emergency]: 'emergency',
29
+ [LogLevel.alert]: 'alert',
30
+ [LogLevel.critical]: 'critical',
31
+ [LogLevel.error]: 'error',
32
+ [LogLevel.warning]: 'warning',
33
+ [LogLevel.notice]: 'notice',
34
+ [LogLevel.info]: 'info',
35
+ [LogLevel.debug]: 'debug',
36
+ };
37
+ /**
38
+ * Per-request logger that carries `traceId` and respects `logLevel` filtering.
39
+ *
40
+ * Created by `loggingMiddleware` for each incoming HTTP request and injected
41
+ * into the UseCase via the `logger` property.
42
+ *
43
+ * Accepts an optional `writeFn` for output — defaults to `console.log`.
44
+ * In tests, pass a capturing function to inspect output without side-effects.
45
+ */
46
+ class RequestScopedLogger {
47
+ constructor(traceId, logLevel, serviceName, writeFn = (line) => process.stdout.write(line + '\n')) {
48
+ this.traceId = traceId;
49
+ this.logLevel = logLevel;
50
+ this.serviceName = serviceName;
51
+ this.writeFn = writeFn;
52
+ }
53
+ // ─── Public API (8 RFC 5424 levels) ──────────────────────────────
54
+ emergency(msg, fields) {
55
+ this.log(LogLevel.emergency, msg, { fields });
56
+ }
57
+ alert(msg, fields) {
58
+ this.log(LogLevel.alert, msg, { fields });
59
+ }
60
+ critical(msg, fields) {
61
+ this.log(LogLevel.critical, msg, { fields });
62
+ }
63
+ error(msg, fields) {
64
+ this.log(LogLevel.error, msg, { fields });
65
+ }
66
+ warning(msg, fields) {
67
+ this.log(LogLevel.warning, msg, { fields });
68
+ }
69
+ notice(msg, fields) {
70
+ this.log(LogLevel.notice, msg, { fields });
71
+ }
72
+ info(msg, fields) {
73
+ this.log(LogLevel.info, msg, { fields });
74
+ }
75
+ debug(msg, fields) {
76
+ this.log(LogLevel.debug, msg, { fields });
77
+ }
78
+ // ─── Framework-internal: request/response logging ────────────────
79
+ /** Emits a "request received" log at `info` level. */
80
+ logRequest(opts) {
81
+ this.log(LogLevel.info, 'request received', {
82
+ extra: { method: opts.method, route: opts.route },
83
+ });
84
+ }
85
+ /** Emits a "request completed" log at the level determined by `statusCode`. */
86
+ logResponse(opts) {
87
+ this.log(RequestScopedLogger.levelForStatus(opts.statusCode), 'request completed', {
88
+ extra: {
89
+ method: opts.method,
90
+ route: opts.route,
91
+ status: opts.statusCode,
92
+ duration_ms: opts.durationMs,
93
+ },
94
+ });
95
+ }
96
+ /** Emits an "unhandled exception" log at `error` level. No stack trace. */
97
+ logUnhandledException(opts) {
98
+ this.log(LogLevel.error, 'unhandled exception', {
99
+ extra: { route: opts.route, status: 500 },
100
+ });
101
+ }
102
+ // ─── Internal ────────────────────────────────────────────────────
103
+ log(level, msg, opts = {}) {
104
+ // Filtering: only emit if the message level <= configured logLevel.
105
+ if (level > this.logLevel)
106
+ return;
107
+ const entry = {
108
+ ts: Date.now() / 1000,
109
+ level: LEVEL_NAME[level],
110
+ severity: level,
111
+ msg,
112
+ service: this.serviceName,
113
+ trace_id: this.traceId,
114
+ };
115
+ if (opts.extra)
116
+ Object.assign(entry, opts.extra);
117
+ if (opts.fields)
118
+ entry['fields'] = opts.fields;
119
+ this.writeFn(JSON.stringify(entry));
120
+ }
121
+ /** Maps HTTP status code → RFC 5424 log level. */
122
+ static levelForStatus(status) {
123
+ if (status >= 500)
124
+ return LogLevel.error;
125
+ if (status >= 400)
126
+ return LogLevel.warning;
127
+ if (status >= 200 && status < 400)
128
+ return LogLevel.info;
129
+ return LogLevel.notice; // 1xx
130
+ }
131
+ }
132
+ exports.RequestScopedLogger = RequestScopedLogger;
@@ -0,0 +1,25 @@
1
+ import type { RequestHandler } from 'express';
2
+ import { LogLevel } from './logger';
3
+ import type { WriteFn } from './logger';
4
+ /** Key used in `res.locals` to propagate the logger to downstream handlers. */
5
+ export declare const LOGGER_LOCALS_KEY = "modularLogger";
6
+ export interface LoggingMiddlewareOptions {
7
+ logLevel: LogLevel;
8
+ serviceName: string;
9
+ excludedRoutes?: string[];
10
+ /** Override output for testing. Defaults to stdout. */
11
+ writeFn?: WriteFn;
12
+ }
13
+ /**
14
+ * Creates an Express middleware that:
15
+ *
16
+ * 1. Reads or generates a `trace_id` (from `X-Request-ID` header).
17
+ * 2. Creates a {@link RequestScopedLogger} scoped to the current request.
18
+ * 3. Emits a `"request received"` log at `info` level.
19
+ * 4. Passes the logger via `res.locals` for downstream handlers.
20
+ * 5. Emits a `"request completed"` log (level based on status code).
21
+ * 6. Returns the `X-Request-ID` header in the response.
22
+ *
23
+ * Requests whose path matches `excludedRoutes` are passed through silently.
24
+ */
25
+ export declare function loggingMiddleware(opts: LoggingMiddlewareOptions): RequestHandler;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // core/logger/logging_middleware.ts
4
+ // Express middleware — trace_id, structured JSON logs.
5
+ // Mirror of logging_middleware.dart.
6
+ // ============================================================
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.LOGGER_LOCALS_KEY = void 0;
9
+ exports.loggingMiddleware = loggingMiddleware;
10
+ const node_crypto_1 = require("node:crypto");
11
+ const logger_1 = require("./logger");
12
+ /** Key used in `res.locals` to propagate the logger to downstream handlers. */
13
+ exports.LOGGER_LOCALS_KEY = 'modularLogger';
14
+ /**
15
+ * Creates an Express middleware that:
16
+ *
17
+ * 1. Reads or generates a `trace_id` (from `X-Request-ID` header).
18
+ * 2. Creates a {@link RequestScopedLogger} scoped to the current request.
19
+ * 3. Emits a `"request received"` log at `info` level.
20
+ * 4. Passes the logger via `res.locals` for downstream handlers.
21
+ * 5. Emits a `"request completed"` log (level based on status code).
22
+ * 6. Returns the `X-Request-ID` header in the response.
23
+ *
24
+ * Requests whose path matches `excludedRoutes` are passed through silently.
25
+ */
26
+ function loggingMiddleware(opts) {
27
+ const excludedSet = new Set(opts.excludedRoutes ?? []);
28
+ return (req, res, next) => {
29
+ const path = req.path;
30
+ // Skip excluded routes (health, metrics, docs).
31
+ if (excludedSet.has(path)) {
32
+ return next();
33
+ }
34
+ // 1. Resolve trace_id
35
+ const headerValue = req.headers['x-request-id'];
36
+ const traceId = typeof headerValue === 'string' && headerValue.length > 0 ? headerValue : (0, node_crypto_1.randomUUID)();
37
+ // 2. Create per-request logger
38
+ const logger = new logger_1.RequestScopedLogger(traceId, opts.logLevel, opts.serviceName, opts.writeFn);
39
+ const method = req.method.toUpperCase();
40
+ const route = path;
41
+ // 3. "request received"
42
+ logger.logRequest({ method, route });
43
+ // 4. Propagate logger via res.locals
44
+ res.locals[exports.LOGGER_LOCALS_KEY] = logger;
45
+ // 5. Attach X-Request-ID to response
46
+ res.setHeader('X-Request-ID', traceId);
47
+ // 6. Capture timing and emit response log on finish
48
+ const startNs = process.hrtime.bigint();
49
+ res.on('finish', () => {
50
+ const durationMs = Number(process.hrtime.bigint() - startNs) / 1e6;
51
+ logger.logResponse({
52
+ method,
53
+ route,
54
+ statusCode: res.statusCode,
55
+ durationMs,
56
+ });
57
+ });
58
+ next();
59
+ };
60
+ }
@@ -2,6 +2,7 @@ import { type RequestHandler } from 'express';
2
2
  import { ModuleBuilder } from './module_builder';
3
3
  import type { HealthCheck } from './health/health_check';
4
4
  import { MetricsRegistrar } from './metrics/metric_registry';
5
+ import { LogLevel } from './logger/logger';
5
6
  export interface ModularApiOptions {
6
7
  /** Base path prefix for all module routes. Default: '/api' */
7
8
  basePath?: string;
@@ -20,6 +21,11 @@ export interface ModularApiOptions {
20
21
  metricsPath?: string;
21
22
  /** Routes excluded from instrumentation. Default: ['/metrics', '/health', '/docs'] */
22
23
  excludedMetricsRoutes?: string[];
24
+ /**
25
+ * Minimum log level for the structured JSON logger.
26
+ * Default: LogLevel.info (emits emergency..info, suppresses debug).
27
+ */
28
+ logLevel?: LogLevel;
23
29
  }
24
30
  /**
25
31
  * Main entry point for modular_api.
@@ -57,6 +63,7 @@ export declare class ModularApi {
57
63
  private readonly httpRequestsTotal?;
58
64
  private readonly httpRequestsInFlight?;
59
65
  private readonly httpRequestDuration?;
66
+ private readonly logLevel;
60
67
  /** Public accessor for custom-metric registration. Undefined when metrics are disabled. */
61
68
  get metrics(): MetricsRegistrar | undefined;
62
69
  constructor(options?: ModularApiOptions);
@@ -17,6 +17,8 @@ const health_service_1 = require("./health/health_service");
17
17
  const health_handler_1 = require("./health/health_handler");
18
18
  const metric_registry_1 = require("./metrics/metric_registry");
19
19
  const metrics_middleware_1 = require("./metrics/metrics_middleware");
20
+ const logging_middleware_1 = require("./logger/logging_middleware");
21
+ const logger_1 = require("./logger/logger");
20
22
  const registry_1 = require("./registry");
21
23
  /**
22
24
  * Main entry point for modular_api.
@@ -56,6 +58,8 @@ class ModularApi {
56
58
  this.metricsEnabled = options.metricsEnabled ?? false;
57
59
  this.metricsPath = options.metricsPath ?? '/metrics';
58
60
  this.excludedMetricsRoutes = options.excludedMetricsRoutes ?? ['/metrics', '/health', '/docs'];
61
+ // Logging
62
+ this.logLevel = options.logLevel ?? logger_1.LogLevel.info;
59
63
  if (this.metricsEnabled) {
60
64
  this.metricRegistry = new metric_registry_1.MetricRegistry();
61
65
  this._metricsRegistrar = new metric_registry_1.MetricsRegistrar(this.metricRegistry);
@@ -134,7 +138,14 @@ class ModularApi {
134
138
  serve(options) {
135
139
  const { port, host = '0.0.0.0' } = options;
136
140
  return new Promise((resolve) => {
137
- // Metrics middleware FIRST — before user middlewares & routes.
141
+ // Logging middleware FIRST — trace_id + structured JSON logs.
142
+ const excludedLogRoutes = ['/health', this.metricsPath, '/docs', '/docs/'];
143
+ this.app.use((0, logging_middleware_1.loggingMiddleware)({
144
+ logLevel: this.logLevel,
145
+ serviceName: this.title,
146
+ excludedRoutes: excludedLogRoutes,
147
+ }));
148
+ // Metrics middleware — before user middlewares & routes.
138
149
  // Created here so registeredPaths is populated from apiRegistry.
139
150
  if (this.metricsEnabled &&
140
151
  this.httpRequestsTotal &&
@@ -164,9 +175,14 @@ class ModularApi {
164
175
  // Swagger / OpenAPI docs
165
176
  const spec = (0, openapi_1.buildOpenApiSpec)({ title: this.title, port });
166
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));
167
181
  const server = this.app.listen(port, host, () => {
168
182
  console.log(`Docs → http://localhost:${port}/docs`);
169
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`);
170
186
  if (this.metricsEnabled) {
171
187
  console.log(`Metrics → http://localhost:${port}${this.metricsPath}`);
172
188
  }
@@ -1,3 +1,4 @@
1
+ import type { ModularLogger } from './logger/logger';
1
2
  /**
2
3
  * **Contract** — use `implements Input`.
3
4
  *
@@ -94,6 +95,12 @@ export declare abstract class UseCase<I extends Input, O extends Output> {
94
95
  abstract readonly input: I;
95
96
  /** Output DTO — set in execute(). */
96
97
  abstract output: O;
98
+ /**
99
+ * Request-scoped logger injected by the framework's logging middleware.
100
+ * Available inside `execute()`. Undefined when running without middleware
101
+ * or in tests that don't provide one.
102
+ */
103
+ logger?: ModularLogger;
97
104
  /**
98
105
  * Synchronous validation.
99
106
  * Return a human-readable error string to abort execution with HTTP 400.
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.useCaseHandler = useCaseHandler;
9
9
  const use_case_exception_1 = require("./use_case_exception");
10
+ const logging_middleware_1 = require("./logger/logging_middleware");
10
11
  const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8' };
11
12
  /**
12
13
  * Wraps any UseCase factory into an Express RequestHandler.
@@ -36,6 +37,11 @@ function useCaseHandler(factory) {
36
37
  : (req.body ?? {});
37
38
  // 2. Build use case
38
39
  const useCase = factory(data);
40
+ // 2b. Inject request-scoped logger (if logging middleware is active)
41
+ const logger = res.locals[logging_middleware_1.LOGGER_LOCALS_KEY];
42
+ if (logger) {
43
+ useCase.logger = logger;
44
+ }
39
45
  // 3. Validate
40
46
  const validationError = useCase.validate();
41
47
  if (validationError !== null) {
@@ -1,4 +1,5 @@
1
1
  import type { UseCaseFactory, Input, Output } from './usecase';
2
+ import type { ModularLogger } from './logger/logger';
2
3
  export interface TestResponse {
3
4
  statusCode: number;
4
5
  body: Record<string, unknown>;
@@ -21,4 +22,6 @@ export interface TestResponse {
21
22
  * expect(response.body).toEqual({ message: 'Hello, World!' });
22
23
  * ```
23
24
  */
24
- export declare function useCaseTestHandler<I extends Input, O extends Output>(factory: UseCaseFactory<I, O>, input?: Record<string, unknown>): Promise<TestResponse>;
25
+ export declare function useCaseTestHandler<I extends Input, O extends Output>(factory: UseCaseFactory<I, O>, input?: Record<string, unknown>, options?: {
26
+ logger?: ModularLogger;
27
+ }): Promise<TestResponse>;
@@ -25,9 +25,13 @@ const use_case_exception_1 = require("./use_case_exception");
25
25
  * expect(response.body).toEqual({ message: 'Hello, World!' });
26
26
  * ```
27
27
  */
28
- async function useCaseTestHandler(factory, input = {}) {
28
+ async function useCaseTestHandler(factory, input = {}, options) {
29
29
  try {
30
30
  const useCase = factory(input);
31
+ // Inject logger if provided
32
+ if (options?.logger) {
33
+ useCase.logger = options.logger;
34
+ }
31
35
  const validationError = useCase.validate();
32
36
  if (validationError !== null) {
33
37
  return {
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';
@@ -17,3 +15,8 @@ export { healthHandler } from './core/health/health_handler';
17
15
  export { MetricRegistry, MetricsRegistrar } from './core/metrics/metric_registry';
18
16
  export { metricsMiddleware, metricsHandler } from './core/metrics/metrics_middleware';
19
17
  export type { MetricsMiddlewareOptions } from './core/metrics/metrics_middleware';
18
+ export { LogLevel, RequestScopedLogger } from './core/logger/logger';
19
+ export type { ModularLogger } from './core/logger/logger';
20
+ export { loggingMiddleware, LOGGER_LOCALS_KEY } from './core/logger/logging_middleware';
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.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; } });
@@ -42,3 +39,16 @@ Object.defineProperty(exports, "MetricsRegistrar", { enumerable: true, get: func
42
39
  var metrics_middleware_1 = require("./core/metrics/metrics_middleware");
43
40
  Object.defineProperty(exports, "metricsMiddleware", { enumerable: true, get: function () { return metrics_middleware_1.metricsMiddleware; } });
44
41
  Object.defineProperty(exports, "metricsHandler", { enumerable: true, get: function () { return metrics_middleware_1.metricsHandler; } });
42
+ // Logger — Structured JSON logging (Loki/Grafana compatible)
43
+ var logger_1 = require("./core/logger/logger");
44
+ Object.defineProperty(exports, "LogLevel", { enumerable: true, get: function () { return logger_1.LogLevel; } });
45
+ Object.defineProperty(exports, "RequestScopedLogger", { enumerable: true, get: function () { return logger_1.RequestScopedLogger; } });
46
+ var logging_middleware_1 = require("./core/logger/logging_middleware");
47
+ Object.defineProperty(exports, "loggingMiddleware", { enumerable: true, get: function () { return logging_middleware_1.loggingMiddleware; } });
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.2.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",