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