@avtechno/sfr 1.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.
@@ -0,0 +1,8 @@
1
+ export declare function REST<V, H, C>(struct: RESTHandlerDescriptor<V, H, C>): H & C;
2
+ export declare function WS<V, H, C>(struct: WSHandlerDescriptor<V, H, C>): {
3
+ validators: RequestValidators;
4
+ controllers: RequestControllers;
5
+ handlers: WSRequestHandlers;
6
+ cfg: SFRConfig;
7
+ };
8
+ export declare function MQ<V, H, C>(struct: MQHandlerDescriptor<V, H, C>): H & C;
@@ -0,0 +1,7 @@
1
+ import { Response } from "express";
2
+ import Joi from "joi";
3
+ export declare function get_stats(dirs: string[], base_dir: string): Promise<string[]>;
4
+ export declare function assess_namespace(stats: string[], dir: string): Promise<any>;
5
+ export declare const template: (rule: object, v: object) => string | false;
6
+ export declare const object_id: Joi.StringSchema<string>;
7
+ export declare const handle_res: (controller: Promise<any>, res: Response) => void;
package/dist/util.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import Joi from "joi";
4
+ export async function get_stats(dirs, base_dir) {
5
+ let temp = await Promise.all(dirs.map((v) => fs.lstat(path.join(base_dir, v)).then((stats) => ({ stats, dir: v }))));
6
+ const result = temp.filter(({ stats }) => stats.isFile()).map((v) => v.dir);
7
+ return result;
8
+ }
9
+ export async function assess_namespace(stats, dir) {
10
+ let dirs = stats.map((v) => `file:///${dir.replaceAll("\\", "/")}/${v}`);
11
+ const result = await Promise.all(dirs.map((d) => import(d).then((v) => [d.replace(".mjs", "").substring(d.lastIndexOf("/") + 1), v.default]))).then(Object.fromEntries);
12
+ return result;
13
+ }
14
+ export const template = (rule, v) => _validate(Joi.object(rule).validate(v));
15
+ function _validate(expression) {
16
+ let result = expression.error;
17
+ return result ? result.details[0].message : false;
18
+ }
19
+ export const object_id = Joi.string().hex().length(24).required();
20
+ /* QoLs */
21
+ export const handle_res = (controller, res) => {
22
+ controller
23
+ .then((data) => res.json({ data }))
24
+ .catch((error) => res.status(400).json({ error }));
25
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@avtechno/sfr",
3
+ "version": "1.0.0",
4
+ "description": "An opinionated way of writing services using ExpressJS.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "lib",
9
+ "src"
10
+ ],
11
+ "main": "./dist/index.mjs",
12
+ "types": "./dist/types/index.d.mts",
13
+ "exports": {
14
+ "types": "./dist/types/index.d.mts",
15
+ "default": "./dist/index.mjs",
16
+ "import": ["./dist/index.mjs", "./dist/mq.mjs"]
17
+ },
18
+ "scripts": {
19
+ "clean": "rimraf ./dist",
20
+ "build": "yarn clean && tsc --project tsconfig.json",
21
+ "build:watch": "yarn clean && tsc --project tsconfig.json -w",
22
+ "postversion": "yarn build"
23
+ },
24
+ "prepublish": "tsc",
25
+ "author": "Emmanuel Abellana",
26
+ "license": "ISC",
27
+ "dependencies": {
28
+ "amqplib": "0.10.7",
29
+ "express": "^4.18.2",
30
+ "express-session": "^1.17.3",
31
+ "file-type": "^18.5.0",
32
+ "joi": "^17.11.0",
33
+ "joi-to-swagger": "^6.2.0",
34
+ "js-yaml": "^4.1.0",
35
+ "multer": "^1.4.5-lts.1",
36
+ "socket.io": "^4.7.2",
37
+ "winston": "^3.17.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/amqplib": "^0.10.5",
41
+ "@types/express": "^4.17.19",
42
+ "@types/express-session": "^1.18.0",
43
+ "@types/js-yaml": "^4.0.7",
44
+ "@types/multer": "^1.4.11",
45
+ "@types/openapi-v3": "^3.0.0",
46
+ "openapi-types": "^12.1.3",
47
+ "rimraf": "^5.0.1",
48
+ "typedoc": "^0.25.2",
49
+ "typescript": "^5.2.2"
50
+ }
51
+ }
@@ -0,0 +1,35 @@
1
+ import Joi from "joi";
2
+ import { REST } from "./templates.mjs";
3
+
4
+
5
+ export default REST({
6
+ cfg : {
7
+ base_dir : "example",
8
+ public : false,
9
+ },
10
+ validators: {
11
+ "get-something": {
12
+ "name": Joi.string().required(),
13
+ "age": Joi.number().required(),
14
+ }
15
+ },
16
+ handlers: {
17
+ GET : {
18
+ "get-something": {
19
+ description : "Get something I guess?",
20
+ async fn(req, res){
21
+ const result = await this.db_call()
22
+ res.status(200).json({
23
+ data: result
24
+ });
25
+ }
26
+ }
27
+ }
28
+ },
29
+ controllers: {
30
+ async db_call(){
31
+ /* Perform database calls here through accessing the "this" context (if injections are set) */
32
+ return "Something";
33
+ }
34
+ }
35
+ })
package/src/index.mts ADDED
@@ -0,0 +1,99 @@
1
+ /// <reference path="types/index.d.ts" />
2
+ import yaml from "js-yaml";
3
+ import path from "path";
4
+ import fs from "fs/promises";
5
+
6
+ import { REST, WS, MQ } from "./templates.mjs";
7
+ import { SFRPipeline } from "./sfr-pipeline.mjs";
8
+ import { MQLib } from "./mq.mjs";
9
+
10
+ const cwd = process.cwd();
11
+
12
+ export default async (cfg: ParserCFG, oas_cfg: OASConfig, connectors: SFRProtocols, base_url?: string):Promise<ServiceManifest> => {
13
+ //TODO: Verify connectors
14
+ const sfr = new SFRPipeline(cfg, oas_cfg, connectors);
15
+
16
+ // Returned service artifacts for both OpenAPI and AsyncAPI are written into the server directory
17
+ // An express static endpoint is pointed to this directory for service directory.
18
+ const documents = await sfr.init(base_url);
19
+
20
+ write_service_discovery(cfg, documents);//Writes services to output dir (cfg.out)
21
+
22
+ return {
23
+ ...oas_cfg,
24
+ documents
25
+ };
26
+ };
27
+
28
+ async function write_service_discovery(cfg: ParserCFG, documents: ServiceDocuments) {
29
+ //Setup files in case they do not exist.
30
+
31
+ //Setup output folder
32
+ await fs.mkdir(path.join(cwd, cfg.out), { recursive: true });
33
+
34
+ // Loop over each protocol
35
+ for (let [protocol, documentation] of Object.entries(documents)) {
36
+ //Strictly await for setup to create dirs for each protocol.
37
+ await fs.mkdir(path.join(cwd, cfg.out, protocol.toLowerCase()), { recursive: true });
38
+ //Convert documentation into service manifest.
39
+
40
+ //OpenAPI Documents : paths
41
+ //AsyncAPI Documents : channels
42
+ switch (protocol) {
43
+ case "REST": {
44
+ documentation = documentation as OAPI_Document;
45
+ for (const [path, methods] of Object.entries(documentation.paths)) {
46
+ write_to_file(cfg, `rest/${path}`, methods);
47
+ }
48
+ } break;
49
+
50
+ case "WS": {
51
+
52
+ } break;
53
+
54
+ case "MQ": {
55
+ documentation = documentation as AsyncAPIDocument;
56
+ write_to_file(cfg, "mq/index.yaml", documentation);
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ //Tasked with recursively creating directories and spec files.
63
+ async function write_to_file(cfg: ParserCFG, dir: string, data: object) {
64
+ //Path is a slash(/) delimited string, with the end delimiter indicating the filename to be used.
65
+ const paths = dir.split("/");
66
+
67
+ //Indicates a root file
68
+ if (paths.length === 1) {
69
+ await fs.writeFile(path.join(cwd, cfg.out, paths[0]), yaml.dump(data), { flag: "w+" });
70
+ } else {//Indicates a nested file
71
+ const file = paths.pop();//Pops the path array to be used for dir creation
72
+ await fs.mkdir(path.join(cwd, cfg.out, ...paths), { recursive: true });
73
+ fs.writeFile(path.join(cwd, cfg.out, ...paths, `${file}.yml`), yaml.dump(data), { flag: "w+" });
74
+ }
75
+ }
76
+
77
+ function inject(injections : InjectedFacilities){
78
+ SFRPipeline.injections.rest.handlers = {
79
+ ...SFRPipeline.injections.rest.handlers,
80
+ ...injections.handlers
81
+ }
82
+
83
+ SFRPipeline.injections.rest.controllers = {
84
+ ...SFRPipeline.injections.rest.controllers,
85
+ ...injections.controllers
86
+ }
87
+
88
+ SFRPipeline.injections.mq.handlers = {
89
+ ...SFRPipeline.injections.mq.handlers,
90
+ ...injections.handlers
91
+ }
92
+
93
+ SFRPipeline.injections.mq.controllers = {
94
+ ...SFRPipeline.injections.mq.controllers,
95
+ ...injections.controllers
96
+ }
97
+ }
98
+
99
+ export { REST, WS, MQ, MQLib, inject };
package/src/logger.mts ADDED
@@ -0,0 +1,52 @@
1
+ import winston from 'winston';
2
+
3
+ // Define log levels
4
+ const levels = {
5
+ error: 0,
6
+ warn: 1,
7
+ info: 2,
8
+ http: 3,
9
+ verbose: 4,
10
+ debug: 5,
11
+ silly: 6,
12
+ };
13
+
14
+ // Define colors for each level
15
+ const colors = {
16
+ error: 'red',
17
+ warn: 'yellow',
18
+ info: 'green',
19
+ http: 'magenta',
20
+ verbose: 'cyan',
21
+ debug: 'blue',
22
+ silly: 'gray',
23
+ };
24
+
25
+ // Add colors to Winston
26
+ winston.addColors(colors);
27
+
28
+ // Define the format for logs
29
+ const format = winston.format.combine(
30
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
31
+ winston.format.colorize({ all: true }),
32
+ winston.format.printf(
33
+ (info) =>
34
+ `${info.timestamp} ${info.level}: ${info.message}${
35
+ info.metadata ? ` ${JSON.stringify(info.metadata)}` : ''
36
+ }`
37
+ )
38
+ );
39
+
40
+ // Create the Winston logger
41
+ const logger = winston.createLogger({
42
+ level: process.env.NODE_ENV === 'development' && Boolean(process.env.DEBUG_SFR) ? 'debug' : 'info',
43
+ levels,
44
+ format,
45
+ transports: [
46
+ // Console transport
47
+ new winston.transports.Console(),
48
+ ],
49
+ });
50
+
51
+ // Export the logger
52
+ export { logger };
package/src/mq.mts ADDED
@@ -0,0 +1,236 @@
1
+ import { Connection, Options, connect, Channel, ConsumeMessage } from "amqplib";
2
+
3
+ /**
4
+ * MQLib class for establishing a connection to the message queue and creating channels.
5
+ */
6
+ export class MQLib {
7
+ private connection?: Connection;
8
+ private channel?: Channel;
9
+
10
+ /**
11
+ * Initializes the connection and channel to the message queue.
12
+ *
13
+ * @param MQ_URL - The URL of the message queue.
14
+ * @example
15
+ * const mqLib = new MQLib();
16
+ * await mqLib.init("amqp://localhost");
17
+ */
18
+ async init(MQ_URL: string) {
19
+ await connect(MQ_URL)
20
+ .then(async (v) => {
21
+ //@ts-ignore
22
+ this.connection = v;
23
+ this.channel = await v.createChannel();
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Returns the connection object.
29
+ *
30
+ * @returns The connection object to the message queue.
31
+ */
32
+ get_connection() {
33
+ return this.connection;
34
+ }
35
+
36
+ /**
37
+ * Returns the channel object.
38
+ *
39
+ * @returns The channel object to the message queue.
40
+ */
41
+ get_channel() {
42
+ return this.channel;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * BroadcastMQ class to handle different types of broadcast communication patterns: Publish-Subscribe, Routing, and Topic.
48
+ */
49
+ export class BroadcastMQ {
50
+ constructor(public channel: Channel, protected type: CommunicationPattern) {}
51
+
52
+ /**
53
+ * Publishes a message to the specified exchange based on the communication pattern.
54
+ *
55
+ * @param exchange - The name of the exchange to send the message to.
56
+ * @param key - The routing key or pattern to use, depending on the communication pattern.
57
+ * @param payload - The message to be sent.
58
+ * @param options - Additional publishing options.
59
+ * @example
60
+ * // Publish a message to a 'logsExchange' with a routing key 'error' for a routing pattern.
61
+ * const mq = new BroadcastMQ(channel, "Routing");
62
+ * mq.publish("logsExchange", "error", { level: "error", message: "Something went wrong" });
63
+ */
64
+ async publish(exchange: string, key: string, payload: any, options?: Options.Publish) {
65
+ switch (this.type) {
66
+ case "Publish-Subscribe": {
67
+ await this.channel.assertExchange(exchange, "fanout", { durable: false });
68
+ this.channel.publish(exchange, "", encode_payload(payload), options);
69
+ break;
70
+ }
71
+
72
+ case "Routing": {
73
+ await this.channel.assertExchange(exchange, "direct", { durable: false });
74
+ this.channel.publish(exchange, key, encode_payload(payload), options);
75
+ break;
76
+ }
77
+
78
+ case "Topic": {
79
+ await this.channel.assertExchange(exchange, "topic", { durable: false });
80
+ this.channel.publish(exchange, key, encode_payload(payload), options);
81
+ break;
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Subscribes to an exchange to receive messages based on the communication pattern.
88
+ *
89
+ * @param exchange - The name of the exchange to subscribe to.
90
+ * @param key - The routing key or pattern to listen for.
91
+ * @param cfg - Configuration options for the consumer, including the callback function.
92
+ * @param options - Additional consumption options.
93
+ * @example
94
+ * // Subscribe to the 'logsExchange' for all 'error' messages in the Routing pattern.
95
+ * const mq = new BroadcastMQ(channel, "Routing");
96
+ * mq.subscribe("logsExchange", "error", { fn: handleErrorLogs, options: { noAck: true } });
97
+ */
98
+ async subscribe(
99
+ exchange: string,
100
+ key: string,
101
+ cfg: ConsumerConfig,
102
+ options?: Options.Publish
103
+ ) {
104
+ let fn = cfg.fn.bind({ channel: this.channel, type: this.type });
105
+
106
+ switch (this.type) {
107
+ case "Publish-Subscribe": {
108
+ await this.channel.assertExchange(exchange, "fanout", { durable: false });
109
+ const { queue } = await this.channel.assertQueue("", { exclusive: true });
110
+
111
+ this.channel.bindQueue(queue, exchange, "");
112
+ this.channel.consume(queue, (v) => fn(parse_payload(v)), cfg.options);
113
+ break;
114
+ }
115
+
116
+ case "Routing": {
117
+ await this.channel.assertExchange(exchange, "direct", { durable: false });
118
+ const { queue } = await this.channel.assertQueue("", { exclusive: true });
119
+ await this.channel.bindQueue(queue, exchange, key);
120
+
121
+ this.channel.consume(queue, (v) => fn(parse_payload(v)), options);
122
+ break;
123
+ }
124
+
125
+ case "Topic": {
126
+ await this.channel.assertExchange(exchange, "topic", { durable: false });
127
+ const { queue } = await this.channel.assertQueue("", { exclusive: true });
128
+
129
+ this.channel.bindQueue(queue, exchange, key);
130
+ this.channel.consume(queue, (v) => fn(parse_payload(v)), options);
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * TargetedMQ class to handle Point-to-Point and Request-Reply communication patterns.
139
+ */
140
+ export class TargetedMQ {
141
+ constructor(public channel: Channel, protected type: CommunicationPattern) {}
142
+
143
+ /**
144
+ * Sends a message to the specified queue depending on the communication pattern.
145
+ *
146
+ * @param binding - The name of the queue or binding to send the message to.
147
+ * @param payload - The message to be sent.
148
+ * @param options - Additional publishing options.
149
+ * @example
150
+ * // Send a message to a queue for point-to-point communication
151
+ * const mq = new TargetedMQ(channel, "Point-to-Point");
152
+ * mq.produce("taskQueue", { taskId: 1, action: "process" });
153
+ */
154
+ async produce(binding: string, payload: any, options?: Options.Publish) {
155
+ /* Alter produce strategy depending on the instance's configured comm pattern */
156
+ switch (this.type) {
157
+ case "Point-to-Point": {
158
+ await this.channel.assertQueue(binding);
159
+ return this.channel.sendToQueue(binding, encode_payload(payload), options);
160
+ }
161
+
162
+ case "Request-Reply": {
163
+ await this.channel.assertQueue(binding);
164
+ return this.channel.sendToQueue(binding, encode_payload(payload), options);
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Sends a reply to the message sender in a Request-Reply pattern.
171
+ *
172
+ * @param msg - The original message to reply to.
173
+ * @param payload - The reply message to send back to the requester.
174
+ * @example
175
+ * // Send a response back to the client in a Request-Reply pattern
176
+ * const mq = new TargetedMQ(channel, "Request-Reply");
177
+ * mq.reply(msg, { result: "Processed successfully" });
178
+ */
179
+ async reply(msg: ConsumeMessage, payload: any) {
180
+ if (this.type !== "Request-Reply") return;
181
+
182
+ this.channel.sendToQueue(msg.properties.replyTo, encode_payload(payload));
183
+ this.channel.ack(msg);
184
+ }
185
+
186
+ /**
187
+ * Consumes messages from the specified queue based on the communication pattern.
188
+ *
189
+ * @param queue - The name of the queue to consume messages from.
190
+ * @param cfg - Configuration options for the consumer, including the callback function.
191
+ * @example
192
+ * // Consume a message from the 'taskQueue' in a Point-to-Point communication pattern
193
+ * const mq = new TargetedMQ(channel, "Point-to-Point");
194
+ * mq.consume("taskQueue", { fn: processTask, options: { noAck: true } });
195
+ */
196
+ async consume(queue: string, cfg: ConsumerConfig) {
197
+ let fn = cfg.fn.bind({ channel: this.channel, type: this.type });
198
+
199
+ switch (this.type) {
200
+ case "Point-to-Point": {
201
+ await this.channel.assertQueue(queue);
202
+ this.channel.consume(queue, (v) => fn(parse_payload(v)), cfg.options);
203
+ break;
204
+ }
205
+
206
+ case "Request-Reply": {
207
+ await this.channel.assertQueue(queue);
208
+ this.channel.consume(queue, (v) => fn(parse_payload(v)), cfg.options);
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /* Helper Functions */
216
+
217
+ /**
218
+ * Parses the payload of a message.
219
+ *
220
+ * @param msg - The consumed message.
221
+ * @returns The parsed message content.
222
+ */
223
+ function parse_payload(msg: any) {
224
+ if(!msg)return {};
225
+ return { ...msg, content: JSON.parse(msg.content.toString()) };
226
+ }
227
+
228
+ /**
229
+ * Encodes a message as a Buffer to be sent over the message queue.
230
+ *
231
+ * @param msg - The message to be encoded.
232
+ * @returns The encoded message as a Buffer.
233
+ */
234
+ function encode_payload(msg: any) {
235
+ return Buffer.from(JSON.stringify(msg));
236
+ }