@takaro/http 0.0.0-next.0da151e

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 (59) hide show
  1. package/README.md +7 -0
  2. package/dist/app.d.ts +21 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +86 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/controllers/meta.d.ts +18 -0
  7. package/dist/controllers/meta.d.ts.map +1 -0
  8. package/dist/controllers/meta.js +284 -0
  9. package/dist/controllers/meta.js.map +1 -0
  10. package/dist/main.d.ts +8 -0
  11. package/dist/main.d.ts.map +1 -0
  12. package/dist/main.js +8 -0
  13. package/dist/main.js.map +1 -0
  14. package/dist/middleware/basicAuth.d.ts +3 -0
  15. package/dist/middleware/basicAuth.d.ts.map +1 -0
  16. package/dist/middleware/basicAuth.js +27 -0
  17. package/dist/middleware/basicAuth.js.map +1 -0
  18. package/dist/middleware/errorHandler.d.ts +3 -0
  19. package/dist/middleware/errorHandler.d.ts.map +1 -0
  20. package/dist/middleware/errorHandler.js +73 -0
  21. package/dist/middleware/errorHandler.js.map +1 -0
  22. package/dist/middleware/logger.d.ts +9 -0
  23. package/dist/middleware/logger.d.ts.map +1 -0
  24. package/dist/middleware/logger.js +51 -0
  25. package/dist/middleware/logger.js.map +1 -0
  26. package/dist/middleware/metrics.d.ts +3 -0
  27. package/dist/middleware/metrics.d.ts.map +1 -0
  28. package/dist/middleware/metrics.js +35 -0
  29. package/dist/middleware/metrics.js.map +1 -0
  30. package/dist/middleware/paginationMiddleware.d.ts +3 -0
  31. package/dist/middleware/paginationMiddleware.d.ts.map +1 -0
  32. package/dist/middleware/paginationMiddleware.js +26 -0
  33. package/dist/middleware/paginationMiddleware.js.map +1 -0
  34. package/dist/middleware/rateLimit.d.ts +9 -0
  35. package/dist/middleware/rateLimit.d.ts.map +1 -0
  36. package/dist/middleware/rateLimit.js +60 -0
  37. package/dist/middleware/rateLimit.js.map +1 -0
  38. package/dist/util/apiResponse.d.ts +37 -0
  39. package/dist/util/apiResponse.d.ts.map +1 -0
  40. package/dist/util/apiResponse.js +99 -0
  41. package/dist/util/apiResponse.js.map +1 -0
  42. package/package.json +15 -0
  43. package/src/app.ts +105 -0
  44. package/src/controllers/__tests__/meta.integration.test.ts +23 -0
  45. package/src/controllers/defaultMetadatastorage.d.ts +5 -0
  46. package/src/controllers/meta.ts +268 -0
  47. package/src/main.ts +8 -0
  48. package/src/middleware/__tests__/paginationMiddleware.unit.test.ts +49 -0
  49. package/src/middleware/__tests__/rateLimit.integration.test.ts +130 -0
  50. package/src/middleware/basicAuth.ts +34 -0
  51. package/src/middleware/errorHandler.ts +95 -0
  52. package/src/middleware/logger.ts +62 -0
  53. package/src/middleware/metrics.ts +42 -0
  54. package/src/middleware/paginationMiddleware.ts +29 -0
  55. package/src/middleware/rateLimit.ts +73 -0
  56. package/src/util/apiResponse.ts +112 -0
  57. package/tsconfig.build.json +9 -0
  58. package/tsconfig.json +8 -0
  59. package/typedoc.json +3 -0
