@unito/integration-sdk 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.
Files changed (142) hide show
  1. package/.eslintrc.cjs +27 -0
  2. package/.nvmrc +1 -0
  3. package/.prettierignore +2 -0
  4. package/.prettierrc +7 -0
  5. package/LICENSE +3 -0
  6. package/README.md +16 -0
  7. package/dist/src/api/index.d.ts +2 -0
  8. package/dist/src/api/index.d.ts.map +1 -0
  9. package/dist/src/api/index.js +2 -0
  10. package/dist/src/api/index.js.map +1 -0
  11. package/dist/src/app/errors/HTTPError.d.ts +5 -0
  12. package/dist/src/app/errors/HTTPError.d.ts.map +1 -0
  13. package/dist/src/app/errors/HTTPError.js +8 -0
  14. package/dist/src/app/errors/HTTPError.js.map +1 -0
  15. package/dist/src/app/errors/HTTPNotFoundError.d.ts +5 -0
  16. package/dist/src/app/errors/HTTPNotFoundError.d.ts.map +1 -0
  17. package/dist/src/app/errors/HTTPNotFoundError.js +7 -0
  18. package/dist/src/app/errors/HTTPNotFoundError.js.map +1 -0
  19. package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts +5 -0
  20. package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts.map +1 -0
  21. package/dist/src/app/errors/HTTPUnprocessableEntityError.js +7 -0
  22. package/dist/src/app/errors/HTTPUnprocessableEntityError.js.map +1 -0
  23. package/dist/src/app/errors/index.d.ts +4 -0
  24. package/dist/src/app/errors/index.d.ts.map +1 -0
  25. package/dist/src/app/errors/index.js +4 -0
  26. package/dist/src/app/errors/index.js.map +1 -0
  27. package/dist/src/app/index.d.ts +6 -0
  28. package/dist/src/app/index.d.ts.map +1 -0
  29. package/dist/src/app/index.js +80 -0
  30. package/dist/src/app/index.js.map +1 -0
  31. package/dist/src/app/integration.d.ts +5 -0
  32. package/dist/src/app/integration.d.ts.map +1 -0
  33. package/dist/src/app/integration.js +86 -0
  34. package/dist/src/app/integration.js.map +1 -0
  35. package/dist/src/app/itemNode.d.ts +8 -0
  36. package/dist/src/app/itemNode.d.ts.map +1 -0
  37. package/dist/src/app/itemNode.js +13 -0
  38. package/dist/src/app/itemNode.js.map +1 -0
  39. package/dist/src/app/middlewares/withCorrelationId.d.ts +11 -0
  40. package/dist/src/app/middlewares/withCorrelationId.d.ts.map +1 -0
  41. package/dist/src/app/middlewares/withCorrelationId.js +8 -0
  42. package/dist/src/app/middlewares/withCorrelationId.js.map +1 -0
  43. package/dist/src/app/middlewares/withLogger.d.ts +12 -0
  44. package/dist/src/app/middlewares/withLogger.d.ts.map +1 -0
  45. package/dist/src/app/middlewares/withLogger.js +18 -0
  46. package/dist/src/app/middlewares/withLogger.js.map +1 -0
  47. package/dist/src/errors.d.ts +15 -0
  48. package/dist/src/errors.js +39 -0
  49. package/dist/src/handler.d.ts +67 -0
  50. package/dist/src/handler.js +224 -0
  51. package/dist/src/httpErrors.d.ts +22 -0
  52. package/dist/src/httpErrors.js +37 -0
  53. package/dist/src/index.d.ts +6 -0
  54. package/dist/src/index.d.ts.map +1 -0
  55. package/dist/src/index.js +6 -0
  56. package/dist/src/index.js.map +1 -0
  57. package/dist/src/integration.d.ts +9 -0
  58. package/dist/src/integration.js +108 -0
  59. package/dist/src/middlewares/correlationId.d.ts +10 -0
  60. package/dist/src/middlewares/correlationId.js +6 -0
  61. package/dist/src/middlewares/credentials.d.ts +11 -0
  62. package/dist/src/middlewares/credentials.js +17 -0
  63. package/dist/src/middlewares/errors.d.ts +3 -0
  64. package/dist/src/middlewares/errors.js +22 -0
  65. package/dist/src/middlewares/filters.d.ts +16 -0
  66. package/dist/src/middlewares/filters.js +25 -0
  67. package/dist/src/middlewares/finish.d.ts +11 -0
  68. package/dist/src/middlewares/finish.js +10 -0
  69. package/dist/src/middlewares/logger.d.ts +12 -0
  70. package/dist/src/middlewares/logger.js +18 -0
  71. package/dist/src/middlewares/notFound.d.ts +3 -0
  72. package/dist/src/middlewares/notFound.js +8 -0
  73. package/dist/src/middlewares/selects.d.ts +10 -0
  74. package/dist/src/middlewares/selects.js +11 -0
  75. package/dist/src/resources/cache.d.ts +4 -0
  76. package/dist/src/resources/cache.d.ts.map +1 -0
  77. package/dist/src/resources/cache.js +25 -0
  78. package/dist/src/resources/cache.js.map +1 -0
  79. package/dist/src/resources/context.d.ts +78 -0
  80. package/dist/src/resources/context.js +2 -0
  81. package/dist/src/resources/logger.d.ts +14 -0
  82. package/dist/src/resources/logger.d.ts.map +1 -0
  83. package/dist/src/resources/logger.js +46 -0
  84. package/dist/src/resources/logger.js.map +1 -0
  85. package/dist/src/resources/provider.d.ts +36 -0
  86. package/dist/src/resources/provider.js +76 -0
  87. package/dist/test/errors.test.d.ts +1 -0
  88. package/dist/test/errors.test.js +16 -0
  89. package/dist/test/handler.test.d.ts +1 -0
  90. package/dist/test/handler.test.js +146 -0
  91. package/dist/test/middlewares/correlationId.test.d.ts +1 -0
  92. package/dist/test/middlewares/correlationId.test.js +19 -0
  93. package/dist/test/middlewares/credentials.test.d.ts +1 -0
  94. package/dist/test/middlewares/credentials.test.js +37 -0
  95. package/dist/test/middlewares/errors.test.d.ts +1 -0
  96. package/dist/test/middlewares/errors.test.js +64 -0
  97. package/dist/test/middlewares/filters.test.d.ts +1 -0
  98. package/dist/test/middlewares/filters.test.js +26 -0
  99. package/dist/test/middlewares/finish.test.d.ts +1 -0
  100. package/dist/test/middlewares/finish.test.js +68 -0
  101. package/dist/test/middlewares/logger.test.d.ts +1 -0
  102. package/dist/test/middlewares/logger.test.js +41 -0
  103. package/dist/test/middlewares/notFound.test.d.ts +1 -0
  104. package/dist/test/middlewares/notFound.test.js +27 -0
  105. package/dist/test/middlewares/selects.test.d.ts +1 -0
  106. package/dist/test/middlewares/selects.test.js +21 -0
  107. package/dist/test/resources/cache.test.d.ts +1 -0
  108. package/dist/test/resources/cache.test.js +25 -0
  109. package/dist/test/resources/logger.test.d.ts +1 -0
  110. package/dist/test/resources/logger.test.js +67 -0
  111. package/dist/tsconfig.tsbuildinfo +1 -0
  112. package/package.json +59 -0
  113. package/src/errors.ts +34 -0
  114. package/src/handler.ts +404 -0
  115. package/src/httpErrors.ts +44 -0
  116. package/src/index.ts +10 -0
  117. package/src/integration.ts +129 -0
  118. package/src/middlewares/correlationId.ts +19 -0
  119. package/src/middlewares/credentials.ts +35 -0
  120. package/src/middlewares/errors.ts +30 -0
  121. package/src/middlewares/filters.ts +51 -0
  122. package/src/middlewares/finish.ts +24 -0
  123. package/src/middlewares/logger.ts +36 -0
  124. package/src/middlewares/notFound.ts +13 -0
  125. package/src/middlewares/selects.ts +31 -0
  126. package/src/resources/cache.ts +34 -0
  127. package/src/resources/context.ts +113 -0
  128. package/src/resources/logger.ts +57 -0
  129. package/src/resources/provider.ts +120 -0
  130. package/test/errors.test.ts +17 -0
  131. package/test/handler.test.ts +178 -0
  132. package/test/middlewares/correlationId.test.ts +26 -0
  133. package/test/middlewares/credentials.test.ts +52 -0
  134. package/test/middlewares/errors.test.ts +78 -0
  135. package/test/middlewares/filters.test.ts +39 -0
  136. package/test/middlewares/finish.test.ts +81 -0
  137. package/test/middlewares/logger.test.ts +57 -0
  138. package/test/middlewares/notFound.test.ts +32 -0
  139. package/test/middlewares/selects.test.ts +34 -0
  140. package/test/resources/cache.test.ts +31 -0
  141. package/test/resources/logger.test.ts +80 -0
  142. package/tsconfig.json +29 -0
