@unito/integration-sdk 0.1.0 → 0.1.2

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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/src/errors.d.ts +3 -2
  3. package/dist/src/errors.js +11 -9
  4. package/dist/src/handler.js +12 -1
  5. package/dist/src/httpErrors.d.ts +3 -0
  6. package/dist/src/httpErrors.js +6 -1
  7. package/dist/src/index.d.ts +2 -1
  8. package/dist/src/index.js +2 -0
  9. package/dist/src/middlewares/credentials.d.ts +4 -1
  10. package/dist/src/resources/provider.d.ts +35 -12
  11. package/dist/src/resources/provider.js +59 -34
  12. package/dist/test/errors.test.js +7 -8
  13. package/dist/test/handler.test.js +26 -2
  14. package/dist/test/resources/provider.test.d.ts +1 -0
  15. package/dist/test/resources/provider.test.js +229 -0
  16. package/package.json +1 -3
  17. package/src/errors.ts +12 -9
  18. package/src/handler.ts +18 -1
  19. package/src/httpErrors.ts +7 -1
  20. package/src/index.ts +4 -0
  21. package/src/middlewares/credentials.ts +1 -1
  22. package/src/resources/provider.ts +89 -47
  23. package/test/errors.test.ts +7 -8
  24. package/test/handler.test.ts +36 -2
  25. package/test/resources/provider.test.ts +285 -0
  26. package/dist/src/api/index.d.ts +0 -2
  27. package/dist/src/api/index.d.ts.map +0 -1
  28. package/dist/src/api/index.js +0 -2
  29. package/dist/src/api/index.js.map +0 -1
  30. package/dist/src/app/errors/HTTPError.d.ts +0 -5
  31. package/dist/src/app/errors/HTTPError.d.ts.map +0 -1
  32. package/dist/src/app/errors/HTTPError.js +0 -8
  33. package/dist/src/app/errors/HTTPError.js.map +0 -1
  34. package/dist/src/app/errors/HTTPNotFoundError.d.ts +0 -5
  35. package/dist/src/app/errors/HTTPNotFoundError.d.ts.map +0 -1
  36. package/dist/src/app/errors/HTTPNotFoundError.js +0 -7
  37. package/dist/src/app/errors/HTTPNotFoundError.js.map +0 -1
  38. package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts +0 -5
  39. package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts.map +0 -1
  40. package/dist/src/app/errors/HTTPUnprocessableEntityError.js +0 -7
  41. package/dist/src/app/errors/HTTPUnprocessableEntityError.js.map +0 -1
  42. package/dist/src/app/errors/index.d.ts +0 -4
  43. package/dist/src/app/errors/index.d.ts.map +0 -1
  44. package/dist/src/app/errors/index.js +0 -4
  45. package/dist/src/app/errors/index.js.map +0 -1
  46. package/dist/src/app/index.d.ts +0 -6
  47. package/dist/src/app/index.d.ts.map +0 -1
  48. package/dist/src/app/index.js +0 -80
  49. package/dist/src/app/index.js.map +0 -1
  50. package/dist/src/app/integration.d.ts +0 -5
  51. package/dist/src/app/integration.d.ts.map +0 -1
  52. package/dist/src/app/integration.js +0 -86
  53. package/dist/src/app/integration.js.map +0 -1
  54. package/dist/src/app/itemNode.d.ts +0 -8
  55. package/dist/src/app/itemNode.d.ts.map +0 -1
  56. package/dist/src/app/itemNode.js +0 -13
  57. package/dist/src/app/itemNode.js.map +0 -1
  58. package/dist/src/app/middlewares/withCorrelationId.d.ts +0 -11
  59. package/dist/src/app/middlewares/withCorrelationId.d.ts.map +0 -1
  60. package/dist/src/app/middlewares/withCorrelationId.js +0 -8
  61. package/dist/src/app/middlewares/withCorrelationId.js.map +0 -1
  62. package/dist/src/app/middlewares/withLogger.d.ts +0 -12
  63. package/dist/src/app/middlewares/withLogger.d.ts.map +0 -1
  64. package/dist/src/app/middlewares/withLogger.js +0 -18
  65. package/dist/src/app/middlewares/withLogger.js.map +0 -1
  66. package/dist/src/index.d.ts.map +0 -1
  67. package/dist/src/index.js.map +0 -1
  68. package/dist/src/resources/cache.d.ts.map +0 -1
  69. package/dist/src/resources/cache.js.map +0 -1
  70. package/dist/src/resources/logger.d.ts.map +0 -1
  71. package/dist/src/resources/logger.js.map +0 -1
  72. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,229 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { Provider } from '../../src/resources/provider.js';