@@ -0,0 +1,268 @@
1
+ import { Controller, Get, getMetadataArgsStorage, Res } from 'routing-controllers';
2
+ import { Response } from 'express';
3
+ import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
4
+ import { routingControllersToSpec, ResponseSchema } from 'routing-controllers-openapi';
5
+ import { IsBoolean } from 'class-validator';
6
+ import { getMetrics, health } from '@takaro/util';
7
+ import { OpenAPIObject } from 'openapi3-ts';
8
+ import { PERMISSIONS } from '@takaro/auth';
9
+ import { EventMapping } from '@takaro/modules';
10
+ import { randomUUID } from 'crypto';
11
+ import dedent from 'dedent';
12
+
13
+ let spec: OpenAPIObject | undefined;
14
+
15
+ export class HealthOutputDTO {
16
+ @IsBoolean()
17
+ healthy!: boolean;
18
+ }
19
+
20
+ function addSearchExamples(original: OpenAPIObject): OpenAPIObject {
21
+ // Copy the spec so we don't mutate the original
22
+ const spec = JSON.parse(JSON.stringify(original));
23
+
24
+ // Add some examples and info to all the POST /search endpoints
25
+ Object.keys(spec.paths).forEach((pathKey) => {
26
+ const pathItem = spec?.paths[pathKey];
27
+ Object.keys(pathItem).forEach((method) => {
28
+ const operation = pathItem[method];
29
+ if (method === 'post' && pathKey.endsWith('/search')) {
30
+ const standardExamples = {
31
+ list: {
32
+ summary: 'List all',
33
+ value: {},
34
+ },
35
+ advanced: {
36
+ summary: 'Advanced search',
37
+ description: dedent`All /search endpoints allow you to combine different filters, search terms, ranges, and extend options.
38
+ Filters are exact matches, search terms are partial matches, ranges are greater than or less than comparisons,
39
+ and extend allows you to include related entities in the response.
40
+
41
+ Ranges allow you to make queries like "all records created in the last 7 days" or "all records with an age greater than 18".
42
+
43
+ In search and filter sections, you pass an array of values for each property
44
+ These values are OR'ed together. So we'll get 2 records back in this case, if the IDs exist.
45
+
46
+ Eg: \`{"filters": {"id": ["${randomUUID()}", "${randomUUID()}"]}}\`
47
+
48
+ Different filters will be AND'ed together.
49
+ This will return all records where the name is John and the age is 19.
50
+
51
+ Eg: \`{"filters": {"name": "John", "age": 19}}\`
52
+
53
+ The extend parameter allows including related data:
54
+ Eg: \`{"extend": ["roles", "gameServers"]}\`
55
+ `,
56
+ value: {
57
+ filters: {
58
+ id: ['ea85ddf4-2885-482f-adc6-548fbe3fd8af'],
59
+ },
60
+ greaterThan: { createdAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() },
61
+ },
62
+ },
63
+ };
64
+
65
+ if (!operation.requestBody) {
66
+ operation.requestBody = {
67
+ content: {
68
+ 'application/json': {
69
+ examples: standardExamples,
70
+ },
71
+ },
72
+ };
73
+ }
74
+
75
+ operation.requestBody.content['application/json'].examples = {
76
+ ...standardExamples,
77
+ ...operation.requestBody.content['application/json'].examples,
78
+ };
79
+ }
80
+ });
81
+ });
82
+
83
+ return spec;
84
+ }
85
+
86
+ @Controller()
87
+ export class Meta {
88
+ @Get('/healthz')
89
+ @ResponseSchema(HealthOutputDTO)
90
+ async getHealth() {
91
+ return { healthy: true };
92
+ }
93
+
94
+ @Get('/readyz')
95
+ @ResponseSchema(HealthOutputDTO)
96
+ async getReadiness() {
97
+ const healthy = await health.check();
98
+ return { healthy };
99
+ }
100
+
101
+ @Get('/openapi.json')
102
+ async getOpenApi() {
103
+ if (spec) return spec;
104
+
105
+ const { getMetadataStorage } = await import('class-validator');
106
+ const classTransformerStorage = await import(
107
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
108
+ // @ts-ignore were doing an import of internal code and ts doesnt like that
109
+ // But this does work, trust me bro...
110
+ 'class-transformer/cjs/storage.js'
111
+ );
112
+
113
+ const metadataArgsStorage = getMetadataArgsStorage();
114
+ const metadataStorage = getMetadataStorage();
115
+ const schemas = validationMetadatasToSchemas({
116
+ refPointerPrefix: '#/components/schemas/',
117
+ classTransformerMetadataStorage: classTransformerStorage.defaultMetadataStorage,
118
+ classValidatorMetadataStorage: metadataStorage,
119
+ forbidNonWhitelisted: true,
120
+ });
121
+
122
+ spec = routingControllersToSpec(
123
+ metadataArgsStorage,
124
+ {},
125
+ {
126
+ info: {
127
+ title: `Takaro ${process.env.PACKAGE || 'API'}`,
128
+ version: `${process.env.TAKARO_VERSION} - ${process.env.TAKARO_COMMIT} `,
129
+ contact: {
130
+ name: 'Takaro Team',
131
+ email: 'support@takaro.io',
132
+ url: 'https://takaro.io',
133
+ },
134
+ },
135
+ components: {
136
+ schemas,
137
+ securitySchemes: {
138
+ adminAuth: {
139
+ description: 'Used for system administration, like creating or deleting domains',
140
+ type: 'apiKey',
141
+ in: 'header',
142
+ name: 'x-takaro-admin-token',
143
+ },
144
+ domainAuth: {
145
+ description: 'Used for anything inside a domain. Players, GameServers, etc.',
146
+ type: 'apiKey',
147
+ in: 'cookie',
148
+ name: 'takaro-token',
149
+ },
150
+ },
151
+ },
152
+ },
153
+ );
154
+
155
+ // Add required permissions to operation descriptions
156
+
157
+ const requiredPermsRegex = /authMiddleware\((.+)\)/;
158
+
159
+ metadataArgsStorage.uses.forEach((use) => {
160
+ const requiredPerms =
161
+ use.middleware.name
162
+ .match(requiredPermsRegex)?.[1]
163
+ .split(',')
164
+ .map((p) => `\`${p}\``)
165
+ .join(', ') || [];
166
+
167
+ const operationId = `${use.target.name}.${use.method}`;
168
+
169
+ if (!requiredPerms.length) return;
170
+
171
+ // Find the corresponding path and method in spec
172
+ Object.keys(spec?.paths ?? []).forEach((pathKey) => {
173
+ const pathItem = spec?.paths[pathKey];
174
+ Object.keys(pathItem).forEach((method) => {
175
+ const operation = pathItem[method];
176
+ if (operation.operationId === operationId) {
177
+ // Update the description with required permissions
178
+ operation.description = (operation.description || '') + `\n\n Required permissions: ${requiredPerms}`;
179
+ }
180
+ });
181
+ });
182
+ });
183
+
184
+ // Add the operationId to the description, this helps users find the corresponding function call in the API client.
185
+ Object.keys(spec.paths).forEach((pathKey) => {
186
+ const pathItem = spec?.paths[pathKey];
187
+ Object.keys(pathItem).forEach((method) => {
188
+ const operation = pathItem[method];
189
+ // Api client exposes it as roleControllerSearch
190
+ // Current value is RoleController.search so lets adjust
191
+ // Capitalize the part after . and remove the .
192
+ const split = operation.operationId.split('.');
193
+ const cleanOperationId = split[0] + split[1].charAt(0).toUpperCase() + split[1].slice(1);
194
+ operation.description = (operation.description || '') + `<br> OperationId: \`${cleanOperationId}\``;
195
+ });
196
+ });
197
+
198
+ if (spec.components?.schemas) {
199
+ spec.components.schemas.PERMISSIONS = {
200
+ enum: Object.values(PERMISSIONS),
201
+ };
202
+ }
203
+
204
+ // Force event meta to be the correct types
205
+ // TODO: figure out how to do this 'properly' with class-validator
206
+ const allEvents = Object.values(EventMapping).map((e) => e.name);
207
+
208
+ const eventOutputMetaSchema = spec.components?.schemas?.EventOutputDTO;
209
+ if (eventOutputMetaSchema && 'properties' in eventOutputMetaSchema && eventOutputMetaSchema.properties) {
210
+ eventOutputMetaSchema.properties.meta = {
211
+ oneOf: [...allEvents.map((e) => ({ $ref: `#/components/schemas/${e}` }))],
212
+ };
213
+ }
214
+
215
+ spec = addSearchExamples(spec);
216
+ return spec;
217
+ }
218
+
219
+ @Get('/')
220
+ getRoot(@Res() res: Response) {
221
+ return res.redirect('/api.html');
222
+ }
223
+
224
+ @Get('/api.html')
225
+ getOpenApiHtml() {
226
+ return `<!DOCTYPE html>
227
+ <html>
228
+ <head>
229
+ <meta charset="utf-8" />
230
+ <script
231
+ type="module"
232
+ src="https://cdn.jsdelivr.net/npm/rapidoc@9.3.8"
233
+ ></script>
234
+ </head>
235
+ <body>
236
+ <rapi-doc
237
+ spec-url="/openapi.json"
238
+ render-style="read"
239
+ fill-request-fields-with-example="false"
240
+ persist-auth="true"
241
+
242
+ sort-tags="true"
243
+ sort-endpoints-by="method"
244
+
245
+ show-method-in-nav-bar="as-colored-block"
246
+ show-header="false"
247
+ allow-authentication="true"
248
+ allow-server-selection="false"
249
+
250
+ schema-style="table"
251
+ schema-expand-level="1"
252
+ default-schema-tab="schema"
253
+
254
+ primary-color="#664de5"
255
+ bg-color="#151515"
256
+ text-color="#c2c2c2"
257
+ header-color="#353535"
258
+ />
259
+ </body>
260
+ </html>
261
+ `;
262
+ }
263
+
264
+ @Get('/metrics')
265
+ getMetrics() {
266
+ return getMetrics();
267
+ }
268
+ }
package/src/main.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { HTTP } from './app.js';
2
+
3
+ export * from './middleware/metrics.js';
4
+ export { createRateLimitMiddleware } from './middleware/rateLimit.js';
5
+ export { getAdminBasicAuth } from './middleware/basicAuth.js';
6
+ export { paginationMiddleware } from './middleware/paginationMiddleware.js';
7
+ export { apiResponse, APIOutput } from './util/apiResponse.js';
8
+ export { ErrorHandler } from './middleware/errorHandler.js';
@@ -0,0 +1,49 @@
1
+ import { sandbox, expect } from '@takaro/test';
2
+ import { NextFunction, Request, Response } from 'express';
3
+ import { errors } from '@takaro/util';
4
+ import { paginationMiddleware } from '../paginationMiddleware.js';
5
+ import { describe, it } from 'node:test';
6
+
7
+ async function runPagination(page?: number, limit?: number) {
8
+ const req = { query: { page, limit } } as unknown as Request;
9
+ const res = { locals: {} } as Response;
10
+ const next = sandbox.stub<errors.ValidationError[]>();
11
+ await paginationMiddleware(req, res, next as unknown as NextFunction);
12
+ return { req, res, next };
13
+ }
14
+
15
+ describe('pagination middleware', () => {
16
+ it('Handles setting defaults', async () => {
17
+ const { res, next } = await runPagination();
18
+ expect(res.locals.page).to.equal(0);
19
+ expect(res.locals.limit).to.equal(100);
20
+ expect(next).to.have.been.calledOnce;
21
+ });
22
+ it('Works when pagination is passed', async () => {
23
+ const { res, next } = await runPagination(2, 20);
24
+ expect(res.locals.page).to.equal(2);
25
+ expect(res.locals.limit).to.equal(20);
26
+ expect(next).to.have.been.calledOnce;
27
+ });
28
+ it('Handles negative limit', async () => {
29
+ const { next } = await runPagination(1, -1);
30
+ expect(next).to.have.been.calledOnce;
31
+ const callArg = next.getCalls()[0].args[0];
32
+ expect(callArg).to.be.an.instanceOf(Error);
33
+ expect(callArg.message).to.equal('Invalid pagination: limit must be greater than or equal to 1');
34
+ });
35
+ it('Handles negative page', async () => {
36
+ const { next } = await runPagination(-1, 5);
37
+ expect(next).to.have.been.calledOnce;
38
+ const callArg = next.getCalls()[0].args[0];
39
+ expect(callArg).to.be.an.instanceOf(Error);
40
+ expect(callArg.message).to.equal('Invalid pagination: page must be greater than or equal to 0');
41
+ });
42
+ it('Handles limit too high', async () => {
43
+ const { next } = await runPagination(1, 1001);
44
+ expect(next).to.have.been.calledOnce;
45
+ const callArg = next.getCalls()[0].args[0];
46
+ expect(callArg).to.be.an.instanceOf(Error);
47
+ expect(callArg.message).to.equal('Invalid pagination: limit must be less than or equal to 1000');
48
+ });
49
+ });
@@ -0,0 +1,130 @@
1
+ import { Response, NextFunction, Request } from 'express';
2
+ import { Redis } from '@takaro/db';
3
+ import { Controller, UseBefore, Get } from 'routing-controllers';
4
+ import { HTTP } from '../../main.js';
5
+ import { createRateLimitMiddleware } from '../rateLimit.js';
6
+ import { ctx } from '@takaro/util';
7
+ import supertest from 'supertest';
8
+ import { describe, beforeEach, afterEach, after, it } from 'node:test';
9
+
10
+ describe('rateLimit middleware', () => {
11
+ let http: HTTP;
12
+ beforeEach(async () => {
13
+ @Controller()
14
+ class TestController {
15
+ @Get('/low-limit')
16
+ @UseBefore(
17
+ await createRateLimitMiddleware({
18
+ max: 5,
19
+ windowSeconds: 5,
20
+ useInMemory: false,
21
+ }),
22
+ )
23
+ getLow() {
24
+ return 'Hello World';
25
+ }
26
+
27
+ @Get('/high-limit')
28
+ @UseBefore(
29
+ await createRateLimitMiddleware({
30
+ max: 15,
31
+ windowSeconds: 5,
32
+ useInMemory: false,
33
+ }),
34
+ )
35
+ getHigh() {
36
+ return 'Hello World';
37
+ }
38
+
39
+ @Get('/authenticated')
40
+ @UseBefore(
41
+ (req: Request, _res: Response, next: NextFunction) => {
42
+ ctx.addData({ user: req.query.user as string });
43
+ next();
44
+ },
45
+ await createRateLimitMiddleware({ max: 5, windowSeconds: 5, useInMemory: false }),
46
+ )
47
+ getAuthenticated() {
48
+ return 'Hello World';
49
+ }
50
+ }
51
+
52
+ http = new HTTP(
53
+ {
54
+ controllers: [TestController],
55
+ },
56
+ { port: undefined },
57
+ );
58
+
59
+ await http.start();
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await http.stop();
64
+
65
+ const redis = await Redis.getClient('http:rateLimit');
66
+ await redis.flushAll();
67
+ });
68
+
69
+ after(async () => {
70
+ await Redis.destroy();
71
+ });
72
+
73
+ it('should limit requests', async () => {
74
+ const agent = supertest(http.expressInstance);
75
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
76
+ // @ts-ignore
77
+ agent.get('/low-limit').set('X-Forwarded-For', '127.0.0.2');
78
+
79
+ for (let i = 0; i < 4; i++) {
80
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
81
+ // @ts-ignore
82
+ await agent.get('/low-limit').expect(200);
83
+ }
84
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
85
+ // @ts-ignore
86
+ await agent.get('/low-limit').expect(429);
87
+ });
88
+
89
+ it('should apply distinct limits per user', async () => {
90
+ const agent = supertest(http.expressInstance);
91
+
92
+ for (let i = 0; i < 4; i++) {
93
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
94
+ // @ts-ignore
95
+ await agent.get('/authenticated?user=1').expect(200);
96
+ }
97
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
98
+ // @ts-ignore
99
+ await agent.get('/authenticated?user=1').expect(429);
100
+
101
+ for (let i = 0; i < 4; i++) {
102
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
103
+ // @ts-ignore
104
+ await agent.get('/authenticated?user=2').expect(200);
105
+ }
106
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
107
+ // @ts-ignore
108
+ await agent.get('/authenticated?user=2').expect(429);
109
+ });
110
+
111
+ it('Should accurately report metadata info via HTTP headers', async () => {
112
+ const agent = supertest(http.expressInstance);
113
+
114
+ for (let i = 1; i < 5; i++) {
115
+ await agent
116
+ .get('/low-limit')
117
+ .expect(200)
118
+ .expect('x-ratelimit-remaining', (5 - i).toString())
119
+ .expect('x-ratelimit-limit', '5')
120
+ .expect('x-ratelimit-reset', /\d+/);
121
+ }
122
+
123
+ agent
124
+ .get('/low-limit')
125
+ .expect(429)
126
+ .expect('x-ratelimit-remaining', '0')
127
+ .expect('x-ratelimit-limit', '5')
128
+ .expect('x-ratelimit-reset', /\d+/);
129
+ });
130
+ });
@@ -0,0 +1,34 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ export function getAdminBasicAuth(secret: string) {
4
+ return function adminBasicAuth(req: Request, res: Response, next: NextFunction) {
5
+ const auth = req.headers.authorization;
6
+
7
+ // If no auth header is present, prompt for credentials
8
+ if (!auth) {
9
+ res.set('WWW-Authenticate', 'Basic realm="Admin Access"');
10
+ res.status(401).send('Authentication required');
11
+ return;
12
+ }
13
+
14
+ const [type, credentials] = auth.split(' ');
15
+
16
+ // Verify auth type is Basic
17
+ if (type !== 'Basic') {
18
+ res.set('WWW-Authenticate', 'Basic realm="Admin Access"');
19
+ res.status(401).send('Invalid authentication type');
20
+ return;
21
+ }
22
+
23
+ // Decode and verify credentials
24
+ const [username, password] = Buffer.from(credentials, 'base64').toString().split(':');
25
+
26
+ if (username !== 'admin' || password !== secret) {
27
+ res.set('WWW-Authenticate', 'Basic realm="Admin Access"');
28
+ res.status(401).send('Invalid credentials');
29
+ return;
30
+ }
31
+
32
+ next();
33
+ };
34
+ }
@@ -0,0 +1,95 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { HttpError } from 'routing-controllers';
3
+ import { logger, errors } from '@takaro/util';
4
+ import { apiResponse } from '../util/apiResponse.js';
5
+ import { ValidationError } from 'class-validator';
6
+ import { MulterError } from 'multer';
7
+
8
+ const log = logger('errorHandler');
9
+
10
+ export async function ErrorHandler(
11
+ originalError: Error,
12
+ req: Request,
13
+ res: Response,
14
+
15
+ _next: NextFunction,
16
+ ) {
17
+ let status = 500;
18
+ let parsedError = new errors.InternalServerError();
19
+
20
+ if (originalError.name === 'BadRequestError') {
21
+ if (Object.prototype.hasOwnProperty.call(originalError, 'errors')) {
22
+ // @ts-expect-error Error typing is weird in ts... but we validate during runtime so should be OK
23
+ const validationErrors = originalError['errors'] as ValidationError[];
24
+ parsedError = new errors.ValidationError('Validation error', validationErrors);
25
+ log.warn('⚠️ Validation errror', {
26
+ details: validationErrors.map((e) => JSON.stringify(e.constraints, null, 2)),
27
+ });
28
+ }
29
+ }
30
+
31
+ if (originalError instanceof MulterError) {
32
+ status = 400;
33
+ parsedError = new errors.BadRequestError('Invalid file');
34
+ if (originalError.code === 'LIMIT_FIELD_VALUE') {
35
+ status = 400;
36
+ parsedError = new errors.BadRequestError('File too large');
37
+ }
38
+ }
39
+
40
+ if (originalError instanceof HttpError) {
41
+ status = originalError.httpCode;
42
+ }
43
+
44
+ if (originalError.name === 'UniqueViolationError') {
45
+ status = 409;
46
+ parsedError = new errors.ConflictError('Unique constraint violation');
47
+ }
48
+
49
+ if (originalError.name === 'NotNullViolationError') {
50
+ status = 400;
51
+ parsedError = new errors.BadRequestError('Missing required field');
52
+ }
53
+
54
+ if (originalError.name === 'CheckViolationError') {
55
+ status = 400;
56
+ parsedError = new errors.BadRequestError('Invalid data provided');
57
+ }
58
+
59
+ if (originalError.name === 'DataError' && originalError.message.includes('invalid input syntax for type uuid')) {
60
+ status = 400;
61
+ parsedError = new errors.BadRequestError('Invalid UUID. Passed a string instead of a UUID');
62
+ }
63
+
64
+ if ('constraint' in originalError && originalError['constraint'] === 'currency_positive') {
65
+ status = 400;
66
+ parsedError = new errors.BadRequestError('Not enough currency');
67
+ }
68
+
69
+ if (originalError instanceof errors.TakaroError) {
70
+ status = originalError.http;
71
+ parsedError = originalError;
72
+ }
73
+
74
+ // If error is a JSON.parse error
75
+ if (originalError instanceof SyntaxError) {
76
+ if (
77
+ originalError.message.includes('Unexpected token') ||
78
+ originalError.message.includes('Unexpected end of JSON input') ||
79
+ originalError.message.includes('Expected property name or')
80
+ ) {
81
+ status = 400;
82
+ parsedError = new errors.BadRequestError('Invalid JSON');
83
+ }
84
+ }
85
+
86
+ log.error(originalError);
87
+ if (status >= 500) {
88
+ log.error(`🔴 FAIL ${req.method} ${req.originalUrl}`, parsedError);
89
+ } else {
90
+ log.warn(`⚠️ FAIL ${req.method} ${req.originalUrl}`, parsedError);
91
+ }
92
+
93
+ res.status(status).json(apiResponse({}, { error: parsedError, req, res }));
94
+ res.end();
95
+ }
@@ -0,0 +1,62 @@
1
+ import { NextFunction, Request, Response } from 'express';
2
+ import { logger, ctx } from '@takaro/util';
3
+ import { context, trace } from '@opentelemetry/api';
4
+
5
+ const SUPPRESS_BODY_KEYWORDS = ['password', 'newPassword'];
6
+ const HIDDEN_ROUTES = ['/metrics', '/health', '/healthz', '/ready', '/readyz', '/queues/api/queues'];
7
+ const log = logger('http');
8
+
9
+ /**
10
+ * This middleware is called very early in the request lifecycle, so it's
11
+ * we leverage this fact to inject the context tracking at this stage
12
+ */
13
+ export const LoggingMiddleware = ctx.wrap('HTTP', loggingMiddleware) as typeof loggingMiddleware;
14
+
15
+ async function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
16
+ if (HIDDEN_ROUTES.some((route) => req.originalUrl.startsWith(route))) {
17
+ return next();
18
+ }
19
+
20
+ const requestStartMs = Date.now();
21
+
22
+ const hideData = SUPPRESS_BODY_KEYWORDS.some((keyword) => (JSON.stringify(req.body) || '').includes(keyword));
23
+
24
+ log.debug(`⬇️ ${req.method} ${req.originalUrl}`, {
25
+ ip: req.ip,
26
+ method: req.method,
27
+ path: req.originalUrl,
28
+ body: hideData ? { suppressed_output: true } : req.body,
29
+ });
30
+
31
+ const span = trace.getSpan(context.active());
32
+
33
+ if (span) {
34
+ // get the trace ID from the span and set it in the headers
35
+ const traceId = span.spanContext().traceId;
36
+ res.header('X-Trace-Id', traceId);
37
+ }
38
+
39
+ // Log on API Call Finish to add responseTime
40
+ res.once('finish', () => {
41
+ const responseTime = Date.now() - requestStartMs;
42
+
43
+ log.info(`⬆️ ${req.method} ${req.originalUrl}`, {
44
+ responseTime,
45
+ requestMethod: req.method,
46
+ requestUrl: req.originalUrl,
47
+ requestSize: req.headers['content-length'],
48
+ status: res.statusCode,
49
+ responseSize: res.getHeader('Content-Length'),
50
+ userAgent: req.get('User-Agent'),
51
+ remoteIp: req.ip,
52
+ serverIp: '127.0.0.1',
53
+ referer: req.get('Referer'),
54
+ cacheLookup: false,
55
+ cacheHit: false,
56
+ cacheValidatedWithOriginServer: false,
57
+ protocol: req.protocol,
58
+ });
59
+ });
60
+
61
+ next();
62
+ }
@@ -0,0 +1,42 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { Counter, Histogram } from 'prom-client';
3
+
4
+ const counter = new Counter({
5
+ name: 'http_requests_total',
6
+ help: 'Total number of HTTP requests made',
7
+ labelNames: ['path', 'method', 'status'],
8
+ });
9
+
10
+ const histogram = new Histogram({
11
+ name: 'http_request_duration_seconds',
12
+ help: 'Duration of HTTP requests in seconds',
13
+ labelNames: ['path', 'method', 'status'],
14
+ buckets: [0.1, 0.5, 1, 2, 5, 10],
15
+ });
16
+
17
+ export async function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
18
+ const rawPath = req.path;
19
+ const method = req.method;
20
+ // Filter out anything that looks like a UUID from path
21
+ const path = rawPath.replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g, '/:id');
22
+
23
+ const start = Date.now();
24
+
25
+ try {
26
+ next();
27
+ } finally {
28
+ counter.inc({
29
+ path,
30
+ method,
31
+ status: res.statusCode.toString(),
32
+ });
33
+ histogram.observe(
34
+ {
35
+ path,
36
+ method,
37
+ status: res.statusCode.toString(),
38
+ },
39
+ (Date.now() - start) / 1000,
40
+ );
41
+ }
42
+ }