@macss/modular-api 0.1.0 → 0.2.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 +132 -131
- package/dist/core/health/health_check.d.ts +50 -0
- package/dist/core/health/health_check.js +69 -0
- package/dist/core/health/health_handler.d.ts +9 -0
- package/dist/core/health/health_handler.js +23 -0
- package/dist/core/health/health_service.d.ts +60 -0
- package/dist/core/health/health_service.js +132 -0
- package/dist/core/metrics/metric_registry.d.ts +32 -0
- package/dist/core/metrics/metric_registry.js +104 -0
- package/dist/core/metrics/metrics_middleware.d.ts +24 -0
- package/dist/core/metrics/metrics_middleware.js +51 -0
- package/dist/core/modular_api.d.ts +36 -1
- package/dist/core/modular_api.js +75 -5
- package/dist/core/usecase_handler.js +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -1
- package/dist/openapi/openapi.js +1 -3
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,131 +1,132 @@
|
|
|
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
|
-
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.log(response.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
- **
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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` — [IETF Health Check Response Format](doc/health_check_guide.md)
|
|
56
|
+
- Prometheus metrics at `GET /metrics` — [Prometheus exposition format](doc/metrics_guide.md)
|
|
57
|
+
- All endpoints default to `POST` (configurable per use case)
|
|
58
|
+
- Full TypeScript declarations (`.d.ts`) included
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install modular_api
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Error handling
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
async execute() {
|
|
74
|
+
const user = await repository.findById(this.input.userId);
|
|
75
|
+
if (!user) {
|
|
76
|
+
throw new UseCaseException({
|
|
77
|
+
statusCode: 404,
|
|
78
|
+
message: 'User not found',
|
|
79
|
+
errorCode: 'USER_NOT_FOUND',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
this.output = new GetUserOutput(user);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "error": "USER_NOT_FOUND", "message": "User not found" }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { useCaseTestHandler } from 'modular_api';
|
|
96
|
+
|
|
97
|
+
const handler = useCaseTestHandler(HelloWorld.fromJson);
|
|
98
|
+
const response = await handler({ name: 'World' });
|
|
99
|
+
|
|
100
|
+
console.log(response.statusCode); // 200
|
|
101
|
+
console.log(response.body); // { message: 'Hello, World!' }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Architecture
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
HTTP Request → ModularApi → Module → UseCase → Business Logic → Output → HTTP Response
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **UseCase layer** — pure logic, independent of HTTP
|
|
113
|
+
- **HTTP adapter** — turns a UseCase into an Express RequestHandler
|
|
114
|
+
- **Middlewares** — cross-cutting concerns (CORS, logging)
|
|
115
|
+
- **Swagger UI** — documentation served automatically
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
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
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT © [ccisne.dev](https://ccisne.dev)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health status values.
|
|
3
|
+
*
|
|
4
|
+
* - `'pass'` — The component is healthy.
|
|
5
|
+
* - `'warn'` — The component is healthy but has a warning condition.
|
|
6
|
+
* - `'fail'` — The component is unhealthy.
|
|
7
|
+
*/
|
|
8
|
+
export type HealthStatus = 'pass' | 'warn' | 'fail';
|
|
9
|
+
/** Compare two statuses and return the worse one. */
|
|
10
|
+
export declare function worstStatus(a: HealthStatus, b: HealthStatus): HealthStatus;
|
|
11
|
+
/**
|
|
12
|
+
* Result returned by a single {@link HealthCheck}.
|
|
13
|
+
*/
|
|
14
|
+
export declare class HealthCheckResult {
|
|
15
|
+
readonly status: HealthStatus;
|
|
16
|
+
readonly responseTime?: number;
|
|
17
|
+
readonly output?: string;
|
|
18
|
+
constructor(status: HealthStatus, options?: {
|
|
19
|
+
responseTime?: number;
|
|
20
|
+
output?: string;
|
|
21
|
+
});
|
|
22
|
+
/** Return a copy with `responseTime` set. */
|
|
23
|
+
withResponseTime(ms: number): HealthCheckResult;
|
|
24
|
+
/** Serialize to the IETF JSON structure. Only includes optional fields when defined. */
|
|
25
|
+
toJson(): Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Abstract base for custom health checks.
|
|
29
|
+
*
|
|
30
|
+
* Implementors must provide `name` and `check()`.
|
|
31
|
+
* Override `timeout` to change the default 5 000 ms deadline.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* class DatabaseHealthCheck extends HealthCheck {
|
|
35
|
+
* readonly name = 'database';
|
|
36
|
+
* async check() {
|
|
37
|
+
* await db.ping();
|
|
38
|
+
* return new HealthCheckResult('pass');
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare abstract class HealthCheck {
|
|
44
|
+
/** Display name used as the key in the `checks` map. */
|
|
45
|
+
abstract readonly name: string;
|
|
46
|
+
/** Maximum time (ms) before the check is considered failed. Default: 5 000. */
|
|
47
|
+
get timeout(): number;
|
|
48
|
+
/** Execute the health check and return a result. */
|
|
49
|
+
abstract check(): Promise<HealthCheckResult>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// core/health/health_check.ts
|
|
4
|
+
// Health check types — IETF Health Check Response Format draft.
|
|
5
|
+
// Mirror of health_check.dart in Dart.
|
|
6
|
+
// ============================================================
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.HealthCheck = exports.HealthCheckResult = void 0;
|
|
9
|
+
exports.worstStatus = worstStatus;
|
|
10
|
+
/** Severity order for worst-status-wins aggregation. */
|
|
11
|
+
const SEVERITY = {
|
|
12
|
+
pass: 0,
|
|
13
|
+
warn: 1,
|
|
14
|
+
fail: 2,
|
|
15
|
+
};
|
|
16
|
+
/** Compare two statuses and return the worse one. */
|
|
17
|
+
function worstStatus(a, b) {
|
|
18
|
+
return SEVERITY[a] >= SEVERITY[b] ? a : b;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Result returned by a single {@link HealthCheck}.
|
|
22
|
+
*/
|
|
23
|
+
class HealthCheckResult {
|
|
24
|
+
constructor(status, options) {
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.responseTime = options?.responseTime;
|
|
27
|
+
this.output = options?.output;
|
|
28
|
+
}
|
|
29
|
+
/** Return a copy with `responseTime` set. */
|
|
30
|
+
withResponseTime(ms) {
|
|
31
|
+
return new HealthCheckResult(this.status, {
|
|
32
|
+
responseTime: ms,
|
|
33
|
+
output: this.output,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Serialize to the IETF JSON structure. Only includes optional fields when defined. */
|
|
37
|
+
toJson() {
|
|
38
|
+
const json = { status: this.status };
|
|
39
|
+
if (this.responseTime !== undefined)
|
|
40
|
+
json.responseTime = this.responseTime;
|
|
41
|
+
if (this.output !== undefined)
|
|
42
|
+
json.output = this.output;
|
|
43
|
+
return json;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.HealthCheckResult = HealthCheckResult;
|
|
47
|
+
/**
|
|
48
|
+
* Abstract base for custom health checks.
|
|
49
|
+
*
|
|
50
|
+
* Implementors must provide `name` and `check()`.
|
|
51
|
+
* Override `timeout` to change the default 5 000 ms deadline.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* class DatabaseHealthCheck extends HealthCheck {
|
|
55
|
+
* readonly name = 'database';
|
|
56
|
+
* async check() {
|
|
57
|
+
* await db.ping();
|
|
58
|
+
* return new HealthCheckResult('pass');
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
class HealthCheck {
|
|
64
|
+
/** Maximum time (ms) before the check is considered failed. Default: 5 000. */
|
|
65
|
+
get timeout() {
|
|
66
|
+
return 5000;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.HealthCheck = HealthCheck;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
import type { HealthService } from './health_service';
|
|
3
|
+
/**
|
|
4
|
+
* Creates an Express request handler that responds to `GET /health`
|
|
5
|
+
* with `application/health+json` following the IETF draft.
|
|
6
|
+
*
|
|
7
|
+
* Returns 200 for pass/warn, 503 for fail.
|
|
8
|
+
*/
|
|
9
|
+
export declare function healthHandler(service: HealthService): RequestHandler;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// core/health/health_handler.ts
|
|
4
|
+
// Express handler for GET /health — application/health+json.
|
|
5
|
+
// Mirror of health_handler.dart in Dart.
|
|
6
|
+
// ============================================================
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.healthHandler = healthHandler;
|
|
9
|
+
/**
|
|
10
|
+
* Creates an Express request handler that responds to `GET /health`
|
|
11
|
+
* with `application/health+json` following the IETF draft.
|
|
12
|
+
*
|
|
13
|
+
* Returns 200 for pass/warn, 503 for fail.
|
|
14
|
+
*/
|
|
15
|
+
function healthHandler(service) {
|
|
16
|
+
return async (_req, res) => {
|
|
17
|
+
const response = await service.evaluate();
|
|
18
|
+
res
|
|
19
|
+
.status(response.httpStatusCode)
|
|
20
|
+
.set('Content-Type', 'application/health+json')
|
|
21
|
+
.json(response.toJson());
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type HealthStatus, HealthCheck, HealthCheckResult } from './health_check';
|
|
2
|
+
/**
|
|
3
|
+
* Aggregated health response following the IETF Health Check Response Format.
|
|
4
|
+
*
|
|
5
|
+
* `httpStatusCode`: 200 for pass/warn, 503 for fail.
|
|
6
|
+
*/
|
|
7
|
+
export declare class HealthResponse {
|
|
8
|
+
readonly status: HealthStatus;
|
|
9
|
+
readonly version: string;
|
|
10
|
+
readonly releaseId: string;
|
|
11
|
+
readonly checks: Record<string, HealthCheckResult>;
|
|
12
|
+
constructor(options: {
|
|
13
|
+
status: HealthStatus;
|
|
14
|
+
version: string;
|
|
15
|
+
releaseId: string;
|
|
16
|
+
checks: Record<string, HealthCheckResult>;
|
|
17
|
+
});
|
|
18
|
+
/** HTTP status code: 200 for pass/warn, 503 for fail. */
|
|
19
|
+
get httpStatusCode(): number;
|
|
20
|
+
/** Serialize to the IETF-compliant JSON structure. */
|
|
21
|
+
toJson(): Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
export interface HealthServiceOptions {
|
|
24
|
+
/** API version string (e.g. '1.0.0'). */
|
|
25
|
+
version: string;
|
|
26
|
+
/**
|
|
27
|
+
* Release identifier. Defaults to `version-debug`.
|
|
28
|
+
* Override via `process.env.RELEASE_ID`.
|
|
29
|
+
*/
|
|
30
|
+
releaseId?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Service that manages and evaluates {@link HealthCheck}s.
|
|
34
|
+
*
|
|
35
|
+
* Checks are executed in parallel with per-check timeout.
|
|
36
|
+
* The overall status uses worst-status-wins aggregation: fail > warn > pass.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const service = new HealthService({ version: '1.0.0' });
|
|
40
|
+
* service.addHealthCheck(new DatabaseHealthCheck());
|
|
41
|
+
* const response = await service.evaluate();
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare class HealthService {
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly releaseId: string;
|
|
47
|
+
private readonly checks;
|
|
48
|
+
constructor(options: HealthServiceOptions);
|
|
49
|
+
/** Register a health check to be evaluated on each call to {@link evaluate}. */
|
|
50
|
+
addHealthCheck(check: HealthCheck): void;
|
|
51
|
+
/**
|
|
52
|
+
* Execute all registered checks in parallel and return an aggregated
|
|
53
|
+
* {@link HealthResponse}.
|
|
54
|
+
*/
|
|
55
|
+
evaluate(): Promise<HealthResponse>;
|
|
56
|
+
/** Run a single check with timeout and timing. */
|
|
57
|
+
private runCheck;
|
|
58
|
+
/** Execute a check with its configured timeout. */
|
|
59
|
+
private withTimeout;
|
|
60
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// core/health/health_service.ts
|
|
4
|
+
// HealthService — evaluates checks in parallel with timeout.
|
|
5
|
+
// Mirror of health_service.dart in Dart.
|
|
6
|
+
// ============================================================
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.HealthService = exports.HealthResponse = void 0;
|
|
9
|
+
const health_check_1 = require("./health_check");
|
|
10
|
+
/**
|
|
11
|
+
* Aggregated health response following the IETF Health Check Response Format.
|
|
12
|
+
*
|
|
13
|
+
* `httpStatusCode`: 200 for pass/warn, 503 for fail.
|
|
14
|
+
*/
|
|
15
|
+
class HealthResponse {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.status = options.status;
|
|
18
|
+
this.version = options.version;
|
|
19
|
+
this.releaseId = options.releaseId;
|
|
20
|
+
this.checks = options.checks;
|
|
21
|
+
}
|
|
22
|
+
/** HTTP status code: 200 for pass/warn, 503 for fail. */
|
|
23
|
+
get httpStatusCode() {
|
|
24
|
+
return this.status === 'fail' ? 503 : 200;
|
|
25
|
+
}
|
|
26
|
+
/** Serialize to the IETF-compliant JSON structure. */
|
|
27
|
+
toJson() {
|
|
28
|
+
const checksJson = {};
|
|
29
|
+
for (const [key, result] of Object.entries(this.checks)) {
|
|
30
|
+
checksJson[key] = result.toJson();
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
status: this.status,
|
|
34
|
+
version: this.version,
|
|
35
|
+
releaseId: this.releaseId,
|
|
36
|
+
checks: checksJson,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.HealthResponse = HealthResponse;
|
|
41
|
+
/**
|
|
42
|
+
* Service that manages and evaluates {@link HealthCheck}s.
|
|
43
|
+
*
|
|
44
|
+
* Checks are executed in parallel with per-check timeout.
|
|
45
|
+
* The overall status uses worst-status-wins aggregation: fail > warn > pass.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const service = new HealthService({ version: '1.0.0' });
|
|
49
|
+
* service.addHealthCheck(new DatabaseHealthCheck());
|
|
50
|
+
* const response = await service.evaluate();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
class HealthService {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.checks = [];
|
|
56
|
+
this.version = options.version;
|
|
57
|
+
this.releaseId = options.releaseId ?? process.env.RELEASE_ID ?? `${options.version}-debug`;
|
|
58
|
+
}
|
|
59
|
+
/** Register a health check to be evaluated on each call to {@link evaluate}. */
|
|
60
|
+
addHealthCheck(check) {
|
|
61
|
+
this.checks.push(check);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Execute all registered checks in parallel and return an aggregated
|
|
65
|
+
* {@link HealthResponse}.
|
|
66
|
+
*/
|
|
67
|
+
async evaluate() {
|
|
68
|
+
if (this.checks.length === 0) {
|
|
69
|
+
return new HealthResponse({
|
|
70
|
+
status: 'pass',
|
|
71
|
+
version: this.version,
|
|
72
|
+
releaseId: this.releaseId,
|
|
73
|
+
checks: {},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const entries = await Promise.all(this.checks.map((c) => this.runCheck(c)));
|
|
77
|
+
const checks = {};
|
|
78
|
+
for (const [name, result] of entries) {
|
|
79
|
+
checks[name] = result;
|
|
80
|
+
}
|
|
81
|
+
// Worst-status-wins: fail > warn > pass
|
|
82
|
+
const status = Object.values(checks)
|
|
83
|
+
.map((r) => r.status)
|
|
84
|
+
.reduce(health_check_1.worstStatus);
|
|
85
|
+
return new HealthResponse({
|
|
86
|
+
status,
|
|
87
|
+
version: this.version,
|
|
88
|
+
releaseId: this.releaseId,
|
|
89
|
+
checks,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Run a single check with timeout and timing. */
|
|
93
|
+
async runCheck(check) {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
try {
|
|
96
|
+
const result = await this.withTimeout(check);
|
|
97
|
+
const elapsed = Date.now() - start;
|
|
98
|
+
return [check.name, result.withResponseTime(elapsed)];
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const elapsed = Date.now() - start;
|
|
102
|
+
return [
|
|
103
|
+
check.name,
|
|
104
|
+
new health_check_1.HealthCheckResult('fail', {
|
|
105
|
+
responseTime: elapsed,
|
|
106
|
+
output: String(err),
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Execute a check with its configured timeout. */
|
|
112
|
+
withTimeout(check) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
resolve(new health_check_1.HealthCheckResult('fail', {
|
|
116
|
+
output: `Health check "${check.name}" timeout after ${check.timeout}ms`,
|
|
117
|
+
}));
|
|
118
|
+
}, check.timeout);
|
|
119
|
+
check
|
|
120
|
+
.check()
|
|
121
|
+
.then((result) => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve(result);
|
|
124
|
+
})
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
reject(err);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.HealthService = HealthService;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Registry, Counter, Gauge, Histogram, type CounterConfiguration, type GaugeConfiguration, type HistogramConfiguration } from 'prom-client';
|
|
2
|
+
/**
|
|
3
|
+
* Internal registry wrapping prom-client's `Registry`.
|
|
4
|
+
*
|
|
5
|
+
* On construction, registers `process_start_time_seconds` as a gauge
|
|
6
|
+
* set to the current epoch in seconds.
|
|
7
|
+
*/
|
|
8
|
+
export declare class MetricRegistry {
|
|
9
|
+
readonly registry: Registry;
|
|
10
|
+
private readonly names;
|
|
11
|
+
constructor();
|
|
12
|
+
createCounter<T extends string = string>(config: Pick<CounterConfiguration<T>, 'name' | 'help' | 'labelNames'>): Counter<T>;
|
|
13
|
+
createGauge<T extends string = string>(config: Pick<GaugeConfiguration<T>, 'name' | 'help' | 'labelNames'>): Gauge<T>;
|
|
14
|
+
createHistogram<T extends string = string>(config: Pick<HistogramConfiguration<T>, 'name' | 'help' | 'labelNames' | 'buckets'>): Histogram<T>;
|
|
15
|
+
/** Serializes all metrics to Prometheus text exposition format. */
|
|
16
|
+
serialize(): Promise<string>;
|
|
17
|
+
/** Content type for the Prometheus text format. */
|
|
18
|
+
get contentType(): string;
|
|
19
|
+
private assertUnique;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Public API for users to register custom metrics.
|
|
23
|
+
* Validates names and rejects reserved prefixes before delegating.
|
|
24
|
+
*/
|
|
25
|
+
export declare class MetricsRegistrar {
|
|
26
|
+
private readonly registry;
|
|
27
|
+
constructor(registry: MetricRegistry);
|
|
28
|
+
createCounter<T extends string = string>(config: Pick<CounterConfiguration<T>, 'name' | 'help' | 'labelNames'>): Counter<T>;
|
|
29
|
+
createGauge<T extends string = string>(config: Pick<GaugeConfiguration<T>, 'name' | 'help' | 'labelNames'>): Gauge<T>;
|
|
30
|
+
createHistogram<T extends string = string>(config: Pick<HistogramConfiguration<T>, 'name' | 'help' | 'labelNames' | 'buckets'>): Histogram<T>;
|
|
31
|
+
private validate;
|
|
32
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// core/metrics/metric_registry.ts
|
|
4
|
+
// Wraps prom-client with a two-layer API:
|
|
5
|
+
// MetricRegistry — internal, manages prom-client Registry
|
|
6
|
+
// MetricsRegistrar — public, validates names before delegating
|
|
7
|
+
// ============================================================
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.MetricsRegistrar = exports.MetricRegistry = void 0;
|
|
10
|
+
const prom_client_1 = require("prom-client");
|
|
11
|
+
/** Prometheus metric name regex. */
|
|
12
|
+
const VALID_NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
13
|
+
function assertValidName(name) {
|
|
14
|
+
if (!name || !VALID_NAME.test(name)) {
|
|
15
|
+
throw new Error(`Invalid metric name "${name}": must match [a-zA-Z_:][a-zA-Z0-9_:]*`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// ── MetricRegistry (internal) ─────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Internal registry wrapping prom-client's `Registry`.
|
|
21
|
+
*
|
|
22
|
+
* On construction, registers `process_start_time_seconds` as a gauge
|
|
23
|
+
* set to the current epoch in seconds.
|
|
24
|
+
*/
|
|
25
|
+
class MetricRegistry {
|
|
26
|
+
constructor() {
|
|
27
|
+
this.names = new Set();
|
|
28
|
+
this.registry = new prom_client_1.Registry();
|
|
29
|
+
// Auto-register process_start_time_seconds.
|
|
30
|
+
const startTime = this.createGauge({
|
|
31
|
+
name: 'process_start_time_seconds',
|
|
32
|
+
help: 'Start time of the process since unix epoch in seconds.',
|
|
33
|
+
});
|
|
34
|
+
startTime.set(Date.now() / 1000);
|
|
35
|
+
}
|
|
36
|
+
createCounter(config) {
|
|
37
|
+
this.assertUnique(config.name);
|
|
38
|
+
const counter = new prom_client_1.Counter({
|
|
39
|
+
...config,
|
|
40
|
+
registers: [this.registry],
|
|
41
|
+
});
|
|
42
|
+
return counter;
|
|
43
|
+
}
|
|
44
|
+
createGauge(config) {
|
|
45
|
+
this.assertUnique(config.name);
|
|
46
|
+
const gauge = new prom_client_1.Gauge({
|
|
47
|
+
...config,
|
|
48
|
+
registers: [this.registry],
|
|
49
|
+
});
|
|
50
|
+
return gauge;
|
|
51
|
+
}
|
|
52
|
+
createHistogram(config) {
|
|
53
|
+
this.assertUnique(config.name);
|
|
54
|
+
const histogram = new prom_client_1.Histogram({
|
|
55
|
+
...config,
|
|
56
|
+
registers: [this.registry],
|
|
57
|
+
});
|
|
58
|
+
return histogram;
|
|
59
|
+
}
|
|
60
|
+
/** Serializes all metrics to Prometheus text exposition format. */
|
|
61
|
+
async serialize() {
|
|
62
|
+
return this.registry.metrics();
|
|
63
|
+
}
|
|
64
|
+
/** Content type for the Prometheus text format. */
|
|
65
|
+
get contentType() {
|
|
66
|
+
return this.registry.contentType;
|
|
67
|
+
}
|
|
68
|
+
assertUnique(name) {
|
|
69
|
+
if (this.names.has(name)) {
|
|
70
|
+
throw new Error(`Metric "${name}" is already registered.`);
|
|
71
|
+
}
|
|
72
|
+
this.names.add(name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.MetricRegistry = MetricRegistry;
|
|
76
|
+
// ── MetricsRegistrar (public) ─────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Public API for users to register custom metrics.
|
|
79
|
+
* Validates names and rejects reserved prefixes before delegating.
|
|
80
|
+
*/
|
|
81
|
+
class MetricsRegistrar {
|
|
82
|
+
constructor(registry) {
|
|
83
|
+
this.registry = registry;
|
|
84
|
+
}
|
|
85
|
+
createCounter(config) {
|
|
86
|
+
this.validate(config.name);
|
|
87
|
+
return this.registry.createCounter(config);
|
|
88
|
+
}
|
|
89
|
+
createGauge(config) {
|
|
90
|
+
this.validate(config.name);
|
|
91
|
+
return this.registry.createGauge(config);
|
|
92
|
+
}
|
|
93
|
+
createHistogram(config) {
|
|
94
|
+
this.validate(config.name);
|
|
95
|
+
return this.registry.createHistogram(config);
|
|
96
|
+
}
|
|
97
|
+
validate(name) {
|
|
98
|
+
assertValidName(name);
|
|
99
|
+
if (name.startsWith('__')) {
|
|
100
|
+
throw new Error(`Metric name "${name}" uses reserved prefix "__".`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
exports.MetricsRegistrar = MetricsRegistrar;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
import type { Counter, Gauge, Histogram } from 'prom-client';
|
|
3
|
+
import type { MetricRegistry } from './metric_registry';
|
|
4
|
+
export interface MetricsMiddlewareOptions {
|
|
5
|
+
requestsTotal: Counter<'method' | 'route' | 'status_code'>;
|
|
6
|
+
requestsInFlight: Gauge;
|
|
7
|
+
requestDuration: Histogram<'method' | 'route' | 'status_code'>;
|
|
8
|
+
excludedRoutes: string[];
|
|
9
|
+
registeredPaths: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Creates an Express middleware that instruments HTTP requests.
|
|
13
|
+
*
|
|
14
|
+
* Records:
|
|
15
|
+
* - `requestsTotal` — counter with labels: method, route, status_code
|
|
16
|
+
* - `requestsInFlight` — gauge (inc on entry, dec on finish)
|
|
17
|
+
* - `requestDuration` — histogram with labels: method, route, status_code
|
|
18
|
+
*/
|
|
19
|
+
export declare function metricsMiddleware(opts: MetricsMiddlewareOptions): RequestHandler;
|
|
20
|
+
/**
|
|
21
|
+
* Creates an Express handler that returns Prometheus metrics.
|
|
22
|
+
* Always returns HTTP 200 with `text/plain; version=0.0.4; charset=utf-8`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function metricsHandler(registry: MetricRegistry): RequestHandler;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// core/metrics/metrics_middleware.ts
|
|
4
|
+
// Express middleware + handler for Prometheus metrics.
|
|
5
|
+
// ============================================================
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.metricsMiddleware = metricsMiddleware;
|
|
8
|
+
exports.metricsHandler = metricsHandler;
|
|
9
|
+
/**
|
|
10
|
+
* Creates an Express middleware that instruments HTTP requests.
|
|
11
|
+
*
|
|
12
|
+
* Records:
|
|
13
|
+
* - `requestsTotal` — counter with labels: method, route, status_code
|
|
14
|
+
* - `requestsInFlight` — gauge (inc on entry, dec on finish)
|
|
15
|
+
* - `requestDuration` — histogram with labels: method, route, status_code
|
|
16
|
+
*/
|
|
17
|
+
function metricsMiddleware(opts) {
|
|
18
|
+
const excludedSet = new Set(opts.excludedRoutes);
|
|
19
|
+
const registeredSet = new Set(opts.registeredPaths);
|
|
20
|
+
return (req, res, next) => {
|
|
21
|
+
const path = req.path;
|
|
22
|
+
// Skip excluded routes.
|
|
23
|
+
if (excludedSet.has(path)) {
|
|
24
|
+
return next();
|
|
25
|
+
}
|
|
26
|
+
const method = req.method.toUpperCase();
|
|
27
|
+
const route = registeredSet.has(path) ? path : 'UNMATCHED';
|
|
28
|
+
opts.requestsInFlight.inc();
|
|
29
|
+
const startTime = process.hrtime.bigint();
|
|
30
|
+
res.on('finish', () => {
|
|
31
|
+
opts.requestsInFlight.dec();
|
|
32
|
+
const durationNs = Number(process.hrtime.bigint() - startTime);
|
|
33
|
+
const durationSecs = durationNs / 1e9;
|
|
34
|
+
const statusCode = res.statusCode.toString();
|
|
35
|
+
const labels = { method, route, status_code: statusCode };
|
|
36
|
+
opts.requestsTotal.inc(labels);
|
|
37
|
+
opts.requestDuration.observe(labels, durationSecs);
|
|
38
|
+
});
|
|
39
|
+
next();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Creates an Express handler that returns Prometheus metrics.
|
|
44
|
+
* Always returns HTTP 200 with `text/plain; version=0.0.4; charset=utf-8`.
|
|
45
|
+
*/
|
|
46
|
+
function metricsHandler(registry) {
|
|
47
|
+
return async (_req, res) => {
|
|
48
|
+
const body = await registry.serialize();
|
|
49
|
+
res.status(200).set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8').send(body);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { type RequestHandler } from 'express';
|
|
2
2
|
import { ModuleBuilder } from './module_builder';
|
|
3
|
+
import type { HealthCheck } from './health/health_check';
|
|
4
|
+
import { MetricsRegistrar } from './metrics/metric_registry';
|
|
3
5
|
export interface ModularApiOptions {
|
|
4
6
|
/** Base path prefix for all module routes. Default: '/api' */
|
|
5
7
|
basePath?: string;
|
|
6
8
|
/** API title shown in Swagger UI. Default: 'API' */
|
|
7
9
|
title?: string;
|
|
10
|
+
/** API version string (e.g. '1.0.0'). Used in health check response. Default: '0.0.0' */
|
|
11
|
+
version?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Release identifier. Defaults to `version-debug`.
|
|
14
|
+
* Override via `process.env.RELEASE_ID`.
|
|
15
|
+
*/
|
|
16
|
+
releaseId?: string;
|
|
17
|
+
/** Opt-in Prometheus metrics endpoint. Default: false */
|
|
18
|
+
metricsEnabled?: boolean;
|
|
19
|
+
/** Path for the metrics endpoint. Default: '/metrics' */
|
|
20
|
+
metricsPath?: string;
|
|
21
|
+
/** Routes excluded from instrumentation. Default: ['/metrics', '/health', '/docs'] */
|
|
22
|
+
excludedMetricsRoutes?: string[];
|
|
8
23
|
}
|
|
9
24
|
/**
|
|
10
25
|
* Main entry point for modular_api.
|
|
@@ -24,7 +39,7 @@ export interface ModularApiOptions {
|
|
|
24
39
|
* ```
|
|
25
40
|
*
|
|
26
41
|
* Auto-mounted endpoints:
|
|
27
|
-
* GET /health → 200
|
|
42
|
+
* GET /health → 200/503 application/health+json (IETF draft)
|
|
28
43
|
* GET /docs → Swagger UI
|
|
29
44
|
*/
|
|
30
45
|
export declare class ModularApi {
|
|
@@ -33,7 +48,27 @@ export declare class ModularApi {
|
|
|
33
48
|
private readonly basePath;
|
|
34
49
|
private readonly title;
|
|
35
50
|
private readonly middlewares;
|
|
51
|
+
private readonly healthService;
|
|
52
|
+
private readonly metricsEnabled;
|
|
53
|
+
private readonly metricsPath;
|
|
54
|
+
private readonly excludedMetricsRoutes;
|
|
55
|
+
private readonly metricRegistry?;
|
|
56
|
+
private readonly _metricsRegistrar?;
|
|
57
|
+
private readonly httpRequestsTotal?;
|
|
58
|
+
private readonly httpRequestsInFlight?;
|
|
59
|
+
private readonly httpRequestDuration?;
|
|
60
|
+
/** Public accessor for custom-metric registration. Undefined when metrics are disabled. */
|
|
61
|
+
get metrics(): MetricsRegistrar | undefined;
|
|
36
62
|
constructor(options?: ModularApiOptions);
|
|
63
|
+
/**
|
|
64
|
+
* Register a {@link HealthCheck} to be evaluated on `GET /health`.
|
|
65
|
+
* Returns `this` for method chaining.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* api.addHealthCheck(new DatabaseHealthCheck());
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
addHealthCheck(check: HealthCheck): this;
|
|
37
72
|
/**
|
|
38
73
|
* Registers a group of use cases under a named module.
|
|
39
74
|
* Returns `this` for method chaining.
|
package/dist/core/modular_api.js
CHANGED
|
@@ -13,6 +13,11 @@ const express_1 = __importDefault(require("express"));
|
|
|
13
13
|
const module_builder_1 = require("./module_builder");
|
|
14
14
|
const openapi_1 = require("../openapi/openapi");
|
|
15
15
|
const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
|
|
16
|
+
const health_service_1 = require("./health/health_service");
|
|
17
|
+
const health_handler_1 = require("./health/health_handler");
|
|
18
|
+
const metric_registry_1 = require("./metrics/metric_registry");
|
|
19
|
+
const metrics_middleware_1 = require("./metrics/metrics_middleware");
|
|
20
|
+
const registry_1 = require("./registry");
|
|
16
21
|
/**
|
|
17
22
|
* Main entry point for modular_api.
|
|
18
23
|
*
|
|
@@ -31,18 +36,59 @@ const swagger_ui_express_1 = __importDefault(require("swagger-ui-express"));
|
|
|
31
36
|
* ```
|
|
32
37
|
*
|
|
33
38
|
* Auto-mounted endpoints:
|
|
34
|
-
* GET /health → 200
|
|
39
|
+
* GET /health → 200/503 application/health+json (IETF draft)
|
|
35
40
|
* GET /docs → Swagger UI
|
|
36
41
|
*/
|
|
37
42
|
class ModularApi {
|
|
43
|
+
/** Public accessor for custom-metric registration. Undefined when metrics are disabled. */
|
|
44
|
+
get metrics() {
|
|
45
|
+
return this._metricsRegistrar;
|
|
46
|
+
}
|
|
38
47
|
constructor(options = {}) {
|
|
39
48
|
this.middlewares = [];
|
|
40
49
|
this.basePath = options.basePath ?? '/api';
|
|
41
|
-
this.title = options.title ?? 'API';
|
|
50
|
+
this.title = options.title ?? 'Modular API';
|
|
51
|
+
this.healthService = new health_service_1.HealthService({
|
|
52
|
+
version: options.version ?? 'x.y.z',
|
|
53
|
+
releaseId: options.releaseId,
|
|
54
|
+
});
|
|
55
|
+
// Metrics setup
|
|
56
|
+
this.metricsEnabled = options.metricsEnabled ?? false;
|
|
57
|
+
this.metricsPath = options.metricsPath ?? '/metrics';
|
|
58
|
+
this.excludedMetricsRoutes = options.excludedMetricsRoutes ?? ['/metrics', '/health', '/docs'];
|
|
59
|
+
if (this.metricsEnabled) {
|
|
60
|
+
this.metricRegistry = new metric_registry_1.MetricRegistry();
|
|
61
|
+
this._metricsRegistrar = new metric_registry_1.MetricsRegistrar(this.metricRegistry);
|
|
62
|
+
this.httpRequestsTotal = this.metricRegistry.createCounter({
|
|
63
|
+
name: 'http_requests_total',
|
|
64
|
+
help: 'Total number of HTTP requests.',
|
|
65
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
66
|
+
});
|
|
67
|
+
this.httpRequestsInFlight = this.metricRegistry.createGauge({
|
|
68
|
+
name: 'http_requests_in_flight',
|
|
69
|
+
help: 'Number of HTTP requests currently being processed.',
|
|
70
|
+
});
|
|
71
|
+
this.httpRequestDuration = this.metricRegistry.createHistogram({
|
|
72
|
+
name: 'http_request_duration_seconds',
|
|
73
|
+
help: 'HTTP request duration in seconds.',
|
|
74
|
+
labelNames: ['method', 'route', 'status_code'],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
42
77
|
this.app = (0, express_1.default)();
|
|
43
78
|
this.app.use(express_1.default.json());
|
|
44
79
|
this.rootRouter = express_1.default.Router();
|
|
45
|
-
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Register a {@link HealthCheck} to be evaluated on `GET /health`.
|
|
83
|
+
* Returns `this` for method chaining.
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* api.addHealthCheck(new DatabaseHealthCheck());
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
addHealthCheck(check) {
|
|
90
|
+
this.healthService.addHealthCheck(check);
|
|
91
|
+
return this;
|
|
46
92
|
}
|
|
47
93
|
/**
|
|
48
94
|
* Registers a group of use cases under a named module.
|
|
@@ -88,18 +134,42 @@ class ModularApi {
|
|
|
88
134
|
serve(options) {
|
|
89
135
|
const { port, host = '0.0.0.0' } = options;
|
|
90
136
|
return new Promise((resolve) => {
|
|
137
|
+
// Metrics middleware FIRST — before user middlewares & routes.
|
|
138
|
+
// Created here so registeredPaths is populated from apiRegistry.
|
|
139
|
+
if (this.metricsEnabled &&
|
|
140
|
+
this.httpRequestsTotal &&
|
|
141
|
+
this.httpRequestsInFlight &&
|
|
142
|
+
this.httpRequestDuration) {
|
|
143
|
+
const registeredPaths = registry_1.apiRegistry.routes.map((r) => r.path);
|
|
144
|
+
this.app.use((0, metrics_middleware_1.metricsMiddleware)({
|
|
145
|
+
requestsTotal: this.httpRequestsTotal,
|
|
146
|
+
requestsInFlight: this.httpRequestsInFlight,
|
|
147
|
+
requestDuration: this.httpRequestDuration,
|
|
148
|
+
excludedRoutes: this.excludedMetricsRoutes,
|
|
149
|
+
registeredPaths,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
91
152
|
// Register middlewares before routes
|
|
92
153
|
for (const mw of this.middlewares) {
|
|
93
154
|
this.app.use(mw);
|
|
94
155
|
}
|
|
95
|
-
//
|
|
96
|
-
this.
|
|
156
|
+
// Metrics endpoint (before rootRouter — its own handler).
|
|
157
|
+
if (this.metricsEnabled && this.metricRegistry) {
|
|
158
|
+
this.app.get(this.metricsPath, (0, metrics_middleware_1.metricsHandler)(this.metricRegistry));
|
|
159
|
+
}
|
|
160
|
+
// Health endpoint — IETF Health Check Response Format
|
|
161
|
+
this.app.get('/health', (0, health_handler_1.healthHandler)(this.healthService));
|
|
162
|
+
// Module use case routes.
|
|
163
|
+
this.app.use(this.rootRouter);
|
|
97
164
|
// Swagger / OpenAPI docs
|
|
98
165
|
const spec = (0, openapi_1.buildOpenApiSpec)({ title: this.title, port });
|
|
99
166
|
this.app.use('/docs', swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(spec));
|
|
100
167
|
const server = this.app.listen(port, host, () => {
|
|
101
168
|
console.log(`Docs → http://localhost:${port}/docs`);
|
|
102
169
|
console.log(`Health → http://localhost:${port}/health`);
|
|
170
|
+
if (this.metricsEnabled) {
|
|
171
|
+
console.log(`Metrics → http://localhost:${port}${this.metricsPath}`);
|
|
172
|
+
}
|
|
103
173
|
resolve(server);
|
|
104
174
|
});
|
|
105
175
|
});
|
|
@@ -33,7 +33,7 @@ function useCaseHandler(factory) {
|
|
|
33
33
|
// 1. Extract payload
|
|
34
34
|
const data = req.method.toUpperCase() === 'GET' || req.method.toUpperCase() === 'DELETE'
|
|
35
35
|
? { ...req.query, ...req.params }
|
|
36
|
-
: req.body ?? {};
|
|
36
|
+
: (req.body ?? {});
|
|
37
37
|
// 2. Build use case
|
|
38
38
|
const useCase = factory(data);
|
|
39
39
|
// 3. Validate
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,11 @@ export { useCaseTestHandler } from './core/usecase_test_handler';
|
|
|
9
9
|
export type { TestResponse } from './core/usecase_test_handler';
|
|
10
10
|
export { cors } from './middlewares/cors';
|
|
11
11
|
export type { CorsOptions } from './middlewares/cors';
|
|
12
|
+
export { HealthCheck, HealthCheckResult } from './core/health/health_check';
|
|
13
|
+
export type { HealthStatus } from './core/health/health_check';
|
|
14
|
+
export { HealthService, HealthResponse } from './core/health/health_service';
|
|
15
|
+
export type { HealthServiceOptions } from './core/health/health_service';
|
|
16
|
+
export { healthHandler } from './core/health/health_handler';
|
|
17
|
+
export { MetricRegistry, MetricsRegistrar } from './core/metrics/metric_registry';
|
|
18
|
+
export { metricsMiddleware, metricsHandler } from './core/metrics/metrics_middleware';
|
|
19
|
+
export type { MetricsMiddlewareOptions } from './core/metrics/metrics_middleware';
|
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.cors = exports.useCaseTestHandler = exports.ModuleBuilder = exports.ModularApi = exports.UseCaseException = exports.UseCase = exports.Output = exports.Input = void 0;
|
|
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;
|
|
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; } });
|
|
@@ -26,3 +26,19 @@ Object.defineProperty(exports, "useCaseTestHandler", { enumerable: true, get: fu
|
|
|
26
26
|
// Middlewares
|
|
27
27
|
var cors_1 = require("./middlewares/cors");
|
|
28
28
|
Object.defineProperty(exports, "cors", { enumerable: true, get: function () { return cors_1.cors; } });
|
|
29
|
+
// Health — IETF Health Check Response Format
|
|
30
|
+
var health_check_1 = require("./core/health/health_check");
|
|
31
|
+
Object.defineProperty(exports, "HealthCheck", { enumerable: true, get: function () { return health_check_1.HealthCheck; } });
|
|
32
|
+
Object.defineProperty(exports, "HealthCheckResult", { enumerable: true, get: function () { return health_check_1.HealthCheckResult; } });
|
|
33
|
+
var health_service_1 = require("./core/health/health_service");
|
|
34
|
+
Object.defineProperty(exports, "HealthService", { enumerable: true, get: function () { return health_service_1.HealthService; } });
|
|
35
|
+
Object.defineProperty(exports, "HealthResponse", { enumerable: true, get: function () { return health_service_1.HealthResponse; } });
|
|
36
|
+
var health_handler_1 = require("./core/health/health_handler");
|
|
37
|
+
Object.defineProperty(exports, "healthHandler", { enumerable: true, get: function () { return health_handler_1.healthHandler; } });
|
|
38
|
+
// Metrics — Prometheus /metrics endpoint
|
|
39
|
+
var metric_registry_1 = require("./core/metrics/metric_registry");
|
|
40
|
+
Object.defineProperty(exports, "MetricRegistry", { enumerable: true, get: function () { return metric_registry_1.MetricRegistry; } });
|
|
41
|
+
Object.defineProperty(exports, "MetricsRegistrar", { enumerable: true, get: function () { return metric_registry_1.MetricsRegistrar; } });
|
|
42
|
+
var metrics_middleware_1 = require("./core/metrics/metrics_middleware");
|
|
43
|
+
Object.defineProperty(exports, "metricsMiddleware", { enumerable: true, get: function () { return metrics_middleware_1.metricsMiddleware; } });
|
|
44
|
+
Object.defineProperty(exports, "metricsHandler", { enumerable: true, get: function () { return metrics_middleware_1.metricsHandler; } });
|
package/dist/openapi/openapi.js
CHANGED
|
@@ -19,9 +19,7 @@ const registry_1 = require("../core/registry");
|
|
|
19
19
|
*/
|
|
20
20
|
function buildOpenApiSpec(options) {
|
|
21
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
|
-
];
|
|
22
|
+
const defaultServers = [{ url: `http://localhost:${port}`, description: 'Local' }];
|
|
25
23
|
const paths = {};
|
|
26
24
|
for (const route of registry_1.apiRegistry.routes) {
|
|
27
25
|
const method = route.method.toLowerCase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@macss/modular-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc --project tsconfig.build.json",
|
|
14
14
|
"dev": "ts-node src/index.ts",
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check ."
|
|
16
18
|
},
|
|
17
19
|
"keywords": [
|
|
18
20
|
"api",
|
|
@@ -25,13 +27,18 @@
|
|
|
25
27
|
"license": "MIT",
|
|
26
28
|
"dependencies": {
|
|
27
29
|
"express": "^4.22.1",
|
|
30
|
+
"prom-client": "^15.1.3",
|
|
28
31
|
"swagger-ui-express": "^5.0.1"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"@types/express": "^5.0.6",
|
|
32
35
|
"@types/node": "^22.19.11",
|
|
36
|
+
"@types/supertest": "^6.0.3",
|
|
33
37
|
"@types/swagger-ui-express": "^4.1.8",
|
|
38
|
+
"prettier": "^3.8.1",
|
|
39
|
+
"supertest": "^7.2.2",
|
|
34
40
|
"ts-node": "^10.9.2",
|
|
35
|
-
"typescript": "^5.9.3"
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"vitest": "^4.0.18"
|
|
36
43
|
}
|
|
37
44
|
}
|