@avtechno/sfr 1.0.18 → 2.0.1
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 +893 -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/README.md
CHANGED
|
@@ -1,72 +1,920 @@
|
|
|
1
1
|
# Single File Router (SFR)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
SFR allows the declaration of services including its controllers, handlers and validators in one single file.
|
|
3
|
+
> An opinionated, convention-over-configuration approach to building services in Node.js
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@avtechno/sfr)
|
|
6
|
+
[](https://opensource.org/licenses/ISC)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
---
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## Overview
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
It's main purpose is to translate and create service-level documentation which follows the OpenAPI Standard, this essentially opens up doors to extensive tooling support, instrumentation, automated endpoint testing, automated documentation generation, just to name a few.`
|
|
12
|
+
**SFR** is a declarative routing framework that allows you to define your entire API service—handlers, validators, and controllers—within a single, structured file. By embracing a **convention over configuration** philosophy, SFR eliminates boilerplate and enforces consistency across your codebase.
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
### Key Features
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
- **Single-File Declaration** — Define validators, handlers, and controllers together in one cohesive module
|
|
17
|
+
- **Multi-Protocol Support** — Build REST APIs, WebSocket servers, and Message Queue consumers using the same patterns
|
|
18
|
+
- **Automatic Validation** — Integrate [Joi](https://joi.dev/) schemas or [Multer](https://github.com/expressjs/multer) middleware for request validation
|
|
19
|
+
- **Dependency Injection** — Inject shared dependencies into handlers and controllers via IoC pattern
|
|
20
|
+
- **Auto-Generated Documentation** — Produces OpenAPI 3.0 specs (REST) and AsyncAPI 3.0 specs (MQ/WS) automatically
|
|
21
|
+
- **File-Based Routing** — Directory structure determines endpoint paths, reducing manual configuration
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
---
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
## Installation
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
```bash
|
|
28
|
+
npm install @avtechno/sfr
|
|
29
|
+
# or
|
|
30
|
+
yarn add @avtechno/sfr
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Peer Dependencies
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
SFR works with the following libraries (install as needed):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install express joi amqplib socket.io
|
|
39
|
+
```
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
---
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
import sfr from "@avtechno/sfr";
|
|
35
47
|
import express from "express";
|
|
36
48
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
49
|
+
const app = express();
|
|
50
|
+
const PORT = 3000;
|
|
51
|
+
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
|
|
54
|
+
await sfr(
|
|
55
|
+
{ root: "dist", path: "api", out: "docs" },
|
|
56
|
+
{
|
|
57
|
+
title: "My Service",
|
|
58
|
+
description: "A sample SFR-powered service",
|
|
59
|
+
version: "1.0.0",
|
|
60
|
+
meta: {
|
|
61
|
+
service: "my-service",
|
|
62
|
+
domain: "example-org",
|
|
63
|
+
type: "backend",
|
|
64
|
+
language: "javascript",
|
|
65
|
+
port: PORT
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ REST: app },
|
|
69
|
+
"api"
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Project Structure
|
|
78
|
+
|
|
79
|
+
SFR expects your API files to be organized by protocol within the specified path:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
project/
|
|
83
|
+
├── dist/ # Compiled output (cfg.root)
|
|
84
|
+
│ └── api/ # API directory (cfg.path)
|
|
85
|
+
│ ├── rest/ # REST endpoints
|
|
86
|
+
│ │ ├── users.mjs
|
|
87
|
+
│ │ └── auth/
|
|
88
|
+
│ │ └── login.mjs
|
|
89
|
+
│ ├── ws/ # WebSocket handlers
|
|
90
|
+
│ │ └── chat.mjs
|
|
91
|
+
│ └── mq/ # Message Queue consumers
|
|
92
|
+
│ └── notifications.mjs
|
|
93
|
+
├── docs/ # Generated OpenAPI/AsyncAPI specs (cfg.out)
|
|
94
|
+
└── src/
|
|
95
|
+
└── api/
|
|
96
|
+
└── ... # Source files
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The file path determines the endpoint URL:
|
|
100
|
+
- `rest/users.mjs` → `/api/users/*`
|
|
101
|
+
- `rest/auth/login.mjs` → `/api/auth/login/*`
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## SFR File Structure
|
|
106
|
+
|
|
107
|
+
Each SFR module exports a default object created using `REST()`, `WS()`, or `MQ()` template functions:
|
|
108
|
+
|
|
109
|
+
| Component | Description |
|
|
110
|
+
|-----------|-------------|
|
|
111
|
+
| **cfg** | Configuration options (base directory, public/private access) |
|
|
112
|
+
| **validators** | Request validation schemas (Joi objects or Multer middleware) |
|
|
113
|
+
| **handlers** | Route handlers organized by HTTP method or MQ pattern |
|
|
114
|
+
| **controllers** | Reusable data access functions (database calls, external APIs) |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Creating a REST Endpoint
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
import Joi from "joi";
|
|
122
|
+
import { REST } from "@avtechno/sfr";
|
|
123
|
+
|
|
124
|
+
export default REST({
|
|
125
|
+
cfg: {
|
|
126
|
+
base_dir: "users", // Optional: Prepends to endpoint path
|
|
127
|
+
public: false // Requires authentication (default: false)
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
validators: {
|
|
131
|
+
"get-profile": {
|
|
132
|
+
user_id: Joi.string().uuid().required()
|
|
133
|
+
},
|
|
134
|
+
"update-profile": {
|
|
135
|
+
name: Joi.string().min(2).max(100),
|
|
136
|
+
email: Joi.string().email()
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
handlers: {
|
|
141
|
+
GET: {
|
|
142
|
+
"get-profile": {
|
|
143
|
+
summary: "Retrieve user profile",
|
|
144
|
+
description: "Fetches the profile for a given user ID",
|
|
145
|
+
tags: ["users", "profile"],
|
|
146
|
+
async fn(req, res) {
|
|
147
|
+
const { user_id } = req.query;
|
|
148
|
+
const profile = await this.fetch_user(user_id);
|
|
149
|
+
res.status(200).json({ data: profile });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
PUT: {
|
|
155
|
+
"update-profile": {
|
|
156
|
+
summary: "Update user profile",
|
|
157
|
+
description: "Updates profile information for authenticated user",
|
|
158
|
+
tags: ["users", "profile"],
|
|
159
|
+
deprecated: false,
|
|
160
|
+
async fn(req, res) {
|
|
161
|
+
const result = await this.update_user(req.body);
|
|
162
|
+
res.status(200).json({ data: result });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
controllers: {
|
|
169
|
+
async fetch_user(user_id) {
|
|
170
|
+
// Access injected dependencies via `this`
|
|
171
|
+
return this.db.users.findById(user_id);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async update_user(data) {
|
|
175
|
+
return this.db.users.update(data);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Resulting Endpoints
|
|
182
|
+
|
|
183
|
+
| Method | Endpoint | Handler |
|
|
184
|
+
|--------|----------|---------|
|
|
185
|
+
| GET | `/api/users/get-profile` | `get-profile` |
|
|
186
|
+
| PUT | `/api/users/update-profile` | `update-profile` |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Creating MQ Consumers
|
|
191
|
+
|
|
192
|
+
SFR supports multiple messaging patterns via RabbitMQ/AMQP:
|
|
193
|
+
|
|
194
|
+
| Pattern | Description | Class |
|
|
195
|
+
|---------|-------------|-------|
|
|
196
|
+
| **Point-to-Point** | Direct queue consumption | `TargetedMQ` |
|
|
197
|
+
| **Request-Reply** | RPC-style messaging with responses | `TargetedMQ` |
|
|
198
|
+
| **Fanout** | Broadcast to all subscribers | `BroadcastMQ` |
|
|
199
|
+
| **Direct** | Route by exact routing key | `BroadcastMQ` |
|
|
200
|
+
| **Topic** | Route by pattern-matched routing key | `BroadcastMQ` |
|
|
201
|
+
|
|
202
|
+
```javascript
|
|
203
|
+
import Joi from "joi";
|
|
204
|
+
import { MQ } from "@avtechno/sfr";
|
|
205
|
+
|
|
206
|
+
export default MQ({
|
|
207
|
+
cfg: {
|
|
208
|
+
base_dir: "notifications"
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
validators: {
|
|
212
|
+
"send-email": {
|
|
213
|
+
to: Joi.string().email().required(),
|
|
214
|
+
subject: Joi.string().required(),
|
|
215
|
+
body: Joi.string().required()
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
handlers: {
|
|
220
|
+
"Point-to-Point": {
|
|
221
|
+
"send-email": {
|
|
222
|
+
summary: "Process email notification",
|
|
223
|
+
options: { noAck: false },
|
|
224
|
+
async fn(msg) {
|
|
225
|
+
const { to, subject, body } = msg.content;
|
|
226
|
+
await this.send_email(to, subject, body);
|
|
227
|
+
this.mq.channel.ack(msg);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
"Request-Reply": {
|
|
233
|
+
"validate-email": {
|
|
234
|
+
summary: "Validate email address",
|
|
235
|
+
async fn(msg) {
|
|
236
|
+
const result = await this.validate(msg.content.email);
|
|
237
|
+
this.mq.reply(msg, { valid: result });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
controllers: {
|
|
244
|
+
async send_email(to, subject, body) {
|
|
245
|
+
// Email sending logic
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async validate(email) {
|
|
249
|
+
// Validation logic
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Creating WebSocket Handlers
|
|
259
|
+
|
|
260
|
+
SFR supports real-time WebSocket communication via [Socket.IO](https://socket.io/). WebSocket SFRs follow the same pattern as REST and MQ:
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
import Joi from "joi";
|
|
264
|
+
import { WS } from "@avtechno/sfr";
|
|
265
|
+
|
|
266
|
+
export default WS({
|
|
267
|
+
cfg: {
|
|
268
|
+
namespace: "/chat", // Socket.IO namespace (optional, defaults to "/")
|
|
269
|
+
public: true // Skip authentication checks
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
validators: {
|
|
273
|
+
"send-message": {
|
|
274
|
+
room: Joi.string().required(),
|
|
275
|
+
message: Joi.string().min(1).max(1000).required(),
|
|
276
|
+
sender: Joi.string().required()
|
|
277
|
+
},
|
|
278
|
+
"join-room": {
|
|
279
|
+
room: Joi.string().required(),
|
|
280
|
+
username: Joi.string().min(2).max(50).required()
|
|
281
|
+
},
|
|
282
|
+
"leave-room": {
|
|
283
|
+
room: Joi.string().required()
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
handlers: {
|
|
288
|
+
"send-message": {
|
|
289
|
+
summary: "Send a message to a room",
|
|
290
|
+
description: "Broadcasts a message to all users in the specified room",
|
|
291
|
+
tags: ["chat", "messaging"],
|
|
292
|
+
async fn(ctx) {
|
|
293
|
+
const { room, message, sender } = ctx.data;
|
|
294
|
+
|
|
295
|
+
// Process via controller
|
|
296
|
+
const processed = await this.process_message(room, sender, message);
|
|
297
|
+
|
|
298
|
+
// Broadcast to room
|
|
299
|
+
ctx.io.to(room).emit("new-message", processed);
|
|
300
|
+
|
|
301
|
+
// Acknowledge if callback provided
|
|
302
|
+
if (ctx.ack) ctx.ack({ success: true });
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
"join-room": {
|
|
307
|
+
summary: "Join a chat room",
|
|
308
|
+
description: "Adds the user to a room and notifies other members",
|
|
309
|
+
tags: ["chat", "rooms"],
|
|
310
|
+
async fn(ctx) {
|
|
311
|
+
const { room, username } = ctx.data;
|
|
312
|
+
|
|
313
|
+
// Join Socket.IO room
|
|
314
|
+
ctx.socket.join(room);
|
|
315
|
+
|
|
316
|
+
// Notify other room members
|
|
317
|
+
ctx.socket.to(room).emit("user-joined", {
|
|
318
|
+
username,
|
|
319
|
+
socket_id: ctx.socket.id
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (ctx.ack) ctx.ack({ success: true, room });
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
"leave-room": {
|
|
327
|
+
summary: "Leave a chat room",
|
|
328
|
+
tags: ["chat", "rooms"],
|
|
329
|
+
async fn(ctx) {
|
|
330
|
+
const { room } = ctx.data;
|
|
331
|
+
|
|
332
|
+
ctx.socket.leave(room);
|
|
333
|
+
ctx.socket.to(room).emit("user-left", { socket_id: ctx.socket.id });
|
|
334
|
+
|
|
335
|
+
if (ctx.ack) ctx.ack({ success: true });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
controllers: {
|
|
341
|
+
async process_message(room, sender, message) {
|
|
342
|
+
// Access injected dependencies via `this`
|
|
343
|
+
return {
|
|
344
|
+
id: `msg_${Date.now()}`,
|
|
345
|
+
room,
|
|
346
|
+
sender,
|
|
347
|
+
message,
|
|
348
|
+
timestamp: Date.now()
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### WebSocket Handler Context
|
|
356
|
+
|
|
357
|
+
Each WebSocket handler receives a context object (`ctx`) with the following properties:
|
|
358
|
+
|
|
359
|
+
| Property | Type | Description |
|
|
360
|
+
|----------|------|-------------|
|
|
361
|
+
| `io` | `Server` | The Socket.IO server instance |
|
|
362
|
+
| `socket` | `Socket` | The current client socket connection |
|
|
363
|
+
| `data` | `object` | The validated event payload |
|
|
364
|
+
| `ack` | `function?` | Optional acknowledgement callback |
|
|
365
|
+
|
|
366
|
+
### Initializing with WebSocket
|
|
367
|
+
|
|
368
|
+
```javascript
|
|
369
|
+
import sfr from "@avtechno/sfr";
|
|
370
|
+
import express from "express";
|
|
371
|
+
import { createServer } from "http";
|
|
372
|
+
import { Server } from "socket.io";
|
|
39
373
|
|
|
40
374
|
const app = express();
|
|
375
|
+
const httpServer = createServer(app);
|
|
376
|
+
const io = new Server(httpServer);
|
|
377
|
+
|
|
378
|
+
await sfr(
|
|
379
|
+
{ root: "dist", path: "api", out: "docs" },
|
|
380
|
+
{ /* OAS config */ },
|
|
381
|
+
{
|
|
382
|
+
REST: app,
|
|
383
|
+
WS: io // Pass Socket.IO server
|
|
384
|
+
},
|
|
385
|
+
"api"
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
httpServer.listen(3000);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### WebSocket Features
|
|
392
|
+
|
|
393
|
+
- **Namespaces** — Use `cfg.namespace` to organize events into logical groups
|
|
394
|
+
- **Rooms** — Use `ctx.socket.join()` and `ctx.socket.to()` for room-based messaging
|
|
395
|
+
- **Validation** — Joi schemas validate incoming event data before handler execution
|
|
396
|
+
- **Acknowledgements** — Support for Socket.IO acknowledgement callbacks via `ctx.ack`
|
|
397
|
+
- **Error Handling** — Validation errors and handler exceptions emit `{event}:error` events
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Dependency Injection
|
|
402
|
+
|
|
403
|
+
Inject shared dependencies (database connections, external services, etc.) into handlers and controllers:
|
|
404
|
+
|
|
405
|
+
```javascript
|
|
406
|
+
import sfr, { inject } from "@avtechno/sfr";
|
|
407
|
+
import { PrismaClient } from "@prisma/client";
|
|
408
|
+
import Redis from "ioredis";
|
|
409
|
+
|
|
410
|
+
const db = new PrismaClient();
|
|
411
|
+
const redis = new Redis();
|
|
412
|
+
|
|
413
|
+
// Inject dependencies before initializing SFR
|
|
414
|
+
inject({
|
|
415
|
+
handlers: {
|
|
416
|
+
redis,
|
|
417
|
+
logger: console
|
|
418
|
+
},
|
|
419
|
+
controllers: {
|
|
420
|
+
db,
|
|
421
|
+
redis
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Now all handlers can access `this.redis` and `this.logger`
|
|
426
|
+
// All controllers can access `this.db` and `this.redis`
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Accessing Injected Dependencies
|
|
430
|
+
|
|
431
|
+
```javascript
|
|
432
|
+
// In handlers
|
|
433
|
+
async fn(req, res) {
|
|
434
|
+
this.logger.info("Request received");
|
|
435
|
+
const cached = await this.redis.get(key);
|
|
436
|
+
// ...
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// In controllers
|
|
440
|
+
async fetch_data(id) {
|
|
441
|
+
return this.db.table.findUnique({ where: { id } });
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Validation
|
|
448
|
+
|
|
449
|
+
### Joi Validation
|
|
450
|
+
|
|
451
|
+
Define validation schemas as plain objects with Joi validators:
|
|
452
|
+
|
|
453
|
+
```javascript
|
|
454
|
+
validators: {
|
|
455
|
+
"create-user": {
|
|
456
|
+
name: Joi.string().min(2).max(50).required(),
|
|
457
|
+
email: Joi.string().email().required(),
|
|
458
|
+
age: Joi.number().integer().min(18).optional()
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
- **GET requests**: Validates `req.query`
|
|
464
|
+
- **POST/PUT/PATCH requests**: Validates `req.body`
|
|
465
|
+
|
|
466
|
+
### Multer Validation (File Uploads)
|
|
467
|
+
|
|
468
|
+
Pass Multer middleware directly as validators:
|
|
469
|
+
|
|
470
|
+
```javascript
|
|
471
|
+
import multer from "multer";
|
|
472
|
+
|
|
473
|
+
const upload = multer({ dest: "uploads/" });
|
|
474
|
+
|
|
475
|
+
validators: {
|
|
476
|
+
"upload-avatar": upload.single("avatar")
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Configuration Reference
|
|
483
|
+
|
|
484
|
+
### Parser Configuration (`ParserCFG`)
|
|
485
|
+
|
|
486
|
+
| Property | Type | Description |
|
|
487
|
+
|----------|------|-------------|
|
|
488
|
+
| `root` | `string` | Root directory of compiled source files |
|
|
489
|
+
| `path` | `string` | Directory containing SFR modules |
|
|
490
|
+
| `out` | `string` | Output directory for generated API specs |
|
|
491
|
+
|
|
492
|
+
### SFR Configuration (`SFRConfig`)
|
|
493
|
+
|
|
494
|
+
| Property | Type | Default | Description |
|
|
495
|
+
|----------|------|---------|-------------|
|
|
496
|
+
| `base_dir` | `string` | `""` | Prefix path for all endpoints in the SFR |
|
|
497
|
+
| `public` | `boolean` | `false` | Skip authentication/authorization checks |
|
|
498
|
+
| `rate_limit` | `RateLimitConfig \| false` | Uses global default | Per-route rate limit configuration (see Rate Limiting section) |
|
|
499
|
+
|
|
500
|
+
### OAS Configuration (`OASConfig`)
|
|
501
|
+
|
|
502
|
+
| Property | Type | Description |
|
|
503
|
+
|----------|------|-------------|
|
|
504
|
+
| `title` | `string` | Service name |
|
|
505
|
+
| `description` | `string` | Service description |
|
|
506
|
+
| `version` | `string` | Semantic version |
|
|
507
|
+
| `meta.service` | `string` | Service identifier |
|
|
508
|
+
| `meta.domain` | `string` | Organization/domain name |
|
|
509
|
+
| `meta.type` | `"backend"` \| `"frontend"` | Service type |
|
|
510
|
+
| `meta.language` | `string` | Programming language |
|
|
511
|
+
| `meta.port` | `number` | Service port |
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Rate Limiting
|
|
41
516
|
|
|
42
|
-
|
|
43
|
-
Note:
|
|
44
|
-
SFR Bundler will look for two folders, named "rest" and "ws" inside the "path".
|
|
45
|
-
The placement of SFRs define the protocol that they'll use.
|
|
46
|
-
`
|
|
47
|
-
i.e:rest SFRs reside within "rest", websocket SFRs reside in "ws".
|
|
48
|
-
*/
|
|
517
|
+
SFR includes built-in rate limiting support via `express-rate-limit` to protect your APIs from abuse and ensure fair resource usage.
|
|
49
518
|
|
|
50
|
-
|
|
51
|
-
root : "dist", //Specifies the working directory
|
|
52
|
-
path : api_path, //Specifies the API directory i.e: where SFR files reside
|
|
53
|
-
out : doc_path //Specifies the directory for the resulting OAS documents
|
|
54
|
-
}, app);
|
|
519
|
+
### Global Configuration
|
|
55
520
|
|
|
521
|
+
Set default rate limiting for all routes:
|
|
522
|
+
|
|
523
|
+
```javascript
|
|
524
|
+
import { set_rate_limit_config } from "@avtechno/sfr";
|
|
525
|
+
|
|
526
|
+
set_rate_limit_config({
|
|
527
|
+
default: {
|
|
528
|
+
max: 100, // Maximum requests
|
|
529
|
+
windowMs: 60000, // Time window (1 minute)
|
|
530
|
+
message: "Too many requests, please try again later.",
|
|
531
|
+
statusCode: 429,
|
|
532
|
+
standardHeaders: true, // Include RateLimit-* headers
|
|
533
|
+
legacyHeaders: false // Include X-RateLimit-* headers
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### Per-Route Configuration
|
|
539
|
+
|
|
540
|
+
Override the global default in individual SFR files:
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
import { REST } from "@avtechno/sfr";
|
|
544
|
+
|
|
545
|
+
export default REST({
|
|
546
|
+
cfg: {
|
|
547
|
+
base_dir: "api",
|
|
548
|
+
// Custom rate limit for this route
|
|
549
|
+
rate_limit: {
|
|
550
|
+
max: 10, // 10 requests
|
|
551
|
+
windowMs: 60000 // per minute
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
// ... rest of config
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Disable Rate Limiting
|
|
559
|
+
|
|
560
|
+
To disable rate limiting for a specific route:
|
|
561
|
+
|
|
562
|
+
```javascript
|
|
563
|
+
cfg: {
|
|
564
|
+
rate_limit: false // Disables rate limiting for this route
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Advanced Configuration
|
|
569
|
+
|
|
570
|
+
All `express-rate-limit` options are supported:
|
|
571
|
+
|
|
572
|
+
```javascript
|
|
573
|
+
import RedisStore from "rate-limit-redis";
|
|
574
|
+
import Redis from "ioredis";
|
|
575
|
+
|
|
576
|
+
const redis = new Redis();
|
|
577
|
+
|
|
578
|
+
set_rate_limit_config({
|
|
579
|
+
default: {
|
|
580
|
+
max: 100,
|
|
581
|
+
windowMs: 60000,
|
|
582
|
+
// Custom key generator (e.g., by user ID)
|
|
583
|
+
keyGenerator: (req) => req.user?.id || req.ip,
|
|
584
|
+
// Skip rate limiting for certain requests
|
|
585
|
+
skip: (req) => req.user?.isAdmin === true,
|
|
586
|
+
// Custom store (Redis, MongoDB, etc.)
|
|
587
|
+
store: new RedisStore({ client: redis })
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**Note:** Rate limiting middleware is applied before validation and authentication middleware.
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## Auto-Generated Documentation
|
|
597
|
+
|
|
598
|
+
SFR automatically generates API documentation in standard formats:
|
|
599
|
+
|
|
600
|
+
- **REST endpoints** → OpenAPI 3.0 specification
|
|
601
|
+
- **MQ consumers** → AsyncAPI 3.0 specification
|
|
602
|
+
|
|
603
|
+
Documentation is written to the `cfg.out` directory as YAML files, enabling integration with:
|
|
604
|
+
|
|
605
|
+
- Swagger UI / ReDoc
|
|
606
|
+
- Postman / Insomnia import
|
|
607
|
+
- API gateways
|
|
608
|
+
- SDK generators
|
|
609
|
+
- Automated testing tools
|
|
610
|
+
|
|
611
|
+
### Handler Metadata
|
|
612
|
+
|
|
613
|
+
Enhance generated documentation with metadata:
|
|
614
|
+
|
|
615
|
+
```javascript
|
|
616
|
+
handlers: {
|
|
617
|
+
GET: {
|
|
618
|
+
"fetch-items": {
|
|
619
|
+
summary: "Short description", // Brief endpoint summary
|
|
620
|
+
description: "Detailed explanation", // Full documentation
|
|
621
|
+
tags: ["inventory", "read"], // Categorization tags
|
|
622
|
+
deprecated: false, // Mark as deprecated
|
|
623
|
+
async fn(req, res) { /* ... */ }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## Error Handling
|
|
632
|
+
|
|
633
|
+
SFR provides automatic error handling for both handlers and controllers:
|
|
634
|
+
|
|
635
|
+
| Context | Supported Error Styles |
|
|
636
|
+
|---------|------------------------|
|
|
637
|
+
| **Handlers** | `throw new Error()`, `next(error)` |
|
|
638
|
+
| **Controllers** | `Promise.reject()`, `return Promise.resolve/reject` |
|
|
639
|
+
|
|
640
|
+
### Example
|
|
641
|
+
|
|
642
|
+
```javascript
|
|
643
|
+
handlers: {
|
|
644
|
+
GET: {
|
|
645
|
+
"risky-operation": {
|
|
646
|
+
async fn(req, res, next) {
|
|
647
|
+
try {
|
|
648
|
+
const result = await this.dangerous_call();
|
|
649
|
+
res.json({ data: result });
|
|
650
|
+
} catch (error) {
|
|
651
|
+
// Option 1: Throw (SFR catches this)
|
|
652
|
+
throw error;
|
|
653
|
+
|
|
654
|
+
// Option 2: Pass to Express error handler
|
|
655
|
+
// next(error);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
controllers: {
|
|
663
|
+
async dangerous_call() {
|
|
664
|
+
// Rejections are automatically caught
|
|
665
|
+
if (bad_condition) {
|
|
666
|
+
return Promise.reject(new Error("Operation failed"));
|
|
667
|
+
}
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
## MQ Integration
|
|
676
|
+
|
|
677
|
+
### Initializing with Message Queue
|
|
678
|
+
|
|
679
|
+
```javascript
|
|
680
|
+
import sfr, { MQLib } from "@avtechno/sfr";
|
|
681
|
+
|
|
682
|
+
const mq = new MQLib();
|
|
683
|
+
await mq.init(process.env.RABBITMQ_URL);
|
|
684
|
+
|
|
685
|
+
await sfr(
|
|
686
|
+
{ root: "dist", path: "api", out: "docs" },
|
|
687
|
+
{ /* OAS config */ },
|
|
688
|
+
{
|
|
689
|
+
REST: app,
|
|
690
|
+
MQ: mq.get_channel()
|
|
691
|
+
},
|
|
692
|
+
"api"
|
|
693
|
+
);
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
### Using MQ in REST Handlers
|
|
697
|
+
|
|
698
|
+
When MQ is configured, handlers receive access to messaging patterns:
|
|
699
|
+
|
|
700
|
+
```javascript
|
|
701
|
+
handlers: {
|
|
702
|
+
POST: {
|
|
703
|
+
"trigger-job": {
|
|
704
|
+
async fn(req, res) {
|
|
705
|
+
// Access injected MQ patterns
|
|
706
|
+
await this.mq["Point-to-Point"].produce("job-queue", req.body);
|
|
707
|
+
res.json({ status: "queued" });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Debugging
|
|
717
|
+
|
|
718
|
+
Enable debug output by setting the environment variable:
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
DEBUG_SFR=true node server.js
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
This displays:
|
|
725
|
+
- Number of mounted endpoints per protocol
|
|
726
|
+
- Resolved paths for each SFR
|
|
727
|
+
- Protocol status indicators
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
## API Reference
|
|
732
|
+
|
|
733
|
+
### Exports
|
|
734
|
+
|
|
735
|
+
| Export | Description |
|
|
736
|
+
|--------|-------------|
|
|
737
|
+
| `default` | Main SFR initializer function |
|
|
738
|
+
| `REST` | Template function for REST endpoints |
|
|
739
|
+
| `WS` | Template function for WebSocket handlers |
|
|
740
|
+
| `MQ` | Template function for MQ consumers |
|
|
741
|
+
| `MQLib` | Helper class for MQ connection management |
|
|
742
|
+
| `BroadcastMQ` | MQ class for Fanout/Direct/Topic patterns |
|
|
743
|
+
| `TargetedMQ` | MQ class for Point-to-Point/Request-Reply patterns |
|
|
744
|
+
| `inject` | Dependency injection function |
|
|
745
|
+
| `set_observability_options` | Configure observability settings |
|
|
746
|
+
| `sfr_logger` | Logger with automatic trace context |
|
|
747
|
+
| `with_span` | Create custom tracing spans |
|
|
748
|
+
| `get_trace_context` | Get current trace/span IDs |
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
## Observability (OpenTelemetry)
|
|
753
|
+
|
|
754
|
+
SFR includes built-in OpenTelemetry-compatible observability with automatic instrumentation for all protocols.
|
|
755
|
+
|
|
756
|
+
### Features
|
|
757
|
+
|
|
758
|
+
- **Tracing**: Distributed traces across REST, WebSocket, and MQ handlers
|
|
759
|
+
- **Metrics**: Request counts, latencies, error rates, connection gauges
|
|
760
|
+
- **Logging**: Structured logs with automatic trace context correlation
|
|
761
|
+
- **Auto-instrumentation**: Express, Socket.IO, and AMQP are automatically traced
|
|
762
|
+
|
|
763
|
+
### Quick Start
|
|
764
|
+
|
|
765
|
+
Observability is enabled by default and uses your `OASConfig` for service identification:
|
|
766
|
+
|
|
767
|
+
```javascript
|
|
768
|
+
import sfr from "@avtechno/sfr";
|
|
769
|
+
|
|
770
|
+
// Service name and version are extracted from oas_cfg automatically
|
|
771
|
+
await sfr(
|
|
772
|
+
{ root: "dist", path: "api", out: "docs" },
|
|
773
|
+
{
|
|
774
|
+
title: "My Service",
|
|
775
|
+
version: "1.0.0",
|
|
776
|
+
meta: {
|
|
777
|
+
service: "my-service", // Used as service.name in traces
|
|
778
|
+
domain: "example-org",
|
|
779
|
+
type: "backend",
|
|
780
|
+
language: "javascript",
|
|
781
|
+
port: 3000
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
{ REST: app }
|
|
785
|
+
);
|
|
56
786
|
```
|
|
57
787
|
|
|
58
|
-
###
|
|
59
|
-
|
|
788
|
+
### Configuration
|
|
789
|
+
|
|
790
|
+
Configure observability before initializing SFR:
|
|
791
|
+
|
|
792
|
+
```javascript
|
|
793
|
+
import sfr, { set_observability_options } from "@avtechno/sfr";
|
|
794
|
+
|
|
795
|
+
set_observability_options({
|
|
796
|
+
enabled: true, // Enable/disable (default: true)
|
|
797
|
+
otlp_endpoint: "http://localhost:4318", // OTLP collector endpoint
|
|
798
|
+
auto_instrumentation: true, // Auto-instrument common libraries
|
|
799
|
+
log_format: "json", // 'json' or 'pretty'
|
|
800
|
+
debug: false // Enable OTel SDK debug logs
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await sfr(cfg, oas_cfg, connectors);
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### Environment Variables
|
|
807
|
+
|
|
808
|
+
| Variable | Description | Default |
|
|
809
|
+
|----------|-------------|---------|
|
|
810
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint | `http://localhost:4318` |
|
|
811
|
+
| `SFR_TELEMETRY_ENABLED` | Enable/disable telemetry | `true` |
|
|
812
|
+
| `SFR_LOG_FORMAT` | Log format (`json`/`pretty`) | `pretty` (dev), `json` (prod) |
|
|
813
|
+
| `DEBUG_SFR` | Enable debug logging | `false` |
|
|
814
|
+
|
|
815
|
+
### Custom Spans in Handlers
|
|
816
|
+
|
|
817
|
+
Add custom tracing spans for fine-grained observability:
|
|
818
|
+
|
|
819
|
+
```javascript
|
|
820
|
+
import { REST, with_span } from "@avtechno/sfr";
|
|
821
|
+
|
|
822
|
+
export default REST({
|
|
823
|
+
handlers: {
|
|
824
|
+
GET: {
|
|
825
|
+
"get-user": {
|
|
826
|
+
async fn(req, res) {
|
|
827
|
+
// Create a child span for database operation
|
|
828
|
+
const user = await with_span({
|
|
829
|
+
name: "db:fetch_user",
|
|
830
|
+
attributes: { "user.id": req.params.id }
|
|
831
|
+
}, async (span) => {
|
|
832
|
+
return this.db.users.findById(req.params.id);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
res.json({ data: user });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
### Logging with Trace Context
|
|
844
|
+
|
|
845
|
+
Logs automatically include trace context when a span is active:
|
|
846
|
+
|
|
847
|
+
```javascript
|
|
848
|
+
import { REST, sfr_logger, create_child_logger } from "@avtechno/sfr";
|
|
849
|
+
|
|
850
|
+
export default REST({
|
|
851
|
+
handlers: {
|
|
852
|
+
POST: {
|
|
853
|
+
"create-order": {
|
|
854
|
+
async fn(req, res) {
|
|
855
|
+
// Logs include trace_id and span_id automatically
|
|
856
|
+
sfr_logger.info("Creating order", { user_id: req.user.id });
|
|
857
|
+
|
|
858
|
+
// Create a child logger with additional context
|
|
859
|
+
const order_logger = create_child_logger({ order_id: "ord_123" });
|
|
860
|
+
order_logger.debug("Processing payment");
|
|
861
|
+
|
|
862
|
+
res.json({ data: { success: true } });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### Graceful Shutdown
|
|
871
|
+
|
|
872
|
+
Ensure telemetry is flushed before shutdown:
|
|
873
|
+
|
|
874
|
+
```javascript
|
|
875
|
+
import { shutdown_observability } from "@avtechno/sfr";
|
|
876
|
+
|
|
877
|
+
process.on("SIGTERM", async () => {
|
|
878
|
+
await shutdown_observability();
|
|
879
|
+
process.exit(0);
|
|
880
|
+
});
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
### Metrics Available
|
|
884
|
+
|
|
885
|
+
| Metric | Type | Description |
|
|
886
|
+
|--------|------|-------------|
|
|
887
|
+
| `sfr.rest.requests.total` | Counter | Total REST requests |
|
|
888
|
+
| `sfr.rest.request.duration` | Histogram | Request latency (ms) |
|
|
889
|
+
| `sfr.rest.errors.total` | Counter | Total REST errors |
|
|
890
|
+
| `sfr.ws.connections.active` | Gauge | Active WebSocket connections |
|
|
891
|
+
| `sfr.ws.events.total` | Counter | Total WebSocket events |
|
|
892
|
+
| `sfr.mq.messages.received` | Counter | Total MQ messages received |
|
|
893
|
+
| `sfr.mq.processing.duration` | Histogram | Message processing time (ms) |
|
|
894
|
+
|
|
895
|
+
> 📖 **For comprehensive OpenTelemetry documentation**, including integration guides for Prometheus, Grafana, Loki, Jaeger, and other backends, see [OpenTelemetry Documentation](./docs/09-opentelemetry.md).
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## Roadmap
|
|
900
|
+
|
|
901
|
+
- [ ] Multer upload validation in OpenAPI specs
|
|
902
|
+
- [x] Built-in structured logging
|
|
903
|
+
- [x] WebSocket protocol implementation
|
|
904
|
+
- [x] Rate limiting middleware integration
|
|
905
|
+
- [ ] GraphQL protocol support
|
|
906
|
+
- [ ] gRPC protocol support
|
|
907
|
+
|
|
908
|
+
Now that I'm using a diverse set of communication protocols, I may rewrite SFR into a more pluggable and modular format with each protocol and major feature being treated as an installable extension.
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
## Contributing
|
|
60
913
|
|
|
61
|
-
|
|
62
|
-
|-|-|
|
|
63
|
-
|Exception Throws|Promise.resolve/reject returns|
|
|
64
|
-
|Passing Exceptions to Next fn||
|
|
914
|
+
Found a bug or have a feature request? Please open an issue on our [GitHub Issues](https://github.com/MannyWeeb/sfr/issues) page.
|
|
65
915
|
|
|
916
|
+
---
|
|
66
917
|
|
|
67
|
-
|
|
68
|
-
If you've found a bug, please file it in our Github Issues Page so we can have a look at it, perhaps fix it XD
|
|
918
|
+
## License
|
|
69
919
|
|
|
70
|
-
|
|
71
|
-
* Multer Upload Validation
|
|
72
|
-
* Built-in Logging
|
|
920
|
+
ISC © Emmanuel Abellana
|