@@ -0,0 +1,51 @@
1
+ import { OperatorType } from '@unito/integration-api';
2
+ import { Request, Response, NextFunction } from 'express';
3
+
4
+ declare global {
5
+ // eslint-disable-next-line @typescript-eslint/no-namespace
6
+ namespace Express {
7
+ interface Locals {
8
+ filters: Filter[];
9
+ }
10
+ }
11
+ }
12
+
13
+ export interface Filter {
14
+ field: string;
15
+ operator: OperatorType;
16
+ // Without the schema of the item,
17
+ // we can't determine the types of the values (number, boolean, etc).
18
+ values: string[] | undefined;
19
+ }
20
+
21
+ // The operators are ordered by their symbol length, in descending order.
22
+ // This is necessary because the symbol of an operator can be
23
+ // a subset of the symbol of another operator.
24
+ //
25
+ // For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
26
+ const ORDERED_OPERATORS = Object.values(OperatorType).sort((o1, o2) => o1.length - o2.length);
27
+
28
+ const middleware = (req: Request, res: Response, next: NextFunction) => {
29
+ const rawFilters = req.query.filter;
30
+
31
+ res.locals.filters = [];
32
+
33
+ if (typeof rawFilters === 'string') {
34
+ for (const rawFilter of rawFilters.split(',')) {
35
+ for (const operator of ORDERED_OPERATORS) {
36
+ if (rawFilter.includes(operator)) {
37
+ const [field, valuesRaw] = rawFilter.split(operator, 2);
38
+ const values = valuesRaw ? valuesRaw.split('|').map(decodeURIComponent) : [];
39
+
40
+ res.locals.filters.push({ field: field!, operator, values });
41
+
42
+ break;
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ next();
49
+ };
50
+
51
+ export default middleware;
@@ -0,0 +1,24 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import Logger from '../resources/logger.js';
3
+
4
+ declare global {
5
+ // eslint-disable-next-line @typescript-eslint/no-namespace
6
+ namespace Express {
7
+ interface Locals {
8
+ logger: Logger;
9
+ }
10
+ }
11
+ }
12
+
13
+ const middleware = (req: Request, res: Response, next: NextFunction) => {
14
+ if (req.originalUrl !== '/health') {
15
+ res.on('finish', function () {
16
+ const loggerLevel = res.statusCode >= 500 ? 'error' : 'info';
17
+ res.locals.logger[loggerLevel](`${req.method} ${req.originalUrl} ${res.statusCode}`);
18
+ });
19
+ }
20
+
21
+ next();
22
+ };
23
+
24
+ export default middleware;
@@ -0,0 +1,36 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import Logger from '../resources/logger.js';
3
+
4
+ declare global {
5
+ // eslint-disable-next-line @typescript-eslint/no-namespace
6
+ namespace Express {
7
+ interface Locals {
8
+ correlationId: string;
9
+ logger: Logger;
10
+ }
11
+ }
12
+ }
13
+
14
+ const ADDITIONAL_CONTEXT_HEADER = 'X-Unito-Additional-Logging-Context';
15
+
16
+ const middleware = (req: Request, res: Response, next: NextFunction) => {
17
+ const logger = new Logger({ correlation_id: res.locals.correlationId });
18
+
19
+ res.locals.logger = logger;
20
+
21
+ const rawAdditionalContext = req.header(ADDITIONAL_CONTEXT_HEADER);
22
+
23
+ if (typeof rawAdditionalContext === 'string') {
24
+ try {
25
+ const additionalContext = JSON.parse(Buffer.from(rawAdditionalContext, 'base64').toString('utf8'));
26
+
27
+ logger.decorate(additionalContext);
28
+ } catch (error) {
29
+ logger.warn(`Malformed HTTP header ${ADDITIONAL_CONTEXT_HEADER}: ${rawAdditionalContext}`);
30
+ }
31
+ }
32
+
33
+ next();
34
+ };
35
+
36
+ export default middleware;
@@ -0,0 +1,13 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { Error as APIError } from '@unito/integration-api';
3
+
4
+ const middleware = (req: Request, res: Response, _next: NextFunction) => {
5
+ const error: APIError = {
6
+ code: '404',
7
+ message: `Path ${req.path} not found.`,
8
+ };
9
+
10
+ res.status(404).json(error);
11
+ };
12
+
13
+ export default middleware;
@@ -0,0 +1,31 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ declare global {
4
+ // eslint-disable-next-line @typescript-eslint/no-namespace
5
+ namespace Express {
6
+ interface Locals {
7
+ // When the query params contains...
8
+ //
9
+ // select=name,department.name
10
+ //
11
+ // ... it becomes available as follow in handlers:
12
+ //
13
+ // ['name', 'department.name']
14
+ selects: string[];
15
+ }
16
+ }
17
+ }
18
+
19
+ const middleware = (req: Request, res: Response, next: NextFunction) => {
20
+ const rawSelect = req.query.select;
21
+
22
+ if (typeof rawSelect === 'string') {
23
+ res.locals.selects = rawSelect.split(',');
24
+ } else {
25
+ res.locals.selects = [];
26
+ }
27
+
28
+ next();
29
+ };
30
+
31
+ export default middleware;
@@ -0,0 +1,34 @@
1
+ import { WriteThroughCache, LocalCache, CacheInstance } from 'cachette';
2
+ import { createHash } from 'node:crypto';
3
+ import * as uuid from 'uuid';
4
+ import Logger from './logger.js';
5
+
6
+ export type Cache = CacheInstance;
7
+
8
+ export function initializeCache(): Cache {
9
+ const cacheInstance: Cache = process.env.REDIS_URL ? new WriteThroughCache(process.env.REDIS_URL) : new LocalCache();
10
+
11
+ // Intended: the correlation id will be the same for all logs of Cachette.
12
+ const correlationId = uuid.v4();
13
+
14
+ const logger = new Logger({ correlation_id: correlationId });
15
+
16
+ cacheInstance
17
+ .on('info', message => {
18
+ logger.info(message);
19
+ })
20
+ .on('warn', message => {
21
+ logger.warn(message);
22
+ })
23
+ .on('error', message => {
24
+ logger.error(message);
25
+ });
26
+
27
+ return cacheInstance;
28
+ }
29
+
30
+ const shake256 = createHash('shake256');
31
+
32
+ export function generateCacheKey(value: string): string {
33
+ return shake256.update(value).digest('hex');
34
+ }
@@ -0,0 +1,113 @@
1
+ /* c8 ignore start */
2
+ import * as API from '@unito/integration-api';
3
+
4
+ import Logger from './logger.js';
5
+ import { Credentials } from '../middlewares/credentials.js';
6
+ import { Filter } from '../middlewares/filters.js';
7
+
8
+ type Context<P extends Record<string, string>, Q extends ParsedQueryString> = {
9
+ /**
10
+ * The parsed credentials associated with the request through the X-Unito-Credentials header.
11
+ *
12
+ * Will contain the keys for the variables defined in the corresponding configuration's authorization.
13
+ */
14
+ credentials: Credentials;
15
+ /**
16
+ * The logger pre decorated with the correlation ID and the additionnal metadata provided through the request headers.
17
+ */
18
+ logger: Logger;
19
+ /**
20
+ * The request params.
21
+ *
22
+ * A call to `/customers/:customerId/subscriptions/:subscriptionId` => `/customers/123/subscriptions/2` will yield:
23
+ *
24
+ * {
25
+ * customerId: '123',
26
+ * subscriptionId: '2'
27
+ * }
28
+ */
29
+ params: P;
30
+ /**
31
+ * The raw query string parsed as an object.
32
+ */
33
+ query: Q;
34
+ };
35
+
36
+ export type GetItemContext<
37
+ P extends Record<string, string> = Record<string, never>,
38
+ Q extends Record<string, string> = Record<string, never>,
39
+ > = Context<P, Q>;
40
+
41
+ export type GetCollectionContext<
42
+ P extends Record<string, string> = Record<string, never>,
43
+ Q extends Record<string, string> = Record<string, never>,
44
+ > = Context<P, Q> & {
45
+ /**
46
+ * Parsed filter query param yielding a list of filters.
47
+ *
48
+ * Given a filter query param:
49
+ * `filter=name=John,department.name=Engineering`
50
+ *
51
+ * Context.filters will be:
52
+ * [
53
+ * { field: 'name', operator: 'EQUAL', values: ['John'] },
54
+ * { field: 'department.name', operator: 'EQUAL', values: ['Engineering'] }
55
+ * ]
56
+ */
57
+ filters: Filter[];
58
+ /**
59
+ * Parsed select query param yielding a list of fields to select.
60
+ *
61
+ * Given a select query param:
62
+ * `select=name,department.name`
63
+ *
64
+ * Context.selects will be:
65
+ * ['name', 'department.name']
66
+ */
67
+ selects: string[];
68
+ };
69
+
70
+ export type CreateItemContext<
71
+ P extends Record<string, string> = Record<string, never>,
72
+ Q extends Record<string, string> = Record<string, never>,
73
+ B extends API.CreateItemRequestPayload = API.CreateItemRequestPayload,
74
+ > = Context<P, Q> & { body: B };
75
+
76
+ export type UpdateItemContext<
77
+ P extends Record<string, string> = Record<string, never>,
78
+ Q extends Record<string, string> = Record<string, never>,
79
+ B extends API.UpdateItemRequestPayload = API.UpdateItemRequestPayload,
80
+ > = Context<P, Q> & { body: B };
81
+
82
+ export type DeleteItemContext<
83
+ P extends Record<string, string> = Record<string, never>,
84
+ Q extends Record<string, string> = Record<string, never>,
85
+ > = Context<P, Q>;
86
+
87
+ export type GetCredentialAccountContext<
88
+ P extends Record<string, string> = Record<string, never>,
89
+ Q extends Record<string, string> = Record<string, never>,
90
+ > = Context<P, Q>;
91
+
92
+ export type ParseWebhooksContext<
93
+ P extends Record<string, string> = Record<string, never>,
94
+ Q extends Record<string, string> = Record<string, never>,
95
+ B extends API.WebhookParseRequestPayload = API.WebhookParseRequestPayload,
96
+ > = Omit<Context<P, Q>, 'credentials'> & { body: B };
97
+
98
+ export type UpdateWebhookSubscriptionsContext<
99
+ P extends Record<string, string> = Record<string, never>,
100
+ Q extends Record<string, string> = Record<string, never>,
101
+ B extends API.WebhookSubscriptionRequestPayload = API.WebhookSubscriptionRequestPayload,
102
+ > = Context<P, Q> & { body: B };
103
+
104
+ export type AckknowledgeWebhooksContext<
105
+ P extends Record<string, string> = Record<string, never>,
106
+ Q extends Record<string, string> = Record<string, never>,
107
+ B extends API.WebhookParseRequestPayload = API.WebhookParseRequestPayload,
108
+ > = Omit<Context<P, Q>, 'credentials'> & { body: B };
109
+
110
+ interface ParsedQueryString {
111
+ [key: string]: undefined | string | string[] | ParsedQueryString | ParsedQueryString[];
112
+ }
113
+ /* c8 ignore stop */
@@ -0,0 +1,57 @@
1
+ enum LogLevel {
2
+ ERROR = 'error',
3
+ WARN = 'warn',
4
+ INFO = 'info',
5
+ LOG = 'log',
6
+ DEBUG = 'debug',
7
+ }
8
+
9
+ export default class Logger {
10
+ private metadata: Record<string, unknown> = {};
11
+
12
+ constructor(metadata?: Record<string, unknown>) {
13
+ if (metadata) {
14
+ this.metadata = structuredClone(metadata);
15
+ }
16
+ }
17
+
18
+ private send(logLevel: LogLevel, message: unknown) {
19
+ console[logLevel](JSON.stringify({ message, ...this.metadata }));
20
+ }
21
+
22
+ public log(data: unknown) {
23
+ this.send(LogLevel.LOG, data);
24
+ }
25
+
26
+ public error(data: unknown) {
27
+ this.send(LogLevel.ERROR, data);
28
+ }
29
+
30
+ public warn(data: unknown) {
31
+ this.send(LogLevel.WARN, data);
32
+ }
33
+
34
+ public info(data: unknown) {
35
+ this.send(LogLevel.INFO, data);
36
+ }
37
+
38
+ public debug(data: unknown) {
39
+ this.send(LogLevel.DEBUG, data);
40
+ }
41
+
42
+ public decorate(metadata: Record<string, unknown>) {
43
+ this.metadata = { ...this.metadata, ...metadata };
44
+ }
45
+
46
+ public getMetadata() {
47
+ return structuredClone(this.metadata);
48
+ }
49
+
50
+ public setMetadata(key: string, value: unknown) {
51
+ this.metadata[key] = value;
52
+ }
53
+
54
+ public clearMetadata() {
55
+ this.metadata = {};
56
+ }
57
+ }
@@ -0,0 +1,120 @@
1
+ import { handleErrorResponse as throwHttpError } from '../errors.js';
2
+ import { Credentials } from '../middlewares/credentials.js';
3
+
4
+ export interface RequestOptions {
5
+ credentials: Credentials;
6
+ queryParams?: { [key: string]: string };
7
+ body?: Record<string, unknown>;
8
+ additionnalheaders?: { [key: string]: string };
9
+ }
10
+
11
+ export interface Response<T> {
12
+ data: T;
13
+ headers: Headers;
14
+ }
15
+
16
+ export class Provider {
17
+ protected prepareRequest:
18
+ | ((context: { credentials: Credentials }) => {
19
+ /**
20
+ * The base URL of the provider.
21
+ */
22
+ url: string;
23
+ /**
24
+ * The additional headers to add to the request.
25
+ */
26
+ headers: Record<string, string>;
27
+ })
28
+ | undefined = undefined;
29
+
30
+ constructor(options: { prepareRequest: typeof Provider.prototype.prepareRequest }) {
31
+ this.prepareRequest = options.prepareRequest;
32
+ }
33
+
34
+ public async get<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
35
+ return this.fetchWrapper<T>(endpoint, {
36
+ ...options,
37
+ method: 'GET',
38
+ defaultHeaders: {
39
+ 'Content-Type': 'application/json',
40
+ Accept: 'application/json',
41
+ },
42
+ });
43
+ }
44
+
45
+ public async post<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
46
+ return this.fetchWrapper<T>(endpoint, {
47
+ ...options,
48
+ method: 'POST',
49
+ defaultHeaders: {
50
+ 'Content-Type': 'application/x-www-form-urlencoded',
51
+ Accept: 'application/json',
52
+ },
53
+ });
54
+ }
55
+
56
+ public async patch<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
57
+ return this.fetchWrapper<T>(endpoint, {
58
+ ...options,
59
+ method: 'PATCH',
60
+ defaultHeaders: {
61
+ 'Content-Type': 'application/x-www-form-urlencoded',
62
+ Accept: 'application/json',
63
+ },
64
+ });
65
+ }
66
+
67
+ private async fetchWrapper<T>(
68
+ endpoint: string,
69
+ options: RequestOptions & { defaultHeaders: { 'Content-Type': string; Accept: string }; method: string },
70
+ ): Promise<Response<T>> {
71
+ if (!this.prepareRequest) {
72
+ throw new Error('Provider not initialized');
73
+ }
74
+
75
+ const { url: providerUrl, headers: providerHeaders } = this.prepareRequest({ credentials: options.credentials });
76
+
77
+ let absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
78
+
79
+ if (options.queryParams) {
80
+ absoluteUrl = `${absoluteUrl}?${new URLSearchParams(options.queryParams)}`;
81
+ }
82
+
83
+ const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
84
+
85
+ let body: string | null = null;
86
+
87
+ if (options.body) {
88
+ if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
89
+ body = new URLSearchParams(options.body as Record<string, string>).toString(); // this doesn't support objects!
90
+ } else if (headers['Content-Type'] === 'application/json') {
91
+ body = JSON.stringify(options.body);
92
+ }
93
+ }
94
+
95
+ const callToProvider = async () =>
96
+ await fetch(absoluteUrl, {
97
+ method: options.method,
98
+ headers,
99
+ body: body,
100
+ });
101
+
102
+ // TODO: add back rate limiter
103
+ const response = await callToProvider();
104
+
105
+ if (response.status >= 400) {
106
+ const textResult = await response.text();
107
+ throwHttpError(response.status, textResult);
108
+ }
109
+
110
+ let data;
111
+
112
+ try {
113
+ data = await response.json();
114
+ } catch {
115
+ throwHttpError(400, 'Invalid JSON response');
116
+ }
117
+
118
+ return { headers: response.headers, data };
119
+ }
120
+ }
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import * as errors from '../src/errors.js';
4
+ import * as httpErrors from '../src/httpErrors.js';
5
+
6
+ describe('handleErrorResponse', () => {
7
+ it('returns correct httpError given status code', () => {
8
+ assert.throws(() => errors.handleErrorResponse(400, 'bad request'), httpErrors.BadRequestError);
9
+ assert.throws(() => errors.handleErrorResponse(401, 'unauthorized'), httpErrors.UnauthorizedError);
10
+ assert.throws(() => errors.handleErrorResponse(403, 'forbidden'), httpErrors.UnauthorizedError);
11
+ assert.throws(() => errors.handleErrorResponse(404, 'not found'), httpErrors.NotFoundError);
12
+ assert.throws(() => errors.handleErrorResponse(408, 'timeout'), httpErrors.TimeoutError);
13
+ assert.throws(() => errors.handleErrorResponse(422, 'unprocessable entity'), httpErrors.UnprocessableEntityError);
14
+ assert.throws(() => errors.handleErrorResponse(429, 'rate limit exceeded'), httpErrors.RateLimitExceededError);
15
+ assert.throws(() => errors.handleErrorResponse(500, 'internal server error'), httpErrors.HttpError);
16
+ });
17
+ });
@@ -0,0 +1,178 @@
1
+ import { Request, Response } from 'express';
2
+ import assert from 'node:assert/strict';
3
+ import { afterEach, beforeEach, describe, it, mock } from 'node:test';
4
+ import { Handler } from '../src/handler.js';
5
+ import { BadRequestError } from '../src/httpErrors.js';
6
+
7
+ describe('Handler', () => {
8
+ beforeEach(() => {
9
+ // Disable debug logging to keep the test output clean.
10
+ mock.method(global.console, 'debug', () => {});
11
+ });
12
+
13
+ afterEach(() => {
14
+ mock.reset();
15
+ });
16
+
17
+ describe('constructor', () => {
18
+ it('returns a Handler', () => {
19
+ const itemHandler = new Handler('/', {});
20
+
21
+ assert.equal(itemHandler instanceof Handler, true);
22
+ });
23
+
24
+ it('validates path', () => {
25
+ const paths: [string, boolean][] = [
26
+ ['', false],
27
+ ['invalid', false],
28
+ ['/', true],
29
+ ['/bar', true],
30
+ ['/bar/', false],
31
+ ['/foo/:bar', true],
32
+ ['/foo/:bar/', false],
33
+ ['/foo/:bar/spam/:baz', true],
34
+ ['/foo/:bar/:spam', true],
35
+ ];
36
+
37
+ for (const [path, valid] of paths) {
38
+ if (valid) {
39
+ assert.doesNotThrow(() => new Handler(path, {}));
40
+ } else {
41
+ assert.throws(() => new Handler(path, {}));
42
+ }
43
+ }
44
+ });
45
+ });
46
+
47
+ describe('generate', () => {
48
+ async function executeHandler(
49
+ handler: (req: Request, res: Response) => Promise<void>,
50
+ request: Record<string, unknown> = {},
51
+ ) {
52
+ let body: Record<string, unknown> = {};
53
+ let statusCode = 0;
54
+
55
+ const response = {
56
+ status: (receivedStatusCode: number) => ({
57
+ send: (receivedBody: Record<string, unknown>) => {
58
+ body = receivedBody;
59
+ statusCode = receivedStatusCode;
60
+ },
61
+ }),
62
+ statusCode: 0,
63
+ locals: {
64
+ credentials: { foo: 'bar' },
65
+ },
66
+ } as unknown as Response;
67
+
68
+ await handler(request as unknown as Request, response);
69
+
70
+ return { body, statusCode };
71
+ }
72
+
73
+ it('returns a router', async () => {
74
+ const handler = new Handler('/foo/:bar', {
75
+ getCollection: () => Promise.resolve({ info: {}, data: [] }),
76
+ getItem: () => Promise.resolve({ fields: {}, relations: [] }),
77
+ createItem: () => Promise.resolve({ fields: {}, path: '/' }),
78
+ updateItem: () => Promise.resolve({ fields: {}, relations: [] }),
79
+ deleteItem: () => Promise.resolve(),
80
+ });
81
+
82
+ const router = handler.generate();
83
+ const routes = router.stack;
84
+
85
+ assert.equal(routes.length, 5);
86
+ assert.equal(routes[0].route.path, '/foo');
87
+ assert.equal(routes[0].route.methods.get, true);
88
+ assert.equal(routes[1].route.path, '/foo');
89
+ assert.equal(routes[1].route.methods.post, true);
90
+ assert.equal(routes[2].route.path, '/foo/:bar');
91
+ assert.equal(routes[2].route.methods.get, true);
92
+ assert.equal(routes[3].route.path, '/foo/:bar');
93
+ assert.equal(routes[3].route.methods.patch, true);
94
+ assert.equal(routes[4].route.path, '/foo/:bar');
95
+ assert.equal(routes[4].route.methods.delete, true);
96
+
97
+ // GetCollection.
98
+ assert.deepEqual(await executeHandler(routes[0].route.stack[0].handle), {
99
+ body: { info: {}, data: [] },
100
+ statusCode: 200,
101
+ });
102
+
103
+ // CreateItem.
104
+ assert.deepEqual(await executeHandler(routes[1].route.stack[0].handle, { body: { foo: 'bar' } }), {
105
+ body: { fields: {}, path: '/' },
106
+ statusCode: 201,
107
+ });
108
+
109
+ // GetItem.
110
+ assert.deepEqual(await executeHandler(routes[2].route.stack[0].handle), {
111
+ body: { fields: {}, relations: [] },
112
+ statusCode: 200,
113
+ });
114
+
115
+ // UpdateItem.
116
+ assert.deepEqual(await executeHandler(routes[3].route.stack[0].handle, { body: { foo: 'bar' } }), {
117
+ body: { fields: {}, relations: [] },
118
+ statusCode: 200,
119
+ });
120
+
121
+ // DeleteItem.
122
+ assert.deepEqual(await executeHandler(routes[4].route.stack[0].handle), {
123
+ body: null,
124
+ statusCode: 204,
125
+ });
126
+ });
127
+
128
+ it('uses pathWithIdentifier as is when appropriate', async () => {
129
+ const handler = new Handler('/foo/:bar/baz', {
130
+ getCollection: () => Promise.resolve({ info: {}, data: [] }),
131
+ });
132
+
133
+ const router = handler.generate();
134
+ const routes = router.stack;
135
+
136
+ assert.equal(routes.length, 1);
137
+ assert.equal(routes[0].route.path, '/foo/:bar/baz');
138
+ assert.equal(routes[0].route.methods.get, true);
139
+
140
+ // GetCollection.
141
+ assert.deepEqual(await executeHandler(routes[0].route.stack[0].handle), {
142
+ body: { info: {}, data: [] },
143
+ statusCode: 200,
144
+ });
145
+ });
146
+
147
+ it('undefined handlers', async () => {
148
+ const handler = new Handler('/foo/:bar', {});
149
+
150
+ const router = handler.generate();
151
+ const routes = router.stack;
152
+
153
+ assert.equal(routes.length, 0);
154
+ });
155
+
156
+ it('returns a 400 for invalid request payloads', async () => {
157
+ const handler = new Handler('/foo/:bar', {
158
+ createItem: () => Promise.resolve({ fields: {}, path: '/' }),
159
+ updateItem: () => Promise.resolve({ fields: {}, relations: [] }),
160
+ });
161
+
162
+ const router = handler.generate();
163
+ const routes = router.stack;
164
+
165
+ // CreateItemRequestPayload.
166
+ const createHandler = routes[0].route.stack[0].handle;
167
+ await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
168
+ await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
169
+ await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
170
+
171
+ // UpdateItemRequestPayload.
172
+ const updateHandler = routes[0].route.stack[0].handle;
173
+ await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
174
+ await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
175
+ await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
176
+ });
177
+ });
178
+ });