@openapi-typescript-infra/service 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.
Files changed (121) hide show
  1. package/.eslintignore +7 -0
  2. package/.eslintrc.js +14 -0
  3. package/.github/workflows/codeql-analysis.yml +74 -0
  4. package/.github/workflows/nodejs.yml +23 -0
  5. package/.github/workflows/npmpublish.yml +35 -0
  6. package/.husky/pre-commit +6 -0
  7. package/.prettierrc.js +14 -0
  8. package/@types/config.d.ts +56 -0
  9. package/CHANGELOG.md +5 -0
  10. package/LICENSE +21 -0
  11. package/README.md +28 -0
  12. package/SECURITY.md +12 -0
  13. package/__tests__/config.test.ts +31 -0
  14. package/__tests__/fake-serv/api/fake-serv.yaml +48 -0
  15. package/__tests__/fake-serv/config/config.json +15 -0
  16. package/__tests__/fake-serv/src/handlers/hello.ts +10 -0
  17. package/__tests__/fake-serv/src/index.ts +29 -0
  18. package/__tests__/fake-serv/src/routes/error.ts +13 -0
  19. package/__tests__/fake-serv/src/routes/index.ts +22 -0
  20. package/__tests__/fake-serv/src/routes/other/world.ts +7 -0
  21. package/__tests__/fake-serv.test.ts +74 -0
  22. package/build/bin/start-service.d.ts +2 -0
  23. package/build/bin/start-service.js +31 -0
  24. package/build/bin/start-service.js.map +1 -0
  25. package/build/bootstrap.d.ts +16 -0
  26. package/build/bootstrap.js +90 -0
  27. package/build/bootstrap.js.map +1 -0
  28. package/build/config/index.d.ts +10 -0
  29. package/build/config/index.js +98 -0
  30. package/build/config/index.js.map +1 -0
  31. package/build/config/schema.d.ts +48 -0
  32. package/build/config/schema.js +3 -0
  33. package/build/config/schema.js.map +1 -0
  34. package/build/config/shortstops.d.ts +31 -0
  35. package/build/config/shortstops.js +109 -0
  36. package/build/config/shortstops.js.map +1 -0
  37. package/build/config/types.d.ts +3 -0
  38. package/build/config/types.js +3 -0
  39. package/build/config/types.js.map +1 -0
  40. package/build/development/port-finder.d.ts +1 -0
  41. package/build/development/port-finder.js +41 -0
  42. package/build/development/port-finder.js.map +1 -0
  43. package/build/development/repl.d.ts +2 -0
  44. package/build/development/repl.js +29 -0
  45. package/build/development/repl.js.map +1 -0
  46. package/build/env.d.ts +2 -0
  47. package/build/env.js +19 -0
  48. package/build/env.js.map +1 -0
  49. package/build/error.d.ts +25 -0
  50. package/build/error.js +28 -0
  51. package/build/error.js.map +1 -0
  52. package/build/express-app/app.d.ts +6 -0
  53. package/build/express-app/app.js +327 -0
  54. package/build/express-app/app.js.map +1 -0
  55. package/build/express-app/index.d.ts +2 -0
  56. package/build/express-app/index.js +19 -0
  57. package/build/express-app/index.js.map +1 -0
  58. package/build/express-app/internal-server.d.ts +3 -0
  59. package/build/express-app/internal-server.js +34 -0
  60. package/build/express-app/internal-server.js.map +1 -0
  61. package/build/express-app/route-loader.d.ts +2 -0
  62. package/build/express-app/route-loader.js +46 -0
  63. package/build/express-app/route-loader.js.map +1 -0
  64. package/build/express-app/types.d.ts +14 -0
  65. package/build/express-app/types.js +3 -0
  66. package/build/express-app/types.js.map +1 -0
  67. package/build/index.d.ts +8 -0
  68. package/build/index.js +25 -0
  69. package/build/index.js.map +1 -0
  70. package/build/openapi.d.ts +5 -0
  71. package/build/openapi.js +78 -0
  72. package/build/openapi.js.map +1 -0
  73. package/build/service-calls/index.d.ts +16 -0
  74. package/build/service-calls/index.js +85 -0
  75. package/build/service-calls/index.js.map +1 -0
  76. package/build/telemetry/fetchInstrumentation.d.ts +50 -0
  77. package/build/telemetry/fetchInstrumentation.js +144 -0
  78. package/build/telemetry/fetchInstrumentation.js.map +1 -0
  79. package/build/telemetry/index.d.ts +6 -0
  80. package/build/telemetry/index.js +80 -0
  81. package/build/telemetry/index.js.map +1 -0
  82. package/build/telemetry/instrumentations.d.ts +29 -0
  83. package/build/telemetry/instrumentations.js +47 -0
  84. package/build/telemetry/instrumentations.js.map +1 -0
  85. package/build/telemetry/requestLogger.d.ts +6 -0
  86. package/build/telemetry/requestLogger.js +144 -0
  87. package/build/telemetry/requestLogger.js.map +1 -0
  88. package/build/tsconfig.build.tsbuildinfo +1 -0
  89. package/build/types.d.ts +77 -0
  90. package/build/types.js +3 -0
  91. package/build/types.js.map +1 -0
  92. package/config/config.json +31 -0
  93. package/config/development.json +11 -0
  94. package/config/test.json +5 -0
  95. package/jest.config.js +14 -0
  96. package/package.json +111 -0
  97. package/src/bin/start-service.ts +28 -0
  98. package/src/bootstrap.ts +112 -0
  99. package/src/config/index.ts +115 -0
  100. package/src/config/schema.ts +66 -0
  101. package/src/config/shortstops.ts +118 -0
  102. package/src/config/types.ts +5 -0
  103. package/src/development/port-finder.ts +40 -0
  104. package/src/development/repl.ts +24 -0
  105. package/src/env.ts +14 -0
  106. package/src/error.ts +44 -0
  107. package/src/express-app/app.ts +399 -0
  108. package/src/express-app/index.ts +2 -0
  109. package/src/express-app/internal-server.ts +31 -0
  110. package/src/express-app/route-loader.ts +48 -0
  111. package/src/express-app/types.ts +31 -0
  112. package/src/index.ts +8 -0
  113. package/src/openapi.ts +67 -0
  114. package/src/service-calls/index.ts +129 -0
  115. package/src/telemetry/fetchInstrumentation.ts +209 -0
  116. package/src/telemetry/index.ts +69 -0
  117. package/src/telemetry/instrumentations.ts +54 -0
  118. package/src/telemetry/requestLogger.ts +193 -0
  119. package/src/types.ts +139 -0
  120. package/tsconfig.build.json +10 -0
  121. package/tsconfig.json +36 -0
