@takaro/http 0.0.1

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 (50) hide show
  1. package/README.md +7 -0
  2. package/dist/app.d.ts +21 -0
  3. package/dist/app.js +87 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/controllers/meta.d.ts +15 -0
  6. package/dist/controllers/meta.js +186 -0
  7. package/dist/controllers/meta.js.map +1 -0
  8. package/dist/main.d.ts +6 -0
  9. package/dist/main.js +7 -0
  10. package/dist/main.js.map +1 -0
  11. package/dist/middleware/adminAuth.d.ts +2 -0
  12. package/dist/middleware/adminAuth.js +27 -0
  13. package/dist/middleware/adminAuth.js.map +1 -0
  14. package/dist/middleware/errorHandler.d.ts +2 -0
  15. package/dist/middleware/errorHandler.js +51 -0
  16. package/dist/middleware/errorHandler.js.map +1 -0
  17. package/dist/middleware/logger.d.ts +5 -0
  18. package/dist/middleware/logger.js +51 -0
  19. package/dist/middleware/logger.js.map +1 -0
  20. package/dist/middleware/metrics.d.ts +2 -0
  21. package/dist/middleware/metrics.js +38 -0
  22. package/dist/middleware/metrics.js.map +1 -0
  23. package/dist/middleware/paginationMiddleware.d.ts +2 -0
  24. package/dist/middleware/paginationMiddleware.js +26 -0
  25. package/dist/middleware/paginationMiddleware.js.map +1 -0
  26. package/dist/middleware/rateLimit.d.ts +8 -0
  27. package/dist/middleware/rateLimit.js +65 -0
  28. package/dist/middleware/rateLimit.js.map +1 -0
  29. package/dist/util/apiResponse.d.ts +36 -0
  30. package/dist/util/apiResponse.js +100 -0
  31. package/dist/util/apiResponse.js.map +1 -0
  32. package/package.json +37 -0
  33. package/src/app.ts +104 -0
  34. package/src/controllers/__tests__/meta.integration.test.ts +29 -0
  35. package/src/controllers/defaultMetadatastorage.d.ts +5 -0
  36. package/src/controllers/meta.ts +170 -0
  37. package/src/main.ts +7 -0
  38. package/src/middleware/__tests__/adminAuth.unit.test.ts +65 -0
  39. package/src/middleware/__tests__/paginationMiddleware.unit.test.ts +48 -0
  40. package/src/middleware/__tests__/rateLimit.integration.test.ts +125 -0
  41. package/src/middleware/adminAuth.ts +33 -0
  42. package/src/middleware/errorHandler.ts +67 -0
  43. package/src/middleware/logger.ts +62 -0
  44. package/src/middleware/metrics.ts +44 -0
  45. package/src/middleware/paginationMiddleware.ts +29 -0
  46. package/src/middleware/rateLimit.ts +78 -0
  47. package/src/util/apiResponse.ts +106 -0
  48. package/tsconfig.build.json +9 -0
  49. package/tsconfig.json +8 -0
  50. package/typedoc.json +3 -0
