@avtechno/sfr 1.0.18 → 2.0.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 +890 -45
- package/dist/index.mjs +20 -2
- package/dist/logger.mjs +9 -37
- package/dist/mq.mjs +46 -0
- package/dist/observability/index.mjs +143 -0
- package/dist/observability/logger.mjs +128 -0
- package/dist/observability/metrics.mjs +177 -0
- package/dist/observability/middleware/mq.mjs +156 -0
- package/dist/observability/middleware/rest.mjs +120 -0
- package/dist/observability/middleware/ws.mjs +135 -0
- package/dist/observability/tracer.mjs +163 -0
- package/dist/sfr-pipeline.mjs +412 -12
- package/dist/templates.mjs +8 -1
- package/dist/types/index.d.mts +8 -1
- package/dist/types/logger.d.mts +9 -3
- package/dist/types/mq.d.mts +19 -0
- package/dist/types/observability/index.d.mts +45 -0
- package/dist/types/observability/logger.d.mts +54 -0
- package/dist/types/observability/metrics.d.mts +74 -0
- package/dist/types/observability/middleware/mq.d.mts +46 -0
- package/dist/types/observability/middleware/rest.d.mts +33 -0
- package/dist/types/observability/middleware/ws.d.mts +35 -0
- package/dist/types/observability/tracer.d.mts +90 -0
- package/dist/types/sfr-pipeline.d.mts +42 -1
- package/dist/types/templates.d.mts +1 -6
- package/package.json +29 -4
- package/src/index.mts +66 -3
- package/src/logger.mts +16 -51
- package/src/mq.mts +49 -0
- package/src/observability/index.mts +184 -0
- package/src/observability/logger.mts +169 -0
- package/src/observability/metrics.mts +266 -0
- package/src/observability/middleware/mq.mts +187 -0
- package/src/observability/middleware/rest.mts +143 -0
- package/src/observability/middleware/ws.mts +162 -0
- package/src/observability/tracer.mts +205 -0
- package/src/sfr-pipeline.mts +468 -18
- package/src/templates.mts +14 -5
- package/src/types/index.d.ts +240 -16
- package/dist/example.mjs +0 -33
- package/dist/types/example.d.mts +0 -11
- package/src/example.mts +0 -35
package/src/sfr-pipeline.mts
CHANGED
|
@@ -3,8 +3,19 @@ import fs from "fs/promises";
|
|
|
3
3
|
import { template } from "./util.mjs";
|
|
4
4
|
import { BroadcastMQ, TargetedMQ } from "./mq.mjs";
|
|
5
5
|
import j2s from "joi-to-swagger";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import rateLimit from "express-rate-limit";
|
|
7
|
+
|
|
8
|
+
// Observability imports
|
|
9
|
+
import {
|
|
10
|
+
init_observability,
|
|
11
|
+
is_observability_enabled,
|
|
12
|
+
type ObservabilityOptions
|
|
13
|
+
} from "./observability/index.mjs";
|
|
14
|
+
import { sfr_logger as logger, create_child_logger } from "./observability/logger.mjs";
|
|
15
|
+
import { sfr_rest_telemetry } from "./observability/middleware/rest.mjs";
|
|
16
|
+
import { instrument_socket_io, wrap_ws_handler } from "./observability/middleware/ws.mjs";
|
|
17
|
+
import { wrap_mq_handler, record_mq_reject } from "./observability/middleware/mq.mjs";
|
|
18
|
+
import { with_span, SpanKind, add_span_attributes } from "./observability/tracer.mjs";
|
|
8
19
|
|
|
9
20
|
const CWD = process.cwd();
|
|
10
21
|
|
|
@@ -28,16 +39,71 @@ export class SFRPipeline {
|
|
|
28
39
|
handlers: {},
|
|
29
40
|
controllers: {}
|
|
30
41
|
},
|
|
42
|
+
ws: {
|
|
43
|
+
handlers: {},
|
|
44
|
+
controllers: {}
|
|
45
|
+
},
|
|
31
46
|
mq: {
|
|
32
47
|
handlers: {},
|
|
33
48
|
controllers: {}
|
|
34
49
|
},
|
|
35
50
|
}
|
|
36
51
|
|
|
52
|
+
/* Authentication middleware configuration */
|
|
53
|
+
static auth_config: AuthConfig = {};
|
|
54
|
+
|
|
55
|
+
/* Rate limit configuration */
|
|
56
|
+
static rate_limit_config: RateLimitGlobalConfig = {};
|
|
57
|
+
|
|
58
|
+
/* Observability configuration */
|
|
59
|
+
static observability_options: ObservabilityOptions = {};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sets the authentication middleware for protected routes.
|
|
63
|
+
* @param middleware - The authentication middleware function
|
|
64
|
+
*/
|
|
65
|
+
static set_auth_middleware(middleware: AuthMiddleware) {
|
|
66
|
+
SFRPipeline.auth_config.middleware = middleware;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Configures global rate limiting options for all routes.
|
|
71
|
+
* Can be overridden per-route using cfg.rate_limit in SFR files.
|
|
72
|
+
* @param config - Rate limit configuration options
|
|
73
|
+
*/
|
|
74
|
+
static set_rate_limit_config(config: RateLimitGlobalConfig) {
|
|
75
|
+
SFRPipeline.rate_limit_config = config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Configures observability options for tracing, metrics, and logging.
|
|
80
|
+
* Must be called before SFR initialization to take effect.
|
|
81
|
+
* @param options - Observability configuration options
|
|
82
|
+
*/
|
|
83
|
+
static set_observability_options(options: ObservabilityOptions) {
|
|
84
|
+
SFRPipeline.observability_options = options;
|
|
85
|
+
}
|
|
86
|
+
|
|
37
87
|
constructor(private cfg: ParserCFG, private oas_cfg: OASConfig, private comms: SFRProtocols) { }
|
|
38
88
|
|
|
39
89
|
async init(base_url?: string): Promise<ServiceDocuments> {
|
|
40
90
|
this.base_url = base_url;
|
|
91
|
+
|
|
92
|
+
// Initialize observability with service config from oas_cfg
|
|
93
|
+
if (SFRPipeline.observability_options.enabled !== false) {
|
|
94
|
+
init_observability(this.oas_cfg, SFRPipeline.observability_options);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add REST telemetry middleware if enabled
|
|
98
|
+
if (this.comms["REST"] && is_observability_enabled()) {
|
|
99
|
+
this.comms["REST"].use(sfr_rest_telemetry());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Instrument Socket.IO if enabled
|
|
103
|
+
if (this.comms["WS"] && is_observability_enabled()) {
|
|
104
|
+
instrument_socket_io(this.comms["WS"]);
|
|
105
|
+
}
|
|
106
|
+
|
|
41
107
|
if(this.comms["MQ"]){
|
|
42
108
|
//Create channels for each type of Communication Pattern
|
|
43
109
|
const channels = await Promise.all(PATTERNS.map(async (v) => {
|
|
@@ -58,6 +124,19 @@ export class SFRPipeline {
|
|
|
58
124
|
...SFRPipeline.injections.mq.handlers,
|
|
59
125
|
mq: this.pattern_channels
|
|
60
126
|
}
|
|
127
|
+
|
|
128
|
+
SFRPipeline.injections.ws.handlers = {
|
|
129
|
+
...SFRPipeline.injections.ws.handlers,
|
|
130
|
+
mq: this.pattern_channels
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Inject Socket.IO server into WS handlers
|
|
135
|
+
if (this.comms["WS"]) {
|
|
136
|
+
SFRPipeline.injections.ws.handlers = {
|
|
137
|
+
...SFRPipeline.injections.ws.handlers,
|
|
138
|
+
io: this.comms["WS"]
|
|
139
|
+
}
|
|
61
140
|
}
|
|
62
141
|
|
|
63
142
|
//Declarations for each protocol in key/value pairs.
|
|
@@ -118,6 +197,91 @@ export class SFRPipeline {
|
|
|
118
197
|
}
|
|
119
198
|
});
|
|
120
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Generates OpenAPI multipart/form-data schema for Multer validators.
|
|
202
|
+
* Since Multer middleware functions can't be easily inspected at runtime,
|
|
203
|
+
* this generates a generic file upload schema.
|
|
204
|
+
*/
|
|
205
|
+
private generate_multer_openapi_schema(validator: RequestHandler): any {
|
|
206
|
+
// Multer middleware is a function, so we generate a generic multipart schema
|
|
207
|
+
// In the future, this could be enhanced to parse Multer configuration
|
|
208
|
+
return {
|
|
209
|
+
content: {
|
|
210
|
+
"multipart/form-data": {
|
|
211
|
+
schema: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
file: {
|
|
215
|
+
type: "string",
|
|
216
|
+
format: "binary",
|
|
217
|
+
description: "File to upload"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
encoding: {
|
|
222
|
+
file: {
|
|
223
|
+
contentType: "*/*"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Gets rate limit configuration for a route, merging global defaults with route-specific config.
|
|
233
|
+
*/
|
|
234
|
+
private get_rate_limit_config_for_route(cfg?: SFRConfig): RateLimitConfig | null {
|
|
235
|
+
if (cfg?.rate_limit === false) {
|
|
236
|
+
return null; // Explicitly disabled
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const global_default = SFRPipeline.rate_limit_config.default;
|
|
240
|
+
const route_config = cfg?.rate_limit;
|
|
241
|
+
|
|
242
|
+
if (!global_default && !route_config) {
|
|
243
|
+
return null; // No rate limiting configured
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
max: 100,
|
|
248
|
+
windowMs: 60000,
|
|
249
|
+
...global_default,
|
|
250
|
+
...route_config
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Creates a rate limiter middleware based on configuration.
|
|
256
|
+
* Merges route-specific config with global defaults.
|
|
257
|
+
*/
|
|
258
|
+
private create_rate_limiter(route_config?: RateLimitConfig | false): RequestHandler | null {
|
|
259
|
+
// If explicitly disabled, return null
|
|
260
|
+
if (route_config === false) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Merge route config with global default
|
|
265
|
+
const global_default = SFRPipeline.rate_limit_config.default;
|
|
266
|
+
const config: RateLimitConfig = {
|
|
267
|
+
max: 100,
|
|
268
|
+
windowMs: 60000,
|
|
269
|
+
message: "Too many requests, please try again later.",
|
|
270
|
+
statusCode: 429,
|
|
271
|
+
standardHeaders: true,
|
|
272
|
+
legacyHeaders: false,
|
|
273
|
+
...global_default,
|
|
274
|
+
...route_config
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Only create rate limiter if max is greater than 0
|
|
278
|
+
if (config.max && config.max > 0) {
|
|
279
|
+
return rateLimit(config);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
121
285
|
/* Fn Binding */
|
|
122
286
|
private async bind_rest_fns(declaration: RESTNamespaceDeclaration) {
|
|
123
287
|
const comms = this.comms["REST"];
|
|
@@ -146,14 +310,27 @@ export class SFRPipeline {
|
|
|
146
310
|
const validator = validators[name];
|
|
147
311
|
const validator_type = typeof validator !== "function" ? "joi" : "multer";
|
|
148
312
|
|
|
313
|
+
// Apply rate limiting middleware if configured
|
|
314
|
+
const rate_limiter = this.create_rate_limiter(cfg.rate_limit);
|
|
315
|
+
if (rate_limiter) {
|
|
316
|
+
comms[method.toLowerCase() as RESTRequestType](dir, rate_limiter);
|
|
317
|
+
}
|
|
318
|
+
|
|
149
319
|
/* bind validators */
|
|
150
320
|
switch (validator_type) {
|
|
151
321
|
case "joi": {
|
|
152
|
-
comms[method.toLowerCase() as RESTRequestType](dir, (req, res, next) => {
|
|
322
|
+
comms[method.toLowerCase() as RESTRequestType](dir, async (req, res, next) => {
|
|
153
323
|
let error: string | boolean = true;
|
|
154
324
|
|
|
155
|
-
|
|
156
|
-
|
|
325
|
+
// Authentication check for protected routes
|
|
326
|
+
if (!is_public && SFRPipeline.auth_config.middleware) {
|
|
327
|
+
const auth_result = await SFRPipeline.auth_config.middleware(req, res);
|
|
328
|
+
if (auth_result === false) {
|
|
329
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
330
|
+
}
|
|
331
|
+
if (typeof auth_result === "object" && auth_result.error) {
|
|
332
|
+
return res.status(auth_result.status || 401).json({ error: auth_result.error });
|
|
333
|
+
}
|
|
157
334
|
}
|
|
158
335
|
|
|
159
336
|
const validator_keys = Object.keys(validator);
|
|
@@ -186,7 +363,101 @@ export class SFRPipeline {
|
|
|
186
363
|
});
|
|
187
364
|
}
|
|
188
365
|
private async bind_ws_fns(declaration: WSNamespaceDeclaration) {
|
|
366
|
+
const io = this.comms["WS"];
|
|
367
|
+
if (!io) throw new Error(`FN Binding failed: \nWS protocol lacks its associated SFRProtocols`);
|
|
368
|
+
|
|
369
|
+
Object.entries(declaration).forEach(([namespace, module]) => {
|
|
370
|
+
const { cfg, validators, handlers } = module.content;
|
|
371
|
+
|
|
372
|
+
// Determine the Socket.IO namespace to use
|
|
373
|
+
// Priority: cfg.namespace -> cfg.base_dir -> "/"
|
|
374
|
+
let ws_namespace = "/";
|
|
375
|
+
if (cfg.namespace) ws_namespace = cfg.namespace.startsWith("/") ? cfg.namespace : `/${cfg.namespace}`;
|
|
376
|
+
else if (cfg.base_dir) ws_namespace = cfg.base_dir.startsWith("/") ? cfg.base_dir : `/${cfg.base_dir}`;
|
|
377
|
+
|
|
378
|
+
logger.info(`[SFR WS ${module.name}] namespace: ${ws_namespace}`);
|
|
379
|
+
|
|
380
|
+
// Get or create the namespace
|
|
381
|
+
const nsp = io.of(ws_namespace);
|
|
382
|
+
|
|
383
|
+
// Handle connections to this namespace
|
|
384
|
+
nsp.on("connection", (socket) => {
|
|
385
|
+
logger.info(`[SFR WS] Client connected to ${ws_namespace}: ${socket.id}`);
|
|
386
|
+
|
|
387
|
+
// Register event handlers for this connection
|
|
388
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
389
|
+
// Skip if no matching validator
|
|
390
|
+
const validator = validators[event];
|
|
391
|
+
if (!validator) return;
|
|
392
|
+
|
|
393
|
+
socket.on(event, async (data: any, ack?: (...args: any[]) => void) => {
|
|
394
|
+
// Validate incoming data if validator is a Joi schema (not Multer)
|
|
395
|
+
const validator_type = typeof validator !== "function" ? "joi" : "custom";
|
|
396
|
+
|
|
397
|
+
if (validator_type === "joi") {
|
|
398
|
+
const validator_keys = Object.keys(validator);
|
|
399
|
+
if (validator_keys.length) {
|
|
400
|
+
const error = template(validator, data || {});
|
|
401
|
+
if (error) {
|
|
402
|
+
// Send validation error back via acknowledgement or emit
|
|
403
|
+
if (ack) {
|
|
404
|
+
ack({ error });
|
|
405
|
+
} else {
|
|
406
|
+
socket.emit(`${event}:error`, { error });
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
189
412
|
|
|
413
|
+
// Build context for handler
|
|
414
|
+
const ctx: WSRequestCtx = {
|
|
415
|
+
io,
|
|
416
|
+
socket,
|
|
417
|
+
data: data || {},
|
|
418
|
+
ack
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Create the handler execution function
|
|
422
|
+
const execute_handler = async () => {
|
|
423
|
+
await handler.fn.call({
|
|
424
|
+
...module.content.controllers,
|
|
425
|
+
...SFRPipeline.injections.ws.handlers,
|
|
426
|
+
io,
|
|
427
|
+
socket
|
|
428
|
+
}, ctx);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
// Wrap with telemetry if observability is enabled
|
|
433
|
+
if (is_observability_enabled()) {
|
|
434
|
+
const instrumented = wrap_ws_handler(ws_namespace, event, execute_handler);
|
|
435
|
+
await instrumented();
|
|
436
|
+
} else {
|
|
437
|
+
await execute_handler();
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
logger.error(`[SFR WS] Error in handler ${event}`, {
|
|
441
|
+
error: err instanceof Error ? err.message : String(err),
|
|
442
|
+
event,
|
|
443
|
+
namespace: ws_namespace
|
|
444
|
+
});
|
|
445
|
+
if (ack) {
|
|
446
|
+
ack({ error: err instanceof Error ? err.message : "Unknown error" });
|
|
447
|
+
} else {
|
|
448
|
+
socket.emit(`${event}:error`, { error: err instanceof Error ? err.message : "Unknown error" });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
this.mount_data.ws++;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
socket.on("disconnect", (reason) => {
|
|
457
|
+
logger.info(`[SFR WS] Client disconnected from ${ws_namespace}: ${socket.id} (${reason})`);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
190
461
|
}
|
|
191
462
|
private async bind_mq_fns(declaration: MQNamespaceDeclaration) {
|
|
192
463
|
const comms = this.comms["MQ"];
|
|
@@ -214,31 +485,41 @@ export class SFRPipeline {
|
|
|
214
485
|
const validator = validators[name];
|
|
215
486
|
//const validator_type = typeof validator !== "function" ? "joi" : "multer"; // TODO: Dynamically update parameter content based on validator type.
|
|
216
487
|
|
|
217
|
-
|
|
488
|
+
// Create the base handler with validation
|
|
489
|
+
const base_handler = function (msg: ParsedMessage) {
|
|
218
490
|
const validator_keys = Object.keys(validator);
|
|
219
491
|
|
|
220
492
|
if(validator_keys.length){
|
|
221
493
|
const error = template(validator, msg.content);
|
|
222
494
|
|
|
223
495
|
if (error) {
|
|
496
|
+
// Record rejection metrics
|
|
497
|
+
if (is_observability_enabled()) {
|
|
498
|
+
record_mq_reject(dir, pattern, false);
|
|
499
|
+
}
|
|
500
|
+
|
|
224
501
|
if (mq instanceof TargetedMQ) {
|
|
225
502
|
if (error) {
|
|
226
503
|
switch (pattern) {
|
|
227
|
-
case "Request-Reply": mq.reply(msg, { error }); break;
|
|
228
|
-
default: mq.channel.reject(msg, false);
|
|
504
|
+
case "Request-Reply": mq.reply(msg as any, { error }); break;
|
|
505
|
+
default: mq.channel.reject(msg as any, false);
|
|
229
506
|
}
|
|
230
507
|
}
|
|
231
508
|
}
|
|
232
509
|
|
|
233
|
-
if (mq instanceof BroadcastMQ)mq.channel.reject(msg, false);
|
|
510
|
+
if (mq instanceof BroadcastMQ) mq.channel.reject(msg as any, false);
|
|
234
511
|
|
|
235
512
|
return; //Return immediately to avoid executing handler fn (which may contain reply or ack calls.)
|
|
236
513
|
}
|
|
237
514
|
}
|
|
238
515
|
|
|
239
|
-
|
|
240
516
|
handler.fn(msg);
|
|
241
|
-
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Wrap with telemetry if observability is enabled
|
|
520
|
+
const validator_fn = is_observability_enabled()
|
|
521
|
+
? wrap_mq_handler(dir, pattern, base_handler)
|
|
522
|
+
: base_handler;
|
|
242
523
|
|
|
243
524
|
/* bind validators */
|
|
244
525
|
if (mq instanceof TargetedMQ) {
|
|
@@ -267,8 +548,8 @@ export class SFRPipeline {
|
|
|
267
548
|
const documents = Object.entries(protocols).map(([protocol, declaration]) => {
|
|
268
549
|
switch (protocol) {
|
|
269
550
|
case "REST": return [protocol, this.generate_open_api_document(declaration as RESTNamespaceDeclaration)];
|
|
270
|
-
case "WS": return [protocol, this.
|
|
271
|
-
case "MQ": return [protocol, this.
|
|
551
|
+
case "WS": return [protocol, this.generate_ws_async_api_document(declaration as WSNamespaceDeclaration)];
|
|
552
|
+
case "MQ": return [protocol, this.generate_mq_async_api_document(declaration as MQNamespaceDeclaration)];
|
|
272
553
|
default: throw new Error(`Failed to generate SFR Spec: ${protocol} protocol is unknown.`);
|
|
273
554
|
}
|
|
274
555
|
});
|
|
@@ -313,12 +594,15 @@ export class SFRPipeline {
|
|
|
313
594
|
if (body.tags && Array.isArray(body.tags)) default_tags.push(...body.tags);
|
|
314
595
|
|
|
315
596
|
const operation_id = `${base_dir}${namespace}/${endpoint}`;
|
|
597
|
+
const is_public = Boolean(cfg.public);
|
|
598
|
+
const rate_limit_config = this.get_rate_limit_config_for_route(cfg);
|
|
599
|
+
|
|
316
600
|
const document: any = {
|
|
317
601
|
operationId : `${method.toUpperCase()}:${operation_id}`,
|
|
318
602
|
summary : body.summary || "",
|
|
319
603
|
description : body.description || "",
|
|
320
604
|
tags : default_tags,
|
|
321
|
-
public :
|
|
605
|
+
public : is_public,
|
|
322
606
|
|
|
323
607
|
responses: {
|
|
324
608
|
"200": {
|
|
@@ -326,19 +610,90 @@ export class SFRPipeline {
|
|
|
326
610
|
content: { "application/json": { schema: { type: "object" } } }
|
|
327
611
|
},
|
|
328
612
|
"400": {
|
|
329
|
-
description: "
|
|
330
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
613
|
+
description: "Bad request - validation error",
|
|
614
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
331
615
|
}
|
|
332
616
|
}
|
|
333
617
|
};
|
|
334
618
|
|
|
619
|
+
// Add authentication-related responses for protected routes
|
|
620
|
+
if (!is_public) {
|
|
621
|
+
document.responses["401"] = {
|
|
622
|
+
description: "Unauthorized - authentication required",
|
|
623
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
624
|
+
};
|
|
625
|
+
document.responses["403"] = {
|
|
626
|
+
description: "Forbidden - insufficient permissions",
|
|
627
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Add rate limiting response and headers if rate limiting is configured
|
|
632
|
+
if (rate_limit_config && rate_limit_config.max && rate_limit_config.max > 0) {
|
|
633
|
+
document.responses["429"] = {
|
|
634
|
+
description: "Too many requests - rate limit exceeded",
|
|
635
|
+
content: {
|
|
636
|
+
"application/json": {
|
|
637
|
+
schema: {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: {
|
|
640
|
+
error: { type: "string" },
|
|
641
|
+
message: { type: "string" }
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
headers: {}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// Add rate limit headers if standardHeaders is enabled
|
|
650
|
+
if (rate_limit_config.standardHeaders !== false) {
|
|
651
|
+
document.responses["429"].headers["RateLimit-Limit"] = {
|
|
652
|
+
description: "The maximum number of requests allowed in the current window",
|
|
653
|
+
schema: { type: "integer", example: rate_limit_config.max }
|
|
654
|
+
};
|
|
655
|
+
document.responses["429"].headers["RateLimit-Remaining"] = {
|
|
656
|
+
description: "The number of requests remaining in the current window",
|
|
657
|
+
schema: { type: "integer" }
|
|
658
|
+
};
|
|
659
|
+
document.responses["429"].headers["RateLimit-Reset"] = {
|
|
660
|
+
description: "The time at which the current rate limit window resets (Unix timestamp)",
|
|
661
|
+
schema: { type: "integer" }
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Add legacy headers if enabled
|
|
666
|
+
if (rate_limit_config.legacyHeaders) {
|
|
667
|
+
document.responses["429"].headers["X-RateLimit-Limit"] = {
|
|
668
|
+
description: "The maximum number of requests allowed in the current window (legacy)",
|
|
669
|
+
schema: { type: "integer", example: rate_limit_config.max }
|
|
670
|
+
};
|
|
671
|
+
document.responses["429"].headers["X-RateLimit-Remaining"] = {
|
|
672
|
+
description: "The number of requests remaining in the current window (legacy)",
|
|
673
|
+
schema: { type: "integer" }
|
|
674
|
+
};
|
|
675
|
+
document.responses["429"].headers["X-RateLimit-Reset"] = {
|
|
676
|
+
description: "The time at which the current rate limit window resets (legacy)",
|
|
677
|
+
schema: { type: "integer" }
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Add rate limit metadata to operation description
|
|
682
|
+
if (rate_limit_config.windowMs) {
|
|
683
|
+
const window_seconds = Math.floor(rate_limit_config.windowMs / 1000);
|
|
684
|
+
const rate_limit_note = `\n\n**Rate Limit:** ${rate_limit_config.max} requests per ${window_seconds} second${window_seconds !== 1 ? 's' : ''}`;
|
|
685
|
+
document.description = (document.description || "") + rate_limit_note;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
335
689
|
//Insert either a parameter or a requestBody according to the method type
|
|
336
690
|
if(validator_type === "joi"){
|
|
337
691
|
document[method === "GET" ? "parameters" : "requestBody"] = j2s(validator).swagger;
|
|
338
692
|
}
|
|
339
693
|
|
|
340
|
-
//
|
|
694
|
+
// Convert Multer validators to multipart/form-data OpenAPI schema
|
|
341
695
|
if(validator_type === "multer"){
|
|
696
|
+
document.requestBody = this.generate_multer_openapi_schema(validator as RequestHandler);
|
|
342
697
|
}
|
|
343
698
|
|
|
344
699
|
//Create path if it does not exist.
|
|
@@ -355,8 +710,103 @@ export class SFRPipeline {
|
|
|
355
710
|
return spec;
|
|
356
711
|
}
|
|
357
712
|
|
|
713
|
+
// Method to generate an AsyncAPI document from a WSNamespaceDeclaration
|
|
714
|
+
private generate_ws_async_api_document(declaration: WSNamespaceDeclaration): AsyncAPIDocument {
|
|
715
|
+
const spec: any = {
|
|
716
|
+
asyncapi: '3.0.0',
|
|
717
|
+
info: Object.assign({}, this.oas_cfg),
|
|
718
|
+
servers: {
|
|
719
|
+
websocket: {
|
|
720
|
+
host: `localhost:${this.oas_cfg.meta?.port || 3000}`,
|
|
721
|
+
protocol: 'ws',
|
|
722
|
+
description: 'WebSocket server'
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
channels: {},
|
|
726
|
+
operations: {},
|
|
727
|
+
components: {
|
|
728
|
+
messages: {},
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
// Conform to AsyncAPI standards by appending "x-property" on each field under "meta"
|
|
733
|
+
if (spec.info.meta) {
|
|
734
|
+
spec.info.meta = Object.fromEntries(Object.entries(spec.info.meta).map(([k, v]) => [`x-${k}`, v]));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
Object.entries(declaration).forEach(([namespace, module]) => {
|
|
738
|
+
const { cfg, validators, handlers } = module.content;
|
|
739
|
+
|
|
740
|
+
// Determine namespace path
|
|
741
|
+
let ws_namespace = "/";
|
|
742
|
+
if (cfg.namespace) ws_namespace = cfg.namespace.startsWith("/") ? cfg.namespace : `/${cfg.namespace}`;
|
|
743
|
+
else if (cfg.base_dir) ws_namespace = cfg.base_dir.startsWith("/") ? cfg.base_dir : `/${cfg.base_dir}`;
|
|
744
|
+
|
|
745
|
+
// Loop over all event handlers
|
|
746
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
747
|
+
const validator: any = validators[event];
|
|
748
|
+
if (!validator) return;
|
|
749
|
+
|
|
750
|
+
const validator_type = typeof validator !== "function" ? "joi" : "custom";
|
|
751
|
+
const channel_id = `${ws_namespace === "/" ? "" : ws_namespace}/${event}`.replace(/^\/+/, "").replace(/\//g, "-") || event;
|
|
752
|
+
|
|
753
|
+
// Default tags for service classification
|
|
754
|
+
const default_tags = [
|
|
755
|
+
{ name: `sfr-namespace:${namespace}` },
|
|
756
|
+
{ name: `sfr-service:${this.oas_cfg.title || "unspecified"}` }
|
|
757
|
+
];
|
|
758
|
+
|
|
759
|
+
if (handler.tags && Array.isArray(handler.tags)) {
|
|
760
|
+
default_tags.push(...handler.tags.map(t => ({ name: t })));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const channel_document: any = {
|
|
764
|
+
address: ws_namespace === "/" ? `/${event}` : `${ws_namespace}/${event}`,
|
|
765
|
+
messages: {}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const operation_document: any = {
|
|
769
|
+
action: "receive",
|
|
770
|
+
summary: handler.summary || `Handle ${event} event`,
|
|
771
|
+
description: handler.description || "",
|
|
772
|
+
channel: { $ref: `#/channels/${channel_id}` },
|
|
773
|
+
tags: default_tags
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// Generate message schema from Joi validator
|
|
777
|
+
if (validator_type === "joi") {
|
|
778
|
+
const message_id = `${channel_id}-message`;
|
|
779
|
+
channel_document.messages[message_id] = {
|
|
780
|
+
$ref: `#/components/messages/${message_id}`
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
spec.components.messages[message_id] = {
|
|
784
|
+
name: message_id,
|
|
785
|
+
contentType: "application/json",
|
|
786
|
+
payload: j2s(validator).swagger
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
spec.channels[channel_id] = channel_document;
|
|
791
|
+
spec.operations[`${channel_id}-receive`] = operation_document;
|
|
792
|
+
|
|
793
|
+
// Add send operation for events that can emit responses
|
|
794
|
+
const send_operation: any = {
|
|
795
|
+
action: "send",
|
|
796
|
+
summary: `Send ${event} response`,
|
|
797
|
+
description: `Response for ${event} event`,
|
|
798
|
+
channel: { $ref: `#/channels/${channel_id}` },
|
|
799
|
+
tags: default_tags
|
|
800
|
+
};
|
|
801
|
+
spec.operations[`${channel_id}-send`] = send_operation;
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
return spec;
|
|
806
|
+
}
|
|
807
|
+
|
|
358
808
|
// Method to generate an AsyncAPI document from an MQNamespaceDeclaration
|
|
359
|
-
private
|
|
809
|
+
private generate_mq_async_api_document(declaration: MQNamespaceDeclaration): AsyncAPIDocument {
|
|
360
810
|
// This will hold the final AsyncAPI Document.
|
|
361
811
|
const spec = {
|
|
362
812
|
asyncapi: '3.0.0',
|
package/src/templates.mts
CHANGED
|
@@ -22,14 +22,23 @@ export function REST<V, H, C>(struct : RESTHandlerDescriptor<V, H, C>) : H & C {
|
|
|
22
22
|
return { validators, controllers, handlers, cfg } as H & C
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function WS<V, H, C>(struct : WSHandlerDescriptor<V, H, C>){
|
|
25
|
+
export function WS<V, H, C>(struct : WSHandlerDescriptor<V, H, C>): H & C {
|
|
26
26
|
const validators : RequestValidators = struct.validators || {};
|
|
27
27
|
const controllers : RequestControllers = struct.controllers || {};
|
|
28
28
|
const handlers : WSRequestHandlers = struct.handlers || {};
|
|
29
|
-
const cfg :
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
29
|
+
const cfg : WSConfig = struct.cfg || {};
|
|
30
|
+
|
|
31
|
+
// Bind controller injections to each controller
|
|
32
|
+
Object.entries(controllers).map(([k, v]) => controllers[k] = v.bind({...SFRPipeline.injections.ws.controllers}) as RequestController);
|
|
33
|
+
|
|
34
|
+
// Bind handler injections and SFR controllers into each handler
|
|
35
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
36
|
+
/* @ts-ignore */
|
|
37
|
+
handlers[event] = { ...handler, fn: handler.fn.bind({ ...controllers, ...SFRPipeline.injections.ws.handlers }) };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/* @ts-ignore */
|
|
41
|
+
return { validators, controllers, handlers, cfg } as H & C;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export function MQ<V, H, C>(struct : MQHandlerDescriptor<V, H, C>): H & C{
|