4
+ describe('Provider', () => {
5
+ const provider = new Provider({
6
+ prepareRequest: requestOptions => {
7
+ return {
8
+ url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
9
+ headers: {
10
+ 'X-Custom-Provider-Header': 'value',
11
+ 'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
12
+ },
13
+ };
14
+ },
15
+ });
16
+ it('get', async (context) => {
17
+ const response = new Response('{"data": "value"}', {
18
+ status: 200,
19
+ headers: { 'Content-Type': 'application/json' },
20
+ });
21
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
22
+ const actualResponse = await provider.get('/endpoint', {
23
+ credentials: { apiKey: 'apikey#1111' },
24
+ additionnalheaders: { 'X-Additional-Header': 'value1' },
25
+ });
26
+ assert.equal(fetchMock.mock.calls.length, 1);
27
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
28
+ 'www.myApi.com/endpoint',
29
+ {
30
+ method: 'GET',
31
+ body: null,
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ Accept: 'application/json',
35
+ 'X-Custom-Provider-Header': 'value',
36
+ 'X-Provider-Credential-Header': 'apikey#1111',
37
+ 'X-Additional-Header': 'value1',
38
+ },
39
+ },
40
+ ]);
41
+ assert.deepEqual(actualResponse, { status: 200, headers: response.headers, data: { data: 'value' } });
42
+ });
43
+ it('post with url encoded body', async (context) => {
44
+ const response = new Response('{"data": "value"}', {
45
+ status: 201,
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
49
+ const actualResponse = await provider.post('/endpoint', {
50
+ data: 'createdItemInfo',
51
+ }, {
52
+ credentials: { apiKey: 'apikey#1111' },
53
+ additionnalheaders: { 'X-Additional-Header': 'value1' },
54
+ });
55
+ assert.equal(fetchMock.mock.calls.length, 1);
56
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
57
+ 'www.myApi.com/endpoint',
58
+ {
59
+ method: 'POST',
60
+ body: 'data=createdItemInfo',
61
+ headers: {
62
+ 'Content-Type': 'application/x-www-form-urlencoded',
63
+ Accept: 'application/json',
64
+ 'X-Custom-Provider-Header': 'value',
65
+ 'X-Provider-Credential-Header': 'apikey#1111',
66
+ 'X-Additional-Header': 'value1',
67
+ },
68
+ },
69
+ ]);
70
+ assert.deepEqual(actualResponse, { status: 201, headers: response.headers, data: { data: 'value' } });
71
+ });
72
+ it('put with json body', async (context) => {
73
+ const response = new Response('{"data": "value"}', {
74
+ status: 201,
75
+ headers: { 'Content-Type': 'application/json' },
76
+ });
77
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
78
+ // Removing leading '/' on endpoint to make sure we support both cases
79
+ const actualResponse = await provider.put('endpoint/123', {
80
+ data: 'updatedItemInfo',
81
+ }, {
82
+ credentials: { apiKey: 'apikey#1111' },
83
+ additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
84
+ });
85
+ assert.equal(fetchMock.mock.calls.length, 1);
86
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
87
+ 'www.myApi.com/endpoint/123',
88
+ {
89
+ method: 'PUT',
90
+ body: JSON.stringify({ data: 'updatedItemInfo' }),
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ Accept: 'application/json',
94
+ 'X-Custom-Provider-Header': 'value',
95
+ 'X-Provider-Credential-Header': 'apikey#1111',
96
+ 'X-Additional-Header': 'value1',
97
+ },
98
+ },
99
+ ]);
100
+ assert.deepEqual(actualResponse, { status: 201, headers: response.headers, data: { data: 'value' } });
101
+ });
102
+ it('patch with query params', async (context) => {
103
+ const response = new Response('{"data": "value"}', {
104
+ status: 201,
105
+ headers: { 'Content-Type': 'application/json' },
106
+ });
107
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
108
+ const actualResponse = await provider.patch('/endpoint/123', {
109
+ data: 'updatedItemInfo',
110
+ }, {
111
+ credentials: { apiKey: 'apikey#1111' },
112
+ queryParams: { param1: 'value1', param2: 'value2' },
113
+ additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
114
+ });
115
+ assert.equal(fetchMock.mock.calls.length, 1);
116
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
117
+ 'www.myApi.com/endpoint/123?param1=value1&param2=value2',
118
+ {
119
+ method: 'PATCH',
120
+ body: JSON.stringify({ data: 'updatedItemInfo' }),
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ Accept: 'application/json',
124
+ 'X-Custom-Provider-Header': 'value',
125
+ 'X-Provider-Credential-Header': 'apikey#1111',
126
+ 'X-Additional-Header': 'value1',
127
+ },
128
+ },
129
+ ]);
130
+ assert.deepEqual(actualResponse, { status: 201, headers: response.headers, data: { data: 'value' } });
131
+ });
132
+ it('delete', async (context) => {
133
+ const response = new Response(undefined, {
134
+ status: 204,
135
+ headers: { 'Content-Type': 'application/json' },
136
+ });
137
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
138
+ const actualResponse = await provider.delete('/endpoint/123', {
139
+ credentials: { apiKey: 'apikey#1111' },
140
+ additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
141
+ });
142
+ assert.equal(fetchMock.mock.calls.length, 1);
143
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
144
+ 'www.myApi.com/endpoint/123',
145
+ {
146
+ method: 'DELETE',
147
+ body: null,
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ Accept: 'application/json',
151
+ 'X-Custom-Provider-Header': 'value',
152
+ 'X-Provider-Credential-Header': 'apikey#1111',
153
+ 'X-Additional-Header': 'value1',
154
+ },
155
+ },
156
+ ]);
157
+ assert.deepEqual(actualResponse, { status: 204, headers: response.headers, data: undefined });
158
+ });
159
+ it('uses rate limiter if provided', async (context) => {
160
+ const mockRateLimiter = context.mock.fn((_context, request) => Promise.resolve(request()));
161
+ const rateLimitedProvider = new Provider({
162
+ prepareRequest: requestOptions => {
163
+ return {
164
+ url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
165
+ headers: {
166
+ 'X-Custom-Provider-Header': 'value',
167
+ 'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
168
+ },
169
+ };
170
+ },
171
+ rateLimiter: mockRateLimiter,
172
+ });
173
+ const response = new Response(undefined, {
174
+ status: 204,
175
+ headers: { 'Content-Type': 'application/json' },
176
+ });
177
+ const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
178
+ const options = {
179
+ credentials: { apiKey: 'apikey#1111' },
180
+ additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
181
+ };
182
+ const actualResponse = await rateLimitedProvider.delete('/endpoint/123', options);
183
+ assert.equal(mockRateLimiter.mock.calls.length, 1);
184
+ assert.deepEqual(mockRateLimiter.mock.calls[0]?.arguments[0]?.credentials, options.credentials);
185
+ assert.equal(fetchMock.mock.calls.length, 1);
186
+ assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
187
+ 'www.myApi.com/endpoint/123',
188
+ {
189
+ method: 'DELETE',
190
+ body: null,
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ Accept: 'application/json',
194
+ 'X-Custom-Provider-Header': 'value',
195
+ 'X-Provider-Credential-Header': 'apikey#1111',
196
+ 'X-Additional-Header': 'value1',
197
+ },
198
+ },
199
+ ]);
200
+ assert.deepEqual(actualResponse, { status: 204, headers: response.headers, data: undefined });
201
+ });
202
+ it('throws on invalid json response', async (context) => {
203
+ const response = new Response('{invalidJSON}', {
204
+ status: 200,
205
+ });
206
+ context.mock.method(global, 'fetch', () => Promise.resolve(response));
207
+ assert.rejects(() => provider.get('/endpoint/123', {
208
+ credentials: { apiKey: 'apikey#1111' },
209
+ }));
210
+ });
211
+ it('throws on status 400', async (context) => {
212
+ const response = new Response(undefined, {
213
+ status: 400,
214
+ });
215
+ context.mock.method(global, 'fetch', () => Promise.resolve(response));
216
+ assert.rejects(() => provider.get('/endpoint/123', {
217
+ credentials: { apiKey: 'apikey#1111' },
218
+ }));
219
+ });
220
+ it('throws on status 429', async (context) => {
221
+ const response = new Response(undefined, {
222
+ status: 429,
223
+ });
224
+ context.mock.method(global, 'fetch', () => Promise.resolve(response));
225
+ assert.rejects(() => provider.get('/endpoint/123', {
226
+ credentials: { apiKey: 'apikey#1111' },
227
+ }));
228
+ });
229
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
@@ -30,13 +30,11 @@
30
30
  "devDependencies": {
31
31
  "@types/express": "4.x",
32
32
  "@types/node": "20.x",
33
- "@types/prettier": "2.x",
34
33
  "@types/uuid": "9.x",
35
34
  "@typescript-eslint/eslint-plugin": "6.x",
36
35
  "@typescript-eslint/parser": "6.x",
37
36
  "c8": "9.x",
38
37
  "eslint": "8.x",
39
- "json-schema-to-typescript": "13.x",
40
38
  "prettier": "3.x",
41
39
  "ts-node": "10.x",
42
40
  "typescript": "5.x"
package/src/errors.ts CHANGED
@@ -9,26 +9,29 @@ export class ConfigurationMalformed extends Error {}
9
9
  export class InvalidHandler extends Error {}
10
10
 
11
11
  /**
12
- * Processes provider response codes and throw corresponding errors to be translated further in our responses
12
+ * Processes provider response codes and returns the corresponding errors to be translated further in our responses
13
13
  *
14
14
  * @param responseStatus the reponseStatus of the request. Any HTTP response code passed here will result in an error!
15
15
  * @param message The message returned by the provider
16
16
  */
17
17
  // Keep in errors.ts instead of httpErrors.ts because we do not need to export it outside of the sdk
18
- export function handleErrorResponse(responseStatus: number, message: string): void {
18
+ export function buildHttpError(responseStatus: number, message: string): HttpErrors.HttpError {
19
+ let httpError: HttpErrors.HttpError;
19
20
  if (responseStatus === 400) {
20
- throw new HttpErrors.BadRequestError(message);
21
+ httpError = new HttpErrors.BadRequestError(message);
21
22
  } else if (responseStatus === 401 || responseStatus === 403) {
22
- throw new HttpErrors.UnauthorizedError(message);
23
+ httpError = new HttpErrors.UnauthorizedError(message);
23
24
  } else if (responseStatus === 404) {
24
- throw new HttpErrors.NotFoundError(message);
25
+ httpError = new HttpErrors.NotFoundError(message);
25
26
  } else if (responseStatus === 408) {
26
- throw new HttpErrors.TimeoutError(message);
27
+ httpError = new HttpErrors.TimeoutError(message);
27
28
  } else if (responseStatus === 422) {
28
- throw new HttpErrors.UnprocessableEntityError(message);
29
+ httpError = new HttpErrors.UnprocessableEntityError(message);
29
30
  } else if (responseStatus === 429) {
30
- throw new HttpErrors.RateLimitExceededError(message);
31
+ httpError = new HttpErrors.RateLimitExceededError(message);
31
32
  } else {
32
- throw new HttpErrors.HttpError(message, responseStatus);
33
+ httpError = new HttpErrors.HttpError(message, responseStatus);
33
34
  }
35
+
36
+ return httpError;
34
37
  }
package/src/handler.ts CHANGED
@@ -118,6 +118,22 @@ function assertValidPath(path: string): asserts path is Path {
118
118
  }
119
119
  }
120
120
 
121
+ function assertValidConfiguration(path: Path, pathWithIdentifier: Path, handlers: Handlers) {
122
+ if (path === pathWithIdentifier) {
123
+ const individualHandlers = ['getItem', 'updateItem', 'deleteItem'];
124
+ const collectionHandlers = ['getCollection', 'createItem'];
125
+
126
+ const hasIndividualHandlers = individualHandlers.some(handler => handler in handlers);
127
+ const hasCollectionHandlers = collectionHandlers.some(handler => handler in handlers);
128
+
129
+ if (hasIndividualHandlers && hasCollectionHandlers) {
130
+ throw new InvalidHandler(
131
+ `The provided path '${path}' doesn't differentiate between individual and collection level operation, so you cannot define both. `,
132
+ );
133
+ }
134
+ }
135
+ }
136
+
121
137
  function parsePath(path: Path) {
122
138
  const pathParts = path.split('/');
123
139
 
@@ -185,11 +201,12 @@ export class Handler {
185
201
  private handlers: Handlers;
186
202
 
187
203
  constructor(inputPath: string, handlers: HandlersInput) {
188
- // Build paths.
189
204
  assertValidPath(inputPath);
190
205
 
191
206
  const { pathWithIdentifier, path } = parsePath(inputPath);
192
207
 
208
+ assertValidConfiguration(path, pathWithIdentifier, handlers);
209
+
193
210
  this.pathWithIdentifier = pathWithIdentifier;
194
211
  this.path = path;
195
212
 
package/src/httpErrors.ts CHANGED
@@ -39,6 +39,12 @@ export class UnprocessableEntityError extends HttpError {
39
39
 
40
40
  export class RateLimitExceededError extends HttpError {
41
41
  constructor(message?: string) {
42
- super(message || 'Unprocessable Entity', 429);
42
+ super(message || 'Rate Limit Exceeded', 429);
43
+ }
44
+ }
45
+
46
+ export class WouldExceedLimitError extends HttpError {
47
+ constructor(message?: string) {
48
+ super(message || 'Would Exceed Limit', 429);
43
49
  }
44
50
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /* c8 ignore start */
1
2
  export * as Api from '@unito/integration-api';
2
3
  export { default as Integration } from './integration.js';
3
4
  export * from './handler.js';
@@ -5,6 +6,9 @@ export {
5
6
  Provider,
6
7
  type Response as ProviderResponse,
7
8
  type RequestOptions as ProviderRequestOptions,
9
+ type RateLimiter,
8
10
  } from './resources/provider.js';
11
+ export type { Credentials } from './middlewares/credentials.js';
9
12
  export * as HttpErrors from './httpErrors.js';
10
13
  export * from './resources/context.js';
14
+ /* c8 ignore stop */
@@ -10,7 +10,7 @@ declare global {
10
10
  }
11
11
  }
12
12
 
13
- export type Credentials = Record<string, unknown>;
13
+ export type Credentials = { accessToken?: string; [keys: string]: unknown };
14
14
 
15
15
  const CREDENTIALS_HEADER = 'X-Unito-Credentials';
16
16
 
@@ -1,38 +1,59 @@
1
- import { handleErrorResponse as throwHttpError } from '../errors.js';
1
+ import { buildHttpError } from '../errors.js';
2
2
  import { Credentials } from '../middlewares/credentials.js';
3
3
 
4
+ /**
5
+ * RateLimiter is a wrapper function that you can provide to limit the rate of calls to the provider based on the
6
+ * caller's credentials.
7
+ *
8
+ * When necessary, the Provider's Response headers can be inspected to update the rate limit before being returned.
9
+ *
10
+ * NOTE: make sure to return one of the supported HttpErrors from the SDK, otherwise the error will be translated to a
11
+ * generic server (500) error.
12
+ *
13
+ * @param context - The credentials of the caller.
14
+ * @param targetFunction - The function to call the provider.
15
+ * @returns The response from the provider.
16
+ * @throws RateLimitExceededError when the rate limit is exceeded.
17
+ * @throws WouldExceedRateLimitError when the next call would exceed the rate limit.
18
+ * @throws HttpError when the provider returns an error.
19
+ */
20
+ export type RateLimiter = <T>(
21
+ context: { credentials: Credentials },
22
+ targetFunction: () => Promise<Response<T>>,
23
+ ) => Promise<Response<T>>;
24
+
4
25
  export interface RequestOptions {
5
26
  credentials: Credentials;
6
27
  queryParams?: { [key: string]: string };
7
- body?: Record<string, unknown>;
8
28
  additionnalheaders?: { [key: string]: string };
9
29
  }
10
30
 
11
31
  export interface Response<T> {
12
- data: T;
32
+ data: T | undefined;
33
+ status: number;
13
34
  headers: Headers;
14
35
  }
15
36
 
16
37
  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 }) {
38
+ protected rateLimiter: RateLimiter | undefined = undefined;
39
+ protected prepareRequest: (context: { credentials: Credentials }) => {
40
+ url: string;
41
+ headers: Record<string, string>;
42
+ };
43
+
44
+ /**
45
+ * Initialize a Provider with the given options.
46
+ *
47
+ * @property prepareRequest - function to define the Provider's base URL and specific headers to add to the request.
48
+ * @property rateLimiter - function to limit the rate of calls to the provider based on the caller's credentials.
49
+ */
50
+ constructor(options: { prepareRequest: typeof Provider.prototype.prepareRequest; rateLimiter?: RateLimiter }) {
31
51
  this.prepareRequest = options.prepareRequest;
52
+ this.rateLimiter = options.rateLimiter;
32
53
  }
33
54
 
34
55
  public async get<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
35
- return this.fetchWrapper<T>(endpoint, {
56
+ return this.fetchWrapper<T>(endpoint, null, {
36
57
  ...options,
37
58
  method: 'GET',
38
59
  defaultHeaders: {
@@ -42,8 +63,8 @@ export class Provider {
42
63
  });
43
64
  }
44
65
 
45
- public async post<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
46
- return this.fetchWrapper<T>(endpoint, {
66
+ public async post<T>(endpoint: string, body: Record<string, unknown>, options: RequestOptions): Promise<Response<T>> {
67
+ return this.fetchWrapper<T>(endpoint, body, {
47
68
  ...options,
48
69
  method: 'POST',
49
70
  defaultHeaders: {
@@ -53,8 +74,23 @@ export class Provider {
53
74
  });
54
75
  }
55
76
 
56
- public async patch<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
57
- return this.fetchWrapper<T>(endpoint, {
77
+ public async put<T>(endpoint: string, body: Record<string, unknown>, options: RequestOptions): Promise<Response<T>> {
78
+ return this.fetchWrapper<T>(endpoint, body, {
79
+ ...options,
80
+ method: 'PUT',
81
+ defaultHeaders: {
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ Accept: 'application/json',
84
+ },
85
+ });
86
+ }
87
+
88
+ public async patch<T>(
89
+ endpoint: string,
90
+ body: Record<string, unknown>,
91
+ options: RequestOptions,
92
+ ): Promise<Response<T>> {
93
+ return this.fetchWrapper<T>(endpoint, body, {
58
94
  ...options,
59
95
  method: 'PATCH',
60
96
  defaultHeaders: {
@@ -64,14 +100,22 @@ export class Provider {
64
100
  });
65
101
  }
66
102
 
103
+ public async delete<T>(endpoint: string, options: RequestOptions): Promise<Response<T>> {
104
+ return this.fetchWrapper<T>(endpoint, null, {
105
+ ...options,
106
+ method: 'DELETE',
107
+ defaultHeaders: {
108
+ 'Content-Type': 'application/x-www-form-urlencoded',
109
+ Accept: 'application/json',
110
+ },
111
+ });
112
+ }
113
+
67
114
  private async fetchWrapper<T>(
68
115
  endpoint: string,
116
+ body: Record<string, unknown> | null,
69
117
  options: RequestOptions & { defaultHeaders: { 'Content-Type': string; Accept: string }; method: string },
70
118
  ): Promise<Response<T>> {
71
- if (!this.prepareRequest) {
72
- throw new Error('Provider not initialized');
73
- }
74
-
75
119
  const { url: providerUrl, headers: providerHeaders } = this.prepareRequest({ credentials: options.credentials });
76
120
 
77
121
  let absoluteUrl = [providerUrl, endpoint.charAt(0) === '/' ? endpoint.substring(1) : endpoint].join('/');
@@ -82,39 +126,37 @@ export class Provider {
82
126
 
83
127
  const headers = { ...options.defaultHeaders, ...providerHeaders, ...options.additionnalheaders };
84
128
 
85
- let body: string | null = null;
129
+ let stringifiedBody: string | null = null;
86
130
 
87
- if (options.body) {
131
+ if (body) {
88
132
  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!
133
+ stringifiedBody = new URLSearchParams(body as Record<string, string>).toString();
90
134
  } else if (headers['Content-Type'] === 'application/json') {
91
- body = JSON.stringify(options.body);
135
+ stringifiedBody = JSON.stringify(body);
92
136
  }
93
137
  }
94
138
 
95
- const callToProvider = async () =>
96
- await fetch(absoluteUrl, {
139
+ const callToProvider = async (): Promise<Response<T>> => {
140
+ const response = await fetch(absoluteUrl, {
97
141
  method: options.method,
98
142
  headers,
99
- body: body,
143
+ body: stringifiedBody,
100
144
  });
101
145
 
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
- }
146
+ if (response.status >= 400) {
147
+ const textResult = await response.text();
148
+ throw buildHttpError(response.status, textResult);
149
+ }
109
150
 
110
- let data;
151
+ try {
152
+ const data: T | undefined = response.body ? await response.json() : undefined;
111
153
 
112
- try {
113
- data = await response.json();
114
- } catch {
115
- throwHttpError(400, 'Invalid JSON response');
116
- }
154
+ return { status: response.status, headers: response.headers, data };
155
+ } catch {
156
+ throw buildHttpError(400, 'Invalid JSON response');
157
+ }
158
+ };
117
159
 
118
- return { headers: response.headers, data };
160
+ return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
119
161
  }
120
162
  }
@@ -5,13 +5,12 @@ import * as httpErrors from '../src/httpErrors.js';
5
5
 
6
6
  describe('handleErrorResponse', () => {
7
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);
8
+ assert.ok(errors.buildHttpError(401, 'unauthorized') instanceof httpErrors.UnauthorizedError);
9
+ assert.ok(errors.buildHttpError(403, 'forbidden') instanceof httpErrors.UnauthorizedError);
10
+ assert.ok(errors.buildHttpError(404, 'not found') instanceof httpErrors.NotFoundError);
11
+ assert.ok(errors.buildHttpError(408, 'timeout') instanceof httpErrors.TimeoutError);
12
+ assert.ok(errors.buildHttpError(422, 'unprocessable entity') instanceof httpErrors.UnprocessableEntityError);
13
+ assert.ok(errors.buildHttpError(429, 'rate limit exceeded') instanceof httpErrors.RateLimitExceededError);
14
+ assert.ok(errors.buildHttpError(500, 'internal server error') instanceof httpErrors.HttpError);
16
15
  });
17
16
  });
@@ -1,7 +1,15 @@
1
1
  import { Request, Response } from 'express';
2
2
  import assert from 'node:assert/strict';
3
3
  import { afterEach, beforeEach, describe, it, mock } from 'node:test';
4
- import { Handler } from '../src/handler.js';
4
+ import {
5
+ Handler,
6
+ HandlersInput,
7
+ GetItemHandler,
8
+ GetCollectionHandler,
9
+ UpdateItemHandler,
10
+ DeleteItemHandler,
11
+ CreateItemHandler,
12
+ } from '../src/handler.js';
5
13
  import { BadRequestError } from '../src/httpErrors.js';
6
14
 
7
15
  describe('Handler', () => {
@@ -18,7 +26,7 @@ describe('Handler', () => {
18
26
  it('returns a Handler', () => {
19
27
  const itemHandler = new Handler('/', {});
20
28
 
21
- assert.equal(itemHandler instanceof Handler, true);
29
+ assert.ok(itemHandler instanceof Handler);
22
30
  });
23
31
 
24
32
  it('validates path', () => {
@@ -42,6 +50,32 @@ describe('Handler', () => {
42
50
  }
43
51
  }
44
52
  });
53
+
54
+ it('validates configuration', () => {
55
+ const getItem = (() => {}) as unknown as GetItemHandler;
56
+ const updateItem = (() => {}) as unknown as UpdateItemHandler;
57
+ const deleteItem = (() => {}) as unknown as DeleteItemHandler;
58
+ const getCollection = (() => {}) as unknown as GetCollectionHandler;
59
+ const createItem = (() => {}) as unknown as CreateItemHandler;
60
+
61
+ const paths: [string, HandlersInput, boolean][] = [
62
+ ['/foo', { getItem }, true],
63
+ ['/foo', { getCollection }, true],
64
+ ['/foo', { getItem, getCollection }, false],
65
+ ['/foo', { updateItem, createItem }, false],
66
+ ['/foo', { deleteItem, createItem }, false],
67
+ ['/foo/:bar', { getItem, getCollection }, true],
68
+ ['/foo/:bar', { getItem, updateItem, deleteItem, createItem, getCollection }, true],
69
+ ];
70
+
71
+ for (const [path, handlers, valid] of paths) {
72
+ if (valid) {
73
+ assert.doesNotThrow(() => new Handler(path, handlers));
74
+ } else {
75
+ assert.throws(() => new Handler(path, handlers));
76
+ }
77
+ }
78
+ });
45
79
  });
46
80
 
47
81
  describe('generate', () => {