@@ -0,0 +1,399 @@
1
+ import assert from 'assert';
2
+ import http from 'http';
3
+ import path from 'path';
4
+
5
+ import express from 'express';
6
+ import { pino } from 'pino';
7
+ import cookieParser from 'cookie-parser';
8
+ import { MeterProvider } from '@opentelemetry/sdk-metrics';
9
+ import { metrics } from '@opentelemetry/api-metrics';
10
+ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
11
+ import { createTerminus } from '@godaddy/terminus';
12
+ import type { RequestHandler, Response } from 'express';
13
+
14
+ import { loadConfiguration } from '../config/index';
15
+ import { findPort } from '../development/port-finder';
16
+ import { openApi } from '../openapi';
17
+ import {
18
+ errorHandlerMiddleware,
19
+ loggerMiddleware,
20
+ notFoundMiddleware,
21
+ } from '../telemetry/requestLogger';
22
+ import type {
23
+ RequestLocals,
24
+ RequestWithApp,
25
+ ServiceExpress,
26
+ ServiceLocals,
27
+ ServiceOptions,
28
+ ServiceStartOptions,
29
+ } from '../types';
30
+ import { ConfigurationItemEnabled, ConfigurationSchema } from '../config/schema';
31
+ import { isDev } from '../env';
32
+
33
+ import { loadRoutes } from './route-loader';
34
+ import { startInternalApp } from './internal-server';
35
+
36
+ const METRICS_KEY = Symbol('PrometheusMetricsInfo');
37
+
38
+ interface InternalMetricsInfo {
39
+ meterProvider: MeterProvider;
40
+ exporter?: PrometheusExporter;
41
+ }
42
+
43
+ interface AppWithMetrics { [METRICS_KEY]?: InternalMetricsInfo; }
44
+
45
+ async function enableMetrics<SLocals extends ServiceLocals = ServiceLocals>(
46
+ app: ServiceExpress<SLocals>,
47
+ name: string,
48
+ ) {
49
+ const meterProvider = new MeterProvider();
50
+ metrics.setGlobalMeterProvider(meterProvider);
51
+ app.locals.meter = meterProvider.getMeter(name);
52
+
53
+ const metricsConfig = app.locals.config.get<ConfigurationItemEnabled>('server:metrics');
54
+ const value: InternalMetricsInfo = { meterProvider };
55
+ if (metricsConfig?.enabled) {
56
+ const finalConfig = {
57
+ ...metricsConfig,
58
+ preventServerStart: true,
59
+ };
60
+ // There is what I would consider a bug in OpenTelemetry metrics
61
+ // wherein adding metrics BEFORE the metricReader is added results
62
+ // in those metrics screaming into the void. So, we need to add
63
+ // this up front and then just tie it to the internal express
64
+ // app if and when "listen" is called.
65
+ const exporter = new PrometheusExporter(finalConfig);
66
+ meterProvider.addMetricReader(exporter);
67
+ value.exporter = exporter;
68
+ } else {
69
+ app.locals.logger.info('No metrics will be exported');
70
+ }
71
+ // Squirrel it away for later
72
+ Object.defineProperty(app.locals, METRICS_KEY, {
73
+ value,
74
+ enumerable: false,
75
+ configurable: true,
76
+ });
77
+ }
78
+
79
+ async function endMetrics<SLocals extends ServiceLocals = ServiceLocals>(
80
+ app: ServiceExpress<SLocals>,
81
+ ) {
82
+ const { internalApp, logger } = app.locals;
83
+ const meterProvider = internalApp?.locals.meterProvider as MeterProvider | undefined;
84
+ await meterProvider?.shutdown();
85
+ logger.info('Metrics shutdown');
86
+ }
87
+
88
+ export async function startApp<
89
+ SLocals extends ServiceLocals = ServiceLocals,
90
+ RLocals extends RequestLocals = RequestLocals,
91
+ >(startOptions: ServiceStartOptions<SLocals, RLocals>): Promise<ServiceExpress<SLocals>> {
92
+ const {
93
+ service, rootDirectory, codepath = 'build', name,
94
+ } = startOptions;
95
+ const shouldPrettyPrint = isDev() && !process.env.NO_PRETTY_LOGS;
96
+ const destination = pino.destination({
97
+ dest: process.env.LOG_TO_FILE || process.stdout.fd,
98
+ minLength: process.env.LOG_BUFFER ? Number(process.env.LOG_BUFFER) : undefined,
99
+ });
100
+ const logger = shouldPrettyPrint
101
+ ? pino({
102
+ transport: {
103
+ destination,
104
+ target: 'pino-pretty',
105
+ options: {
106
+ colorize: true,
107
+ },
108
+ },
109
+ })
110
+ : pino(
111
+ {
112
+ formatters: {
113
+ level(label) {
114
+ return { level: label };
115
+ },
116
+ },
117
+ },
118
+ destination,
119
+ );
120
+
121
+ const serviceImpl = service();
122
+ assert(serviceImpl?.start, 'Service function did not return a conforming object');
123
+
124
+ const baseOptions: ServiceOptions = {
125
+ configurationDirectories: [path.resolve(rootDirectory, './config')],
126
+ };
127
+ const options = serviceImpl.configure?.(startOptions, baseOptions) || baseOptions;
128
+
129
+ const config = await loadConfiguration({
130
+ name,
131
+ configurationDirectories: options.configurationDirectories,
132
+ rootDirectory,
133
+ });
134
+
135
+ const logging = config.get('logging') as ConfigurationSchema['logging'];
136
+ logger.level = logging?.level || 'info';
137
+
138
+ // Concentrate the Typescript ugliness...
139
+ const app = express() as unknown as ServiceExpress<SLocals>;
140
+ const routing = config.get('routing') as ConfigurationSchema['routing'];
141
+
142
+ app.disable('x-powered-by');
143
+ if (routing?.etag !== true) {
144
+ app.disable('etag');
145
+ }
146
+
147
+ Object.assign(app.locals, { services: {} }, startOptions.locals, {
148
+ service: serviceImpl,
149
+ logger,
150
+ config,
151
+ name,
152
+ });
153
+
154
+ try {
155
+ await enableMetrics(app, name);
156
+ } catch (error) {
157
+ logger.error(error, 'Could not enable metrics.');
158
+ throw error;
159
+ }
160
+
161
+ if (config.get('trustProxy')) {
162
+ app.set('trust proxy', config.get('trustProxy'));
163
+ }
164
+
165
+ app.use(loggerMiddleware(app, logging?.logRequestBody, logging?.logResponseBody));
166
+
167
+ // Allow the service to add locals, etc. We put this before the body parsers
168
+ // so that the req can decide whether to save the raw request body or not.
169
+ const attachServiceLocals: RequestHandler = (req, res, next) => {
170
+ res.locals.logger = logger;
171
+ let maybePromise: Promise<void> | void;
172
+ try {
173
+ maybePromise = serviceImpl.onRequest?.(
174
+ req as RequestWithApp<SLocals>,
175
+ res as Response<unknown, RLocals>,
176
+ );
177
+ } catch (error) {
178
+ next(error);
179
+ }
180
+ if (maybePromise) {
181
+ maybePromise.catch(next).then(next);
182
+ } else {
183
+ next();
184
+ }
185
+ };
186
+ app.use(attachServiceLocals);
187
+
188
+ if (routing?.cookieParser) {
189
+ app.use(cookieParser());
190
+ }
191
+
192
+ if (routing?.bodyParsers?.json) {
193
+ app.use(
194
+ express.json({
195
+ verify(req, res, buf) {
196
+ const locals = (res as Response).locals as RequestLocals;
197
+ if (locals?.rawBody === true) {
198
+ locals.rawBody = buf;
199
+ }
200
+ },
201
+ }),
202
+ );
203
+ }
204
+ if (routing?.bodyParsers?.form) {
205
+ app.use(express.urlencoded());
206
+ }
207
+
208
+ if (serviceImpl.authorize) {
209
+ const authorize: RequestHandler = (req, res, next) => {
210
+ let maybePromise: Promise<boolean> | boolean | undefined;
211
+ try {
212
+ maybePromise = serviceImpl.authorize?.(
213
+ req as RequestWithApp<SLocals>,
214
+ res as Response<unknown, RLocals>,
215
+ );
216
+ } catch (error) {
217
+ next(error);
218
+ }
219
+ if (maybePromise && typeof maybePromise !== 'boolean') {
220
+ maybePromise
221
+ .then((val) => {
222
+ if (val === false) {
223
+ return;
224
+ }
225
+ next();
226
+ })
227
+ .catch(next);
228
+ } else if (maybePromise !== false) {
229
+ next();
230
+ }
231
+ };
232
+ app.use(authorize);
233
+ }
234
+
235
+ if (routing?.static?.enabled) {
236
+ const localdir = path.resolve(rootDirectory, routing?.static?.path || 'public');
237
+ if (routing.static.mountPath) {
238
+ app.use(routing.static.mountPath, express.static(localdir));
239
+ } else {
240
+ app.use(express.static(localdir));
241
+ }
242
+ }
243
+
244
+ if (routing?.freezeQuery) {
245
+ app.use((req, res, next) => {
246
+ // Express 5 re-parses the query string every time. This causes problems with
247
+ // various libraries, namely the express OpenAPI parser. So we "freeze it" in place
248
+ // here, which runs right before the routing validation logic does. Note that this
249
+ // means the app middleware will see the unfrozen one, which is intentional. If the
250
+ // app wants to modify or freeze the query itself, this shouldn't get in the way.
251
+ const { query } = req;
252
+ if (query) {
253
+ Object.defineProperty(req, 'query', {
254
+ configurable: true,
255
+ enumerable: true,
256
+ value: query,
257
+ });
258
+ }
259
+ next();
260
+ });
261
+ }
262
+
263
+ if (routing?.routes) {
264
+ await loadRoutes(
265
+ app,
266
+ path.resolve(rootDirectory, codepath, config.get<string>('routing:routes') || 'routes'),
267
+ codepath === 'build' ? '**/*.js' : '**/*.ts',
268
+ );
269
+ }
270
+ if (routing?.openapi) {
271
+ app.use(openApi(app, rootDirectory, codepath, options.openApiOptions));
272
+ }
273
+
274
+ // Putting this here allows more flexible middleware insertion
275
+ await serviceImpl.start(app);
276
+
277
+ const { notFound, errors } = routing?.finalHandlers || {};
278
+ if (notFound) {
279
+ app.use(notFoundMiddleware());
280
+ }
281
+ if (errors?.enabled) {
282
+ app.use(errorHandlerMiddleware(app, errors?.unnest, errors?.render));
283
+ }
284
+
285
+ return app;
286
+ }
287
+
288
+ export async function shutdownApp(app: ServiceExpress) {
289
+ const { logger } = app.locals;
290
+ try {
291
+ await app.locals.service.stop?.(app);
292
+ await endMetrics(app);
293
+ logger.info('App shutdown complete');
294
+ } catch (error) {
295
+ logger.warn(error, 'Shutdown failed');
296
+ }
297
+ (logger as pino.Logger).flush?.();
298
+ }
299
+
300
+ export async function listen<SLocals extends ServiceLocals = ServiceLocals>(
301
+ app: ServiceExpress<SLocals>,
302
+ shutdownHandler?: () => Promise<void>,
303
+ ) {
304
+ let port = app.locals.config.get('server:port');
305
+
306
+ if (port === 0) {
307
+ port = await findPort(8001);
308
+ }
309
+
310
+ const { service, logger } = app.locals;
311
+ const server = http.createServer(app);
312
+ let shutdownInProgress = false;
313
+ createTerminus(server, {
314
+ timeout: 15000,
315
+ useExit0: true,
316
+ // https://github.com/godaddy/terminus#how-to-set-terminus-up-with-kubernetes
317
+ beforeShutdown() {
318
+ if (shutdownInProgress) {
319
+ return Promise.resolve();
320
+ }
321
+ shutdownInProgress = true;
322
+ if (app.locals.internalApp) {
323
+ app.locals.internalApp.locals.server?.close();
324
+ }
325
+ logger.info('Graceful shutdown beginning');
326
+ return new Promise((accept) => {
327
+ setTimeout(accept, 10000);
328
+ });
329
+ },
330
+ onShutdown() {
331
+ return Promise.resolve()
332
+ .then(() => service.stop?.(app))
333
+ .then(() => endMetrics(app))
334
+ .then(shutdownHandler || Promise.resolve)
335
+ .then(() => logger.info('Graceful shutdown complete'))
336
+ .catch((error) => logger.error(error, 'Error terminating tracing'))
337
+ .then(() => (logger as pino.Logger).flush?.());
338
+ },
339
+ logger: (msg, e) => {
340
+ logger.error(e, msg);
341
+ },
342
+ });
343
+
344
+ server.on('close', () => {
345
+ if (!shutdownInProgress) {
346
+ shutdownInProgress = true;
347
+ app.locals.logger.info('Shutdown requested');
348
+ if (app.locals.internalApp) {
349
+ app.locals.internalApp.locals.server?.close();
350
+ }
351
+ shutdownApp(app);
352
+ }
353
+ });
354
+
355
+ const metricInfo = (app.locals as AppWithMetrics)[METRICS_KEY] as InternalMetricsInfo;
356
+ delete (app.locals as AppWithMetrics)[METRICS_KEY];
357
+
358
+ // TODO handle rejection/error?
359
+ const listenPromise = new Promise<void>((accept) => {
360
+ server.listen(port, () => {
361
+ const { locals } = app;
362
+ locals.logger.info({ port, service: locals.name }, 'express listening');
363
+
364
+ const serverConfig = locals.config.get('server') as ConfigurationSchema['server'];
365
+ // Ok now start the internal port if we have one.
366
+ if (serverConfig?.internalPort) {
367
+ startInternalApp(app, serverConfig.internalPort)
368
+ .then((internalApp) => {
369
+ locals.internalApp = internalApp;
370
+ internalApp.locals.meterProvider = metricInfo.meterProvider;
371
+ locals.logger.info(
372
+ { port: serverConfig.internalPort },
373
+ 'Internal metadata server started',
374
+ );
375
+ })
376
+ .then(() => {
377
+ if (metricInfo.exporter) {
378
+ locals.internalApp.get(
379
+ '/metrics',
380
+ metricInfo.exporter.getMetricsRequestHandler.bind(metricInfo.exporter),
381
+ );
382
+ locals.logger.info('Metrics exporter started');
383
+ } else {
384
+ locals.logger.info('No metrics will be exported');
385
+ }
386
+ accept();
387
+ })
388
+ .catch((error) => {
389
+ locals.logger.warn(error, 'Failed to start internal metadata app');
390
+ });
391
+ } else {
392
+ accept();
393
+ }
394
+ });
395
+ });
396
+
397
+ await listenPromise;
398
+ return server;
399
+ }
@@ -0,0 +1,2 @@
1
+ export * from './app';
2
+ export * from './types';
@@ -0,0 +1,31 @@
1
+ import express from 'express';
2
+ import type { Application } from 'express-serve-static-core';
3
+
4
+ import { InternalLocals, ServiceExpress } from '../types';
5
+
6
+ export async function startInternalApp(mainApp: ServiceExpress, port: number) {
7
+ const app = express() as unknown as Application<InternalLocals>;
8
+ app.locals.mainApp = mainApp;
9
+
10
+ app.get('/health', async (req, res) => {
11
+ if (mainApp.locals.service?.healthy) {
12
+ try {
13
+ const ok = await mainApp.locals.service.healthy(mainApp);
14
+ res.sendStatus(ok ? 204 : 500);
15
+ } catch (error) {
16
+ mainApp.locals.logger.error(error, 'Health check failed');
17
+ }
18
+ } else {
19
+ res.sendStatus(204);
20
+ }
21
+ });
22
+
23
+ const listenPromise = new Promise<void>((accept) => {
24
+ app.locals.server = app.listen(port, () => {
25
+ accept();
26
+ });
27
+ });
28
+
29
+ await listenPromise;
30
+ return app;
31
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'path';
2
+
3
+ import { glob } from 'glob';
4
+ import express from 'express';
5
+
6
+ import type { ServiceExpress } from '../types';
7
+
8
+ export async function loadRoutes(app: ServiceExpress, routingDir: string, pattern: string) {
9
+ const files: string[] = await new Promise((accept, reject) => {
10
+ glob(
11
+ pattern,
12
+ {
13
+ nodir: true,
14
+ strict: true,
15
+ cwd: routingDir,
16
+ },
17
+ (error, matches) => (error ? reject(error) : accept(matches)),
18
+ );
19
+ });
20
+
21
+ await Promise.all(
22
+ files.map(async (filename) => {
23
+ const routeBase = path.dirname(filename);
24
+ const modulePath = path.resolve(routingDir, filename);
25
+ // Need to use require for loading .ts files
26
+ // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
27
+ const module = require(modulePath);
28
+ const mounter = module.default || module.route;
29
+ if (typeof mounter === 'function') {
30
+ const childRouter = express.Router();
31
+ mounter(childRouter, app);
32
+ const pathParts = [''];
33
+ if (routeBase !== '.') {
34
+ pathParts.push(routeBase);
35
+ }
36
+ const fn = path.parse(path.basename(filename)).name;
37
+ if (fn.toLowerCase() !== 'index') {
38
+ pathParts.push(fn);
39
+ }
40
+ const finalPath = pathParts.join('/') || '/';
41
+ app.locals.logger.debug({ path: finalPath, source: filename }, 'Registering route');
42
+ app.use(finalPath, childRouter);
43
+ } else {
44
+ app.locals.logger.warn({ filename }, 'Route file had no default export');
45
+ }
46
+ }),
47
+ );
48
+ }
@@ -0,0 +1,31 @@
1
+ import type { NextFunction, Response } from 'express';
2
+
3
+ import type { RequestLocals, RequestWithApp, ServiceLocals } from '../types';
4
+
5
+ export type ServiceHandler<
6
+ SLocals extends ServiceLocals = ServiceLocals,
7
+ RLocals extends RequestLocals = RequestLocals,
8
+ ResBody = unknown,
9
+ RetType = unknown,
10
+ > = (
11
+ req: RequestWithApp<SLocals>,
12
+ res: Response<ResBody, RLocals>,
13
+ next: NextFunction,
14
+ ) => RetType | Promise<RetType>;
15
+
16
+ // Make it easier to declare route files. This is not an exhaustive list
17
+ // of supported router methods, but it has the most common ones.
18
+ export interface ServiceRouter<
19
+ SLocals extends ServiceLocals = ServiceLocals,
20
+ RLocals extends RequestLocals = RequestLocals,
21
+ > {
22
+ all(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
23
+ get(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
24
+ post(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
25
+ put(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
26
+ delete(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
27
+ patch(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
28
+ options(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
29
+ head(path: string, ...handlers: ServiceHandler<SLocals, RLocals>[]): void;
30
+ use(...handlers: ServiceHandler<SLocals, RLocals>[]): void;
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './telemetry';
2
+ export * from './service-calls';
3
+ export * from './express-app';
4
+ export * from './types';
5
+ export * from './env';
6
+ export * from './config';
7
+ export * from './error';
8
+ export * from './bootstrap';
package/src/openapi.ts ADDED
@@ -0,0 +1,67 @@
1
+ import path from 'path';
2
+
3
+ import _ from 'lodash';
4
+ import * as OpenApiValidator from 'express-openapi-validator';
5
+ import type { Handler } from 'express';
6
+
7
+ import type { ServiceExpress } from './types';
8
+
9
+ const notImplementedHandler: Handler = (req, res) => {
10
+ res.status(501).json({
11
+ code: 'NotImplemented',
12
+ domain: 'http',
13
+ message: 'This method is not yet implemented',
14
+ });
15
+ };
16
+
17
+ type OAPIOpts = Parameters<typeof OpenApiValidator.middleware>[0];
18
+
19
+ export function openApi(
20
+ app: ServiceExpress,
21
+ rootDirectory: string,
22
+ codepath: string,
23
+ openApiOptions?: Partial<OAPIOpts>,
24
+ ) {
25
+ const apiSpec = path.resolve(rootDirectory, `./api/${app.locals.name}.yaml`);
26
+ app.locals.logger.debug({ apiSpec, codepath }, 'Serving OpenAPI');
27
+
28
+ const defaultOptions: OAPIOpts = {
29
+ apiSpec,
30
+ ignoreUndocumented: true,
31
+ validateRequests: {
32
+ allowUnknownQueryParameters: true,
33
+ coerceTypes: 'array',
34
+ },
35
+ operationHandlers: {
36
+ basePath: path.resolve(rootDirectory, `${codepath}/handlers`),
37
+ resolver(basePath: string, route: Parameters<typeof OpenApiValidator.resolvers.defaultResolver>[1]) {
38
+ const pathKey = route.openApiRoute.substring(route.basePath.length);
39
+ const modulePath = path.join(basePath, pathKey);
40
+
41
+ try {
42
+ // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
43
+ const module = require(modulePath);
44
+ const method = Object.keys(module).find((m) => m.toUpperCase() === route.method);
45
+ if (!method) {
46
+ throw new Error(
47
+ `Could not find a [${route.method}] function in ${modulePath} when trying to route [${route.method} ${route.expressRoute}].`,
48
+ );
49
+ }
50
+ return module[method];
51
+ } catch (error) {
52
+ app.locals.logger.error(
53
+ {
54
+ error: (error as Error).message,
55
+ pathKey,
56
+ modulePath: path.relative(rootDirectory, modulePath),
57
+ },
58
+ 'Failed to load API method handler',
59
+ );
60
+ return notImplementedHandler;
61
+ }
62
+ },
63
+ },
64
+ };
65
+
66
+ return OpenApiValidator.middleware(_.defaultsDeep(defaultOptions, openApiOptions || {}));
67
+ }