@@ -0,0 +1,65 @@
1
+ import { Redis } from '@takaro/db';
2
+ import { logger, errors, ctx } from '@takaro/util';
3
+ import { RateLimiterRes, RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible';
4
+ export async function createRateLimitMiddleware(opts) {
5
+ const log = logger('http:rateLimit');
6
+ const redis = await Redis.getClient('http:rateLimit', {
7
+ // Legacy mode is required for rate-limiter-flexible which isn't updated
8
+ // to use the new v4 redis API.
9
+ // See: https://github.com/animir/node-rate-limiter-flexible/wiki/Redis#usage
10
+ legacyMode: true,
11
+ });
12
+ // We create a randomHash to use in Redis keys
13
+ // This makes sure that each endpoint can get different rate limits without too much hassle
14
+ const randomHash = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
15
+ let rateLimiter;
16
+ if (opts.useInMemory) {
17
+ rateLimiter = new RateLimiterMemory({
18
+ points: opts.max,
19
+ duration: opts.windowSeconds,
20
+ keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
21
+ });
22
+ }
23
+ else {
24
+ rateLimiter = new RateLimiterRedis({
25
+ points: opts.max,
26
+ duration: opts.windowSeconds,
27
+ storeClient: redis,
28
+ keyPrefix: `http:rateLimit:${opts.keyPrefix ? opts.keyPrefix : randomHash}`,
29
+ });
30
+ }
31
+ return async (req, res, next) => {
32
+ const ctxData = ctx.data;
33
+ let limitedKey = null;
34
+ if (ctxData.user) {
35
+ limitedKey = ctxData.user;
36
+ }
37
+ else {
38
+ // TODO: should handle case ip is undefined
39
+ limitedKey = req.ip;
40
+ }
41
+ let rateLimiterRes = null;
42
+ try {
43
+ rateLimiterRes = await rateLimiter.consume(limitedKey);
44
+ }
45
+ catch (err) {
46
+ if (err instanceof RateLimiterRes) {
47
+ rateLimiterRes = err;
48
+ log.warn(`rate limited, try again in ${err.msBeforeNext}ms`);
49
+ }
50
+ else {
51
+ throw err;
52
+ }
53
+ }
54
+ if (rateLimiterRes) {
55
+ res.set('X-RateLimit-Limit', opts.max.toString());
56
+ res.set('X-RateLimit-Remaining', rateLimiterRes.remainingPoints.toString());
57
+ res.set('X-RateLimit-Reset', rateLimiterRes.msBeforeNext.toString());
58
+ if (rateLimiterRes.remainingPoints === 0) {
59
+ next(new errors.TooManyRequestsError());
60
+ }
61
+ }
62
+ return next();
63
+ };
64
+ }
65
+ //# sourceMappingURL=rateLimit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/middleware/rateLimit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ import { TakaroDTO } from '@takaro/util';
2
+ import { ValidationError as ClassValidatorError } from 'class-validator';
3
+ import { Request, Response } from 'express';
4
+ declare class ErrorOutput {
5
+ code?: string;
6
+ message?: string;
7
+ details?: string | ClassValidatorError[];
8
+ }
9
+ declare class MetadataOutput {
10
+ serverTime: string;
11
+ error?: ErrorOutput;
12
+ /**
13
+ * The page number of the response
14
+ */
15
+ page?: number;
16
+ /**
17
+ * The number of items returned in the response (aka page size)
18
+ */
19
+ limit?: number;
20
+ /**
21
+ * The total number of items in the collection
22
+ */
23
+ total?: number;
24
+ }
25
+ export declare class APIOutput<T> extends TakaroDTO<APIOutput<T>> {
26
+ meta: MetadataOutput;
27
+ data: T;
28
+ }
29
+ interface IApiResponseOptions {
30
+ error?: Error;
31
+ meta?: Record<string, string | number>;
32
+ req: Request;
33
+ res: Response;
34
+ }
35
+ export declare function apiResponse(data?: unknown, opts?: IApiResponseOptions): APIOutput<unknown>;
36
+ export {};
@@ -0,0 +1,100 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { errors, isTakaroDTO, TakaroDTO } from '@takaro/util';
11
+ import { Type } from 'class-transformer';
12
+ import { IsISO8601, IsNumber, IsOptional, ValidateNested } from 'class-validator';
13
+ import { IsString } from 'class-validator';
14
+ class ErrorOutput {
15
+ }
16
+ __decorate([
17
+ IsString(),
18
+ __metadata("design:type", String)
19
+ ], ErrorOutput.prototype, "code", void 0);
20
+ __decorate([
21
+ IsString(),
22
+ __metadata("design:type", String)
23
+ ], ErrorOutput.prototype, "message", void 0);
24
+ __decorate([
25
+ IsString(),
26
+ __metadata("design:type", Object)
27
+ ], ErrorOutput.prototype, "details", void 0);
28
+ class MetadataOutput {
29
+ }
30
+ __decorate([
31
+ IsString(),
32
+ IsISO8601(),
33
+ __metadata("design:type", String)
34
+ ], MetadataOutput.prototype, "serverTime", void 0);
35
+ __decorate([
36
+ Type(() => ErrorOutput),
37
+ ValidateNested(),
38
+ __metadata("design:type", ErrorOutput)
39
+ ], MetadataOutput.prototype, "error", void 0);
40
+ __decorate([
41
+ IsNumber(),
42
+ IsOptional(),
43
+ __metadata("design:type", Number)
44
+ ], MetadataOutput.prototype, "page", void 0);
45
+ __decorate([
46
+ IsNumber(),
47
+ IsOptional(),
48
+ __metadata("design:type", Number)
49
+ ], MetadataOutput.prototype, "limit", void 0);
50
+ __decorate([
51
+ IsNumber(),
52
+ IsOptional(),
53
+ __metadata("design:type", Number)
54
+ ], MetadataOutput.prototype, "total", void 0);
55
+ export class APIOutput extends TakaroDTO {
56
+ }
57
+ __decorate([
58
+ Type(() => MetadataOutput),
59
+ ValidateNested(),
60
+ __metadata("design:type", MetadataOutput)
61
+ ], APIOutput.prototype, "meta", void 0);
62
+ export function apiResponse(data = {}, opts) {
63
+ const returnVal = new APIOutput();
64
+ returnVal.meta = new MetadataOutput();
65
+ returnVal.data = {};
66
+ if (opts?.error) {
67
+ returnVal.meta.error = new ErrorOutput();
68
+ returnVal.meta.error.code = String(opts.error.name);
69
+ if ('details' in opts.error) {
70
+ if (opts.error instanceof errors.ValidationError) {
71
+ returnVal.meta.error.details = opts.error.details;
72
+ }
73
+ else {
74
+ returnVal.meta.error.details = opts.error.details;
75
+ }
76
+ }
77
+ returnVal.meta.error.message = String(opts.error.message);
78
+ }
79
+ if (opts?.meta) {
80
+ returnVal.meta.page = opts?.res.locals.page;
81
+ returnVal.meta.limit = opts?.res.locals.limit;
82
+ returnVal.meta.total = opts?.meta.total;
83
+ }
84
+ if (isTakaroDTO(data)) {
85
+ returnVal.data = data.toJSON();
86
+ }
87
+ else if (Array.isArray(data)) {
88
+ returnVal.data = data.map((item) => {
89
+ if (isTakaroDTO(item)) {
90
+ return item.toJSON();
91
+ }
92
+ return item;
93
+ });
94
+ }
95
+ else {
96
+ returnVal.data = data;
97
+ }
98
+ return returnVal;
99
+ }
100
+ //# sourceMappingURL=apiResponse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiResponse.js","sourceRoot":"","sources":["../../src/util/apiResponse.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@takaro/http",
3
+ "version": "0.0.1",
4
+ "description": "An opinionated http server",
5
+ "main": "dist/main.js",
6
+ "types": "dist/main.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "start:dev": "tsc --watch --preserveWatchOutput -p ./tsconfig.build.json",
10
+ "build": "tsc -p ./tsconfig.build.json",
11
+ "test": "npm run test:unit --if-present && npm run test:integration --if-present",
12
+ "test:unit": "mocha --config ../../.mocharc.js src/**/*.unit.test.ts --exit",
13
+ "test:integration": "mocha --config ../../.mocharc.js src/**/*.integration.test.ts --exit"
14
+ },
15
+ "keywords": [],
16
+ "author": "",
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "@takaro/config": "0.0.1",
20
+ "@takaro/util": "0.0.1",
21
+ "body-parser": "^1.20.0",
22
+ "class-validator-jsonschema": "^3.1.1",
23
+ "cookie-parser": "^1.4.6",
24
+ "cors": "^2.8.5",
25
+ "express": "^4.18.1",
26
+ "i": "^0.3.7",
27
+ "rate-limiter-flexible": "^2.4.1",
28
+ "zod": "^3.17.10"
29
+ },
30
+ "devDependencies": {
31
+ "@takaro/test": "0.0.1",
32
+ "@types/cookie-parser": "^1.4.3",
33
+ "@types/cors": "^2.8.12",
34
+ "@types/express": "^4.17.13",
35
+ "supertest": "6.3.4"
36
+ }
37
+ }
package/src/app.ts ADDED
@@ -0,0 +1,104 @@
1
+ import 'reflect-metadata';
2
+ import express, { Application } from 'express';
3
+ import { logger, errors, Sentry } from '@takaro/util';
4
+ import { getBullBoard } from '@takaro/queues';
5
+ import { Server, createServer } from 'http';
6
+ import { RoutingControllersOptions, useExpressServer } from 'routing-controllers';
7
+ import { Meta } from './controllers/meta.js';
8
+ import { LoggingMiddleware } from './middleware/logger.js';
9
+ import { ErrorHandler } from './middleware/errorHandler.js';
10
+ import bodyParser from 'body-parser';
11
+ import cors from 'cors';
12
+ import cookieParser from 'cookie-parser';
13
+ import { metricsMiddleware } from './main.js';
14
+ import { paginationMiddleware } from './middleware/paginationMiddleware.js';
15
+
16
+ interface IHTTPOptions {
17
+ port?: number;
18
+ allowedOrigins?: string[];
19
+ }
20
+
21
+ export class HTTP {
22
+ private app: Application;
23
+ private httpServer: Server;
24
+ private logger;
25
+
26
+ constructor(options: RoutingControllersOptions = {}, private httpOptions: IHTTPOptions = {}) {
27
+ this.logger = logger('http');
28
+ this.app = express();
29
+ this.httpServer = createServer(this.app);
30
+ this.app.use(Sentry.Handlers.requestHandler());
31
+ this.app.use(
32
+ bodyParser.json({
33
+ verify: (req, res, buf) => {
34
+ (req as any).rawBody = buf.toString();
35
+ },
36
+ })
37
+ );
38
+ this.app.use(LoggingMiddleware);
39
+ this.app.use(metricsMiddleware);
40
+ this.app.use(paginationMiddleware);
41
+ this.app.set('x-powered-by', false);
42
+ this.app.use(
43
+ cors({
44
+ credentials: true,
45
+ exposedHeaders: ['X-Trace-Id'],
46
+ origin: (origin: string | undefined, callback: CallableFunction) => {
47
+ if (!origin) return callback(null, true);
48
+ const allowedOrigins = this.httpOptions.allowedOrigins ?? [];
49
+ if (!origin || allowedOrigins.includes(origin)) {
50
+ callback(null, true);
51
+ } else {
52
+ this.logger.warn(`Origin ${origin} not allowed by CORS`);
53
+ callback(new errors.BadRequestError('Not allowed by CORS'));
54
+ }
55
+ },
56
+ })
57
+ );
58
+ this.app.use(cookieParser());
59
+
60
+ if (options.controllers) {
61
+ useExpressServer(this.app, {
62
+ ...options,
63
+ defaultErrorHandler: false,
64
+ validation: { whitelist: true, forbidNonWhitelisted: true },
65
+ // eslint-disable-next-line @typescript-eslint/ban-types
66
+ controllers: [Meta, ...(options.controllers as Function[])],
67
+ });
68
+ } else {
69
+ useExpressServer(this.app, {
70
+ ...options,
71
+ defaultErrorHandler: false,
72
+ controllers: [Meta],
73
+ validation: { whitelist: true, forbidNonWhitelisted: true },
74
+ });
75
+ }
76
+
77
+ this.app.use('/queues', getBullBoard());
78
+
79
+ this.app.use(Sentry.Handlers.errorHandler());
80
+
81
+ this.app.use(ErrorHandler);
82
+ }
83
+
84
+ get expressInstance() {
85
+ return this.app;
86
+ }
87
+
88
+ get server() {
89
+ return this.httpServer;
90
+ }
91
+
92
+ async start() {
93
+ this.httpServer = this.httpServer.listen(this.httpOptions.port, () => {
94
+ this.logger.info(`HTTP server listening on port ${this.httpOptions.port}`);
95
+ });
96
+ }
97
+
98
+ async stop() {
99
+ if (this.httpServer) {
100
+ this.httpServer.close();
101
+ this.logger.info('HTTP server stopped');
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,29 @@
1
+ import { HTTP } from '../../app.js';
2
+ import supertest from 'supertest';
3
+ import { expect } from '@takaro/test';
4
+
5
+ describe('app', () => {
6
+ let http: HTTP;
7
+ before(async () => {
8
+ http = new HTTP({}, { port: undefined });
9
+ await http.start();
10
+ });
11
+
12
+ after(async () => {
13
+ await http.stop();
14
+ });
15
+
16
+ it('Serves a health status', async () => {
17
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
18
+ // @ts-ignore
19
+ const response = await supertest(http.expressInstance).get('/healthz');
20
+ expect(response.status).to.be.equal(200);
21
+ });
22
+
23
+ it('Serves a open api spec', async () => {
24
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
25
+ // @ts-ignore
26
+ const response = await supertest(http.expressInstance).get('/openapi.json');
27
+ expect(response.status).to.be.equal(200);
28
+ });
29
+ });
@@ -0,0 +1,5 @@
1
+ declare module 'class-transformer/cjs/storage.js' {
2
+ import type { MetadataStorage } from 'class-transformer/types/MetadataStorage';
3
+
4
+ export const defaultMetadataStorage: MetadataStorage;
5
+ }
@@ -0,0 +1,170 @@
1
+ import { Controller, Get } from 'routing-controllers';
2
+ import { getMetadataArgsStorage } from 'routing-controllers';
3
+ import { routingControllersToSpec } from 'routing-controllers-openapi';
4
+ import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
5
+ import { ResponseSchema } from 'routing-controllers-openapi';
6
+ import { IsBoolean } from 'class-validator';
7
+ import { getMetrics, health } from '@takaro/util';
8
+ import { OpenAPIObject } from 'openapi3-ts';
9
+ import { PERMISSIONS } from '@takaro/auth';
10
+ import { EventMapping } from '@takaro/modules';
11
+
12
+ let spec: OpenAPIObject | undefined;
13
+
14
+ export class HealthOutputDTO {
15
+ @IsBoolean()
16
+ healthy!: boolean;
17
+ }
18
+ @Controller()
19
+ export class Meta {
20
+ @Get('/healthz')
21
+ @ResponseSchema(HealthOutputDTO)
22
+ async getHealth() {
23
+ return { healthy: true };
24
+ }
25
+
26
+ @Get('/readyz')
27
+ @ResponseSchema(HealthOutputDTO)
28
+ async getReadiness() {
29
+ const healthy = await health.check();
30
+ return { healthy };
31
+ }
32
+
33
+ @Get('/openapi.json')
34
+ async getOpenApi() {
35
+ if (spec) return spec;
36
+
37
+ const { getMetadataStorage } = await import('class-validator');
38
+ const classTransformerStorage = await import(
39
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
40
+ // @ts-ignore were doing an import of internal code and ts doesnt like that
41
+ // But this does work, trust me bro...
42
+ 'class-transformer/cjs/storage.js'
43
+ );
44
+
45
+ const storage = getMetadataArgsStorage();
46
+ const schemas = validationMetadatasToSchemas({
47
+ refPointerPrefix: '#/components/schemas/',
48
+ classTransformerMetadataStorage: classTransformerStorage.defaultMetadataStorage,
49
+ classValidatorMetadataStorage: getMetadataStorage(),
50
+ forbidNonWhitelisted: true,
51
+ });
52
+
53
+ spec = routingControllersToSpec(
54
+ storage,
55
+ {},
56
+ {
57
+ components: {
58
+ schemas,
59
+ securitySchemes: {
60
+ adminAuth: {
61
+ description: 'Used for system administration, like creating or deleting domains',
62
+ type: 'http',
63
+ scheme: 'bearer',
64
+ bearerFormat: 'JWT',
65
+ },
66
+ domainAuth: {
67
+ description: 'Used for anything inside a domain. Players, GameServers, etc.',
68
+ type: 'apiKey',
69
+ in: 'cookie',
70
+ name: 'takaro-token',
71
+ },
72
+ },
73
+ },
74
+ }
75
+ );
76
+
77
+ // Add required permissions to operation descriptions
78
+
79
+ const requiredPermsRegex = /authMiddleware\((.+)\)/;
80
+
81
+ storage.uses.forEach((use) => {
82
+ const requiredPerms =
83
+ use.middleware.name
84
+ .match(requiredPermsRegex)?.[1]
85
+ .split(',')
86
+ .map((p) => `\`${p}\``)
87
+ .join(', ') || [];
88
+
89
+ const operationId = `${use.target.name}.${use.method}`;
90
+
91
+ if (!requiredPerms.length) return;
92
+
93
+ // Find the corresponding path and method in spec
94
+ Object.keys(spec?.paths ?? []).forEach((pathKey) => {
95
+ const pathItem = spec?.paths[pathKey];
96
+ Object.keys(pathItem).forEach((method) => {
97
+ const operation = pathItem[method];
98
+ if (operation.operationId === operationId) {
99
+ // Update the description with required permissions
100
+ operation.description = (operation.description || '') + ` Required permissions: ${requiredPerms}`;
101
+ }
102
+ });
103
+ });
104
+ });
105
+
106
+ if (spec.components?.schemas) {
107
+ spec.components.schemas.PERMISSIONS = {
108
+ enum: Object.values(PERMISSIONS),
109
+ };
110
+ }
111
+
112
+ // Force event meta to be the correct types
113
+ // TODO: figure out how to do this 'properly' with class-validator
114
+ const allEvents = Object.values(EventMapping).map((e) => e.name);
115
+
116
+ const eventOutputMetaSchema = spec.components?.schemas?.EventOutputDTO;
117
+ if (eventOutputMetaSchema && 'properties' in eventOutputMetaSchema && eventOutputMetaSchema.properties) {
118
+ eventOutputMetaSchema.properties.meta = {
119
+ oneOf: [...allEvents.map((e) => ({ $ref: `#/components/schemas/${e}` }))],
120
+ };
121
+ }
122
+
123
+ return spec;
124
+ }
125
+
126
+ @Get('/api.html')
127
+ getOpenApiHtml() {
128
+ return `<!DOCTYPE html>
129
+ <html>
130
+ <head>
131
+ <meta charset="utf-8" />
132
+ <script
133
+ type="module"
134
+ src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
135
+ ></script>
136
+ </head>
137
+ <body>
138
+ <rapi-doc
139
+ spec-url="/openapi.json"
140
+ render-style="read"
141
+ fill-request-fields-with-example="false"
142
+ persist-auth="true"
143
+
144
+ sort-tags="true"
145
+ sort-endpoints-by="method"
146
+
147
+ show-method-in-nav-bar="as-colored-block"
148
+ show-header="false"
149
+ allow-authentication="false"
150
+ allow-server-selection="false"
151
+
152
+ schema-style="table"
153
+ schema-expand-level="1"
154
+ default-schema-tab="schema"
155
+
156
+ primary-color="#664de5"
157
+ bg-color="#151515"
158
+ text-color="#c2c2c2"
159
+ header-color="#353535"
160
+ />
161
+ </body>
162
+ </html>
163
+ `;
164
+ }
165
+
166
+ @Get('/metrics')
167
+ getMetrics() {
168
+ return getMetrics();
169
+ }
170
+ }
package/src/main.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { HTTP } from './app.js';
2
+
3
+ export * from './middleware/metrics.js';
4
+ export { createRateLimitMiddleware } from './middleware/rateLimit.js';
5
+ export { adminAuthMiddleware } from './middleware/adminAuth.js';
6
+ export { paginationMiddleware } from './middleware/paginationMiddleware.js';
7
+ export { apiResponse, APIOutput } from './util/apiResponse.js';
@@ -0,0 +1,65 @@
1
+ import { HTTP } from '../../app.js';
2
+ import supertest from 'supertest';
3
+ import { expect } from '@takaro/test';
4
+ import { ory } from '@takaro/auth';
5
+ import { AdminClient } from '@takaro/apiclient';
6
+ import { adminAuthMiddleware } from '../adminAuth.js';
7
+ import { ErrorHandler } from '../errorHandler.js';
8
+ import { Request, Response } from 'express';
9
+
10
+ describe('adminAuth', () => {
11
+ let http: HTTP;
12
+ before(async () => {
13
+ http = new HTTP({}, { port: undefined });
14
+ http.expressInstance.use(
15
+ '/test',
16
+ adminAuthMiddleware,
17
+ (_req: Request, res: Response) => {
18
+ res.json({ ok: true });
19
+ },
20
+ ErrorHandler
21
+ );
22
+ await http.start();
23
+ });
24
+
25
+ after(async () => {
26
+ await http.stop();
27
+ });
28
+
29
+ it('Rejects requests with no credentials', async () => {
30
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
31
+ // @ts-ignore
32
+ const response = await supertest(http.expressInstance).get('/test');
33
+ expect(response.status).to.be.equal(401);
34
+ });
35
+
36
+ it('Rejects requests with invalid credentials', async () => {
37
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
38
+ // @ts-ignore
39
+ const response = await supertest(http.expressInstance).get('/test').set('Authorization', 'Bearer foobar');
40
+ expect(response.status).to.be.equal(403);
41
+ });
42
+
43
+ it('Accepts requests with valid credentials', async () => {
44
+ const { clientId, clientSecret } = await ory.createOIDCClient();
45
+
46
+ const adminClient = new AdminClient({
47
+ url: 'http://localhost:3000',
48
+ auth: {
49
+ clientId,
50
+ clientSecret,
51
+ },
52
+ OAuth2URL: ory.OAuth2URL,
53
+ });
54
+
55
+ const token = await adminClient.getOidcToken();
56
+
57
+ const response = await supertest(http.expressInstance)
58
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
59
+ // @ts-ignore
60
+ .get('/test')
61
+ .set('Authorization', `Bearer ${token.access_token}`);
62
+
63
+ expect(response.status).to.be.equal(200);
64
+ });
65
+ });