@extk/expressive 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,499 @@
1
+ // src/expressive.ts
2
+ import express from "express";
3
+ import helmet from "helmet";
4
+ import morgan from "morgan";
5
+ import qs from "qs";
6
+ import swaggerUi from "swagger-ui-express";
7
+
8
+ // src/swagger.ts
9
+ var buildSwaggerBuilder = (swaggerDoc) => {
10
+ return {
11
+ swaggerBuilder: () => {
12
+ return {
13
+ withInfo(info) {
14
+ swaggerDoc.info = info;
15
+ return this;
16
+ },
17
+ withServers(servers) {
18
+ swaggerDoc.servers = servers;
19
+ return this;
20
+ },
21
+ withSecuritySchemes(schemes) {
22
+ swaggerDoc.components.securitySchemes = schemes;
23
+ return this;
24
+ },
25
+ withSchemas(schemas) {
26
+ swaggerDoc.components.schemas = schemas;
27
+ return this;
28
+ },
29
+ withDefaultSecurity(globalAuthMethods) {
30
+ swaggerDoc.security = globalAuthMethods;
31
+ return this;
32
+ },
33
+ get() {
34
+ return swaggerDoc;
35
+ }
36
+ };
37
+ }
38
+ };
39
+ };
40
+ var securitySchemes = {
41
+ BasicAuth: () => ({
42
+ type: "http",
43
+ scheme: "basic"
44
+ }),
45
+ BearerAuth: () => ({
46
+ type: "http",
47
+ scheme: "bearer"
48
+ }),
49
+ ApiKeyAuth: (headerName) => ({
50
+ type: "apiKey",
51
+ in: "header",
52
+ name: headerName
53
+ }),
54
+ OpenID: (openIdConnectUrl) => ({
55
+ type: "openIdConnect",
56
+ openIdConnectUrl
57
+ }),
58
+ OAuth2: (authorizationUrl, tokenUrl, scopes) => ({
59
+ type: "oauth2",
60
+ flows: {
61
+ authorizationCode: {
62
+ authorizationUrl,
63
+ tokenUrl,
64
+ scopes
65
+ }
66
+ }
67
+ })
68
+ };
69
+ var securityRegistry = {};
70
+ var security = (name) => {
71
+ if (!securityRegistry[name]) {
72
+ securityRegistry[name] = { [name]: [] };
73
+ }
74
+ return securityRegistry[name];
75
+ };
76
+ function jsonSchema(schema) {
77
+ return {
78
+ content: {
79
+ "application/json": {
80
+ schema
81
+ }
82
+ }
83
+ };
84
+ }
85
+ function jsonSchemaRef(name) {
86
+ return jsonSchema({ $ref: `#/components/schemas/${name}` });
87
+ }
88
+ function param(inP, id, schema, required = true, description = "", name) {
89
+ return {
90
+ in: inP,
91
+ name: name ?? id,
92
+ desciption: description,
93
+ required,
94
+ schema
95
+ };
96
+ }
97
+ function pathParam(id, schema, required = true, description = "", name) {
98
+ return param("path", id, schema, required, description, name);
99
+ }
100
+ function queryParam(id, schema, required = true, description = "", name) {
101
+ return param("query", id, schema, required, description, name);
102
+ }
103
+ function headerParam(id, schema, required = true, description = "", name) {
104
+ return param("headers", id, schema, required, description, name);
105
+ }
106
+ function convertExpressPath(path2) {
107
+ return path2.replace(/:([a-zA-Z0-9_*]+)/g, "{$1}");
108
+ }
109
+ function tryParsePathParameters(path2) {
110
+ const matches = path2.match(/(?<={)[^}]+(?=})/g);
111
+ if (!matches) {
112
+ return [];
113
+ }
114
+ return matches.map((pp) => pathParam(pp, { type: "string" }));
115
+ }
116
+ var SWG = {
117
+ param,
118
+ pathParam,
119
+ queryParam,
120
+ headerParam,
121
+ jsonSchema,
122
+ jsonSchemaRef,
123
+ security,
124
+ securitySchemes
125
+ };
126
+
127
+ // src/expressive.ts
128
+ function buildExpressive(container, swaggerDoc) {
129
+ return {
130
+ expressiveServer(configs) {
131
+ const { options } = configs;
132
+ const app = configs.app ?? express();
133
+ app.use(helmet(options?.helmet ?? {}));
134
+ app.set("query parser", function(str) {
135
+ return qs.parse(str, { decoder(s) {
136
+ return decodeURIComponent(s);
137
+ } });
138
+ });
139
+ app.use(morgan(
140
+ options?.morgan?.format ?? ":req[x-real-ip] :method :url :status :res[content-length] - :response-time ms",
141
+ options?.morgan?.options ?? { stream: { write(message) {
142
+ container.logger.info(message.trim());
143
+ } } }
144
+ ));
145
+ app.use(configs.swagger.path, swaggerUi.serve, swaggerUi.setup(configs.swagger.doc));
146
+ return app;
147
+ },
148
+ expressiveRouter(configs) {
149
+ const router = express.Router();
150
+ return {
151
+ router,
152
+ addRoute(context, ...handlers) {
153
+ router[context.method](context.path, ...handlers);
154
+ const {
155
+ pathOverride,
156
+ pathParameters,
157
+ headerParameters,
158
+ queryParameters,
159
+ ...pathItemConfig
160
+ } = context.oapi || {};
161
+ const route = pathOverride ?? convertExpressPath(context.path);
162
+ const pathItem = {
163
+ // -- defaults --
164
+ responses: {},
165
+ // has to be defined or else responses are not documented... ¯\_(ツ)_/¯
166
+ // -- group defaults --
167
+ ...configs?.oapi || {},
168
+ // -- overrides --
169
+ ...pathItemConfig || {},
170
+ parameters: [
171
+ // ...(contract.pathParameters || []),
172
+ ...headerParameters || [],
173
+ ...queryParameters || []
174
+ ]
175
+ };
176
+ if (pathParameters?.length) {
177
+ pathItem.parameters.push(...pathParameters);
178
+ } else {
179
+ pathItem.parameters.push(...tryParsePathParameters(route));
180
+ }
181
+ swaggerDoc.paths[route] = {
182
+ [context.method]: pathItem
183
+ };
184
+ return router;
185
+ }
186
+ };
187
+ }
188
+ };
189
+ }
190
+
191
+ // src/env.ts
192
+ import dotenv from "dotenv";
193
+ dotenv.config();
194
+ function isDev() {
195
+ return getEnvVar("ENV") === "dev";
196
+ }
197
+ function isProd() {
198
+ return getEnvVar("ENV") === "prod";
199
+ }
200
+ function getEnvVar(configName) {
201
+ const config = process.env[configName];
202
+ if (!config) {
203
+ throw new Error(`Missing config '${configName}'`);
204
+ }
205
+ return config;
206
+ }
207
+
208
+ // src/errors.ts
209
+ var ApiError = class extends Error {
210
+ code;
211
+ httpStatusCode;
212
+ data;
213
+ constructor(message, httpStatusCode, errorCode) {
214
+ super(message);
215
+ this.name = this.constructor.name;
216
+ this.code = errorCode;
217
+ this.httpStatusCode = httpStatusCode;
218
+ }
219
+ setData(data) {
220
+ this.data = data;
221
+ return this;
222
+ }
223
+ };
224
+ var NotFoundError = class extends ApiError {
225
+ constructor(message = "Resource not found") {
226
+ super(message, 404, "NOT_FOUND");
227
+ }
228
+ };
229
+ var DuplicateError = class extends ApiError {
230
+ constructor(message = "Duplicate entry") {
231
+ super(message, 409, "DUPLICATE_ENTRY");
232
+ }
233
+ };
234
+ var BadRequestError = class extends ApiError {
235
+ constructor(message = "Bad request") {
236
+ super(message, 400, "BAD_REQUEST");
237
+ }
238
+ };
239
+ var SchemaValidationError = class extends ApiError {
240
+ constructor(message = "Failed to validate Schema") {
241
+ super(message, 400, "SCHEMA_VALIDATION_ERROR");
242
+ }
243
+ };
244
+ var FileTooBigError = class extends ApiError {
245
+ constructor() {
246
+ super("File too big", 400, "FILE_TOO_BIG");
247
+ }
248
+ };
249
+ var InvalidFileTypeError = class extends ApiError {
250
+ constructor() {
251
+ super("Invalid file type", 400, "INVALID_FILE_TYPE");
252
+ }
253
+ };
254
+ var InvalidCredentialsError = class extends ApiError {
255
+ constructor() {
256
+ super("Invalid credentials", 401, "INVALID_CREDENTIALS");
257
+ }
258
+ };
259
+ var InternalError = class extends ApiError {
260
+ constructor() {
261
+ super("Internal error", 500, "INTERNAL_ERROR");
262
+ }
263
+ };
264
+ var TooManyRequestsError = class extends ApiError {
265
+ constructor(message = "Too many requests") {
266
+ super(message, 429, "TOO_MANY_REQUESTS");
267
+ }
268
+ };
269
+ var ForbiddenError = class extends ApiError {
270
+ constructor(message = "Action not allowed") {
271
+ super(message, 403, "FORBIDDEN");
272
+ }
273
+ };
274
+ var TokenExpiredError = class extends ApiError {
275
+ constructor(message = "Token Expired") {
276
+ super(message, 401, "TOKEN_EXPIRED");
277
+ }
278
+ };
279
+ var UserUnauthorizedError = class extends ApiError {
280
+ constructor(message = "User unauthorized") {
281
+ super(message, 401, "USER_UNAUTHORIZED");
282
+ }
283
+ };
284
+
285
+ // src/response/ApiErrorResponse.ts
286
+ var ApiErrorResponse = class {
287
+ status = "error";
288
+ message;
289
+ errorCode;
290
+ errors;
291
+ constructor(message, errorCode, errors) {
292
+ this.message = message;
293
+ this.errorCode = errorCode;
294
+ this.errors = errors;
295
+ }
296
+ };
297
+
298
+ // src/common.ts
299
+ import path from "path";
300
+ function parsePositiveInteger(v, defaultValue, max) {
301
+ const value = Number(v);
302
+ return Number.isInteger(value) && value > 0 && (!max || value <= max) ? value : defaultValue;
303
+ }
304
+ function parseIdOrFail(v) {
305
+ const value = Number(v);
306
+ if (!Number.isInteger(value) || value <= 0) {
307
+ throw new ApiError("Invalid Id", 400, "INVALID_ID");
308
+ }
309
+ return value;
310
+ }
311
+ function slugify(text) {
312
+ return text.toString().normalize("NFKD").toLowerCase().trim().replace(/[\s\n_+.]/g, "-").replace(/--+/g, "-");
313
+ }
314
+ function getTmpDir() {
315
+ return path.resolve("tmp");
316
+ }
317
+ function getTmpPath(...steps) {
318
+ return path.join(getTmpDir(), ...steps);
319
+ }
320
+ function parseDefaultPagination(query) {
321
+ const limit = parsePositiveInteger(query.limit, 50, 100);
322
+ const page = parsePositiveInteger(query.page, 1, 1e3);
323
+ const offset = (page - 1) * limit;
324
+ return { limit, offset };
325
+ }
326
+ function createReqSnapshot(req) {
327
+ return {
328
+ query: req.query,
329
+ path: req.path,
330
+ method: req.method,
331
+ userId: req?.user?.id
332
+ };
333
+ }
334
+
335
+ // src/middleware/errorHandlerMiddleware.ts
336
+ var buildErrorHandlerMiddleware = (container) => {
337
+ const { logger, alertHandler } = container;
338
+ return {
339
+ getErrorHandlerMiddleware: (errorMapper) => {
340
+ return async (err, req, res, _next) => {
341
+ let finalError;
342
+ const customMappedError = errorMapper && errorMapper(err);
343
+ if (customMappedError) {
344
+ finalError = customMappedError;
345
+ logger.error("%s\n%o", finalError.message, finalError.data);
346
+ } else if (err.name === "SyntaxError" && err.type === "entity.parse.failed") {
347
+ logger.error("%s", err);
348
+ finalError = new BadRequestError("Invalid json format");
349
+ } else if (err instanceof ApiError) {
350
+ logger.error("%s", err);
351
+ finalError = err;
352
+ } else {
353
+ logger.error("Error: %s", err);
354
+ if (err.cause) {
355
+ logger.error("Cause: %s", err.cause);
356
+ }
357
+ if (alertHandler) {
358
+ alertHandler(err, createReqSnapshot(req));
359
+ }
360
+ finalError = new InternalError();
361
+ if (!isProd()) {
362
+ finalError.data = { name: err.name, message: err.message, stack: err.stack, cause: err.cause };
363
+ }
364
+ }
365
+ res.status(finalError.httpStatusCode).json(new ApiErrorResponse(finalError.message, finalError.code, finalError.data));
366
+ };
367
+ }
368
+ };
369
+ };
370
+
371
+ // src/middleware/notFoundMiddleware.ts
372
+ var notFoundMiddleware = (_req, res, _next) => {
373
+ res.status(404).send("\xAF\\_(\u30C4)_/\xAF").end();
374
+ };
375
+
376
+ // src/logger.ts
377
+ import winston from "winston";
378
+ import DailyRotateFile from "winston-daily-rotate-file";
379
+ var loggerRegistry = {};
380
+ var defaultFormat = winston.format.combine(
381
+ winston.format.timestamp({ format: "DD/MM/YYYY HH:mm:ss" }),
382
+ winston.format.splat(),
383
+ // String interpolation splat for %d %s-style messages.
384
+ winston.format.errors({ stack: true }),
385
+ winston.format.printf(({ level, message, timestamp, stack }) => `${timestamp}[${level.toUpperCase()}]: ${stack || message}`)
386
+ );
387
+ var consoleTransport = new winston.transports.Console({
388
+ handleExceptions: true
389
+ });
390
+ var createFileLogger = (filename) => {
391
+ const logger = winston.createLogger({
392
+ format: defaultFormat,
393
+ transports: [
394
+ new DailyRotateFile({
395
+ level: "debug",
396
+ filename: `./logs/${filename}-%DATE%.log`,
397
+ handleExceptions: true,
398
+ // exitOnError: false, // do not exit on handled exceptions
399
+ datePattern: "YYYY-MM-DD",
400
+ zippedArchive: true,
401
+ auditFile: "./logs/audit.json",
402
+ maxSize: "20m",
403
+ maxFiles: isProd() ? "30d" : "1d"
404
+ // format: defaultFormat,
405
+ // the nested 'format' field causes issues with logging errors;
406
+ // use the 'format' field on logger, instead of transport;
407
+ })
408
+ ]
409
+ });
410
+ if (isDev()) {
411
+ logger.add(consoleTransport);
412
+ }
413
+ return logger;
414
+ };
415
+ var createLogger = winston.createLogger;
416
+ var getDefaultFileLogger = (name = "app") => {
417
+ if (!loggerRegistry[name]) {
418
+ loggerRegistry[name] = createFileLogger(name.toString());
419
+ }
420
+ return loggerRegistry[name];
421
+ };
422
+ var getDefaultConsoleLogger = (name = "console") => {
423
+ if (!loggerRegistry[name]) {
424
+ loggerRegistry[name] = winston.createLogger({
425
+ format: defaultFormat,
426
+ transports: [
427
+ consoleTransport
428
+ ]
429
+ });
430
+ }
431
+ return loggerRegistry[name];
432
+ };
433
+
434
+ // src/response/ApiResponse.ts
435
+ var ApiResponse = class {
436
+ status = "ok";
437
+ result;
438
+ constructor(result) {
439
+ this.result = result;
440
+ }
441
+ };
442
+
443
+ // src/index.ts
444
+ function bootstrap(container) {
445
+ const swaggerDoc = {
446
+ openapi: "3.1.0",
447
+ // TODO
448
+ info: {},
449
+ paths: {},
450
+ components: {}
451
+ };
452
+ return {
453
+ ...buildExpressive(container, swaggerDoc),
454
+ ...buildSwaggerBuilder(swaggerDoc),
455
+ ...buildErrorHandlerMiddleware(container),
456
+ notFoundMiddleware,
457
+ silently: (fn, reqSnapshot) => {
458
+ fn().catch((e) => {
459
+ if (container.alertHandler && e instanceof Error) {
460
+ container.alertHandler(e, reqSnapshot);
461
+ } else {
462
+ container.logger.error(e);
463
+ }
464
+ });
465
+ }
466
+ };
467
+ }
468
+ export {
469
+ ApiError,
470
+ ApiErrorResponse,
471
+ ApiResponse,
472
+ BadRequestError,
473
+ DuplicateError,
474
+ FileTooBigError,
475
+ ForbiddenError,
476
+ InternalError,
477
+ InvalidCredentialsError,
478
+ InvalidFileTypeError,
479
+ NotFoundError,
480
+ SWG,
481
+ SchemaValidationError,
482
+ TokenExpiredError,
483
+ TooManyRequestsError,
484
+ UserUnauthorizedError,
485
+ bootstrap,
486
+ createLogger,
487
+ createReqSnapshot,
488
+ getDefaultConsoleLogger,
489
+ getDefaultFileLogger,
490
+ getEnvVar,
491
+ getTmpDir,
492
+ getTmpPath,
493
+ isDev,
494
+ isProd,
495
+ parseDefaultPagination,
496
+ parseIdOrFail,
497
+ parsePositiveInteger,
498
+ slugify
499
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@extk/expressive",
3
+ "version": "0.1.0",
4
+ "type": "commonjs",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.mjs",
15
+ "require": "./dist/index.js"
16
+ }
17
+ },
18
+ "engines": {
19
+ "node": ">=22.0.0 <23.0.0"
20
+ },
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "test": "node --import=tsx --test 'test/**/*.test.ts'",
24
+ "lint": "eslint ./"
25
+ },
26
+ "devDependencies": {
27
+ "@extk/eslint-config": "^0.2.0",
28
+ "@types/chance": "^1.1.7",
29
+ "@types/express": "^5.0.1",
30
+ "@types/morgan": "^1.9.9",
31
+ "@types/node": "^20.9.3",
32
+ "@types/swagger-ui-express": "^4.1.8",
33
+ "chance": "^1.1.13",
34
+ "eslint": "^9.36.0",
35
+ "tsup": "^8.5.0",
36
+ "tsx": "^4.20.3",
37
+ "typescript": "^5.9.2"
38
+ },
39
+ "peerDependencies": {
40
+ "express": "^5.1.0"
41
+ },
42
+ "dependencies": {
43
+ "dotenv": "^17.1.0",
44
+ "helmet": "^8.0.0",
45
+ "morgan": "^1.10.0",
46
+ "swagger-ui-express": "^5.0.1",
47
+ "winston": "^3.11.0",
48
+ "winston-daily-rotate-file": "^5.0.0"
49
+ },
50
+ "author": "",
51
+ "license": "ISC",
52
+ "description": ""
53
+ }