@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.
- package/README.md +1 -1
- package/dist/src/errors.d.ts +3 -2
- package/dist/src/errors.js +11 -9
- package/dist/src/handler.js +12 -1
- package/dist/src/httpErrors.d.ts +3 -0
- package/dist/src/httpErrors.js +6 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -0
- package/dist/src/middlewares/credentials.d.ts +4 -1
- package/dist/src/resources/provider.d.ts +35 -12
- package/dist/src/resources/provider.js +59 -34
- package/dist/test/errors.test.js +7 -8
- package/dist/test/handler.test.js +26 -2
- package/dist/test/resources/provider.test.d.ts +1 -0
- package/dist/test/resources/provider.test.js +229 -0
- package/package.json +1 -3
- package/src/errors.ts +12 -9
- package/src/handler.ts +18 -1
- package/src/httpErrors.ts +7 -1
- package/src/index.ts +4 -0
- package/src/middlewares/credentials.ts +1 -1
- package/src/resources/provider.ts +89 -47
- package/test/errors.test.ts +7 -8
- package/test/handler.test.ts +36 -2
- package/test/resources/provider.test.ts +285 -0
- package/dist/src/api/index.d.ts +0 -2
- package/dist/src/api/index.d.ts.map +0 -1
- package/dist/src/api/index.js +0 -2
- package/dist/src/api/index.js.map +0 -1
- package/dist/src/app/errors/HTTPError.d.ts +0 -5
- package/dist/src/app/errors/HTTPError.d.ts.map +0 -1
- package/dist/src/app/errors/HTTPError.js +0 -8
- package/dist/src/app/errors/HTTPError.js.map +0 -1
- package/dist/src/app/errors/HTTPNotFoundError.d.ts +0 -5
- package/dist/src/app/errors/HTTPNotFoundError.d.ts.map +0 -1
- package/dist/src/app/errors/HTTPNotFoundError.js +0 -7
- package/dist/src/app/errors/HTTPNotFoundError.js.map +0 -1
- package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts +0 -5
- package/dist/src/app/errors/HTTPUnprocessableEntityError.d.ts.map +0 -1
- package/dist/src/app/errors/HTTPUnprocessableEntityError.js +0 -7
- package/dist/src/app/errors/HTTPUnprocessableEntityError.js.map +0 -1
- package/dist/src/app/errors/index.d.ts +0 -4
- package/dist/src/app/errors/index.d.ts.map +0 -1
- package/dist/src/app/errors/index.js +0 -4
- package/dist/src/app/errors/index.js.map +0 -1
- package/dist/src/app/index.d.ts +0 -6
- package/dist/src/app/index.d.ts.map +0 -1
- package/dist/src/app/index.js +0 -80
- package/dist/src/app/index.js.map +0 -1
- package/dist/src/app/integration.d.ts +0 -5
- package/dist/src/app/integration.d.ts.map +0 -1
- package/dist/src/app/integration.js +0 -86
- package/dist/src/app/integration.js.map +0 -1
- package/dist/src/app/itemNode.d.ts +0 -8
- package/dist/src/app/itemNode.d.ts.map +0 -1
- package/dist/src/app/itemNode.js +0 -13
- package/dist/src/app/itemNode.js.map +0 -1
- package/dist/src/app/middlewares/withCorrelationId.d.ts +0 -11
- package/dist/src/app/middlewares/withCorrelationId.d.ts.map +0 -1
- package/dist/src/app/middlewares/withCorrelationId.js +0 -8
- package/dist/src/app/middlewares/withCorrelationId.js.map +0 -1
- package/dist/src/app/middlewares/withLogger.d.ts +0 -12
- package/dist/src/app/middlewares/withLogger.d.ts.map +0 -1
- package/dist/src/app/middlewares/withLogger.js +0 -18
- package/dist/src/app/middlewares/withLogger.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/resources/cache.d.ts.map +0 -1
- package/dist/src/resources/cache.js.map +0 -1
- package/dist/src/resources/logger.d.ts.map +0 -1
- package/dist/src/resources/logger.js.map +0 -1
- 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¶m2=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.
|
|
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
|
|
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
|
|
18
|
+
export function buildHttpError(responseStatus: number, message: string): HttpErrors.HttpError {
|
|
19
|
+
let httpError: HttpErrors.HttpError;
|
|
19
20
|
if (responseStatus === 400) {
|
|
20
|
-
|
|
21
|
+
httpError = new HttpErrors.BadRequestError(message);
|
|
21
22
|
} else if (responseStatus === 401 || responseStatus === 403) {
|
|
22
|
-
|
|
23
|
+
httpError = new HttpErrors.UnauthorizedError(message);
|
|
23
24
|
} else if (responseStatus === 404) {
|
|
24
|
-
|
|
25
|
+
httpError = new HttpErrors.NotFoundError(message);
|
|
25
26
|
} else if (responseStatus === 408) {
|
|
26
|
-
|
|
27
|
+
httpError = new HttpErrors.TimeoutError(message);
|
|
27
28
|
} else if (responseStatus === 422) {
|
|
28
|
-
|
|
29
|
+
httpError = new HttpErrors.UnprocessableEntityError(message);
|
|
29
30
|
} else if (responseStatus === 429) {
|
|
30
|
-
|
|
31
|
+
httpError = new HttpErrors.RateLimitExceededError(message);
|
|
31
32
|
} else {
|
|
32
|
-
|
|
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 || '
|
|
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 */
|
|
@@ -1,38 +1,59 @@
|
|
|
1
|
-
import {
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
129
|
+
let stringifiedBody: string | null = null;
|
|
86
130
|
|
|
87
|
-
if (
|
|
131
|
+
if (body) {
|
|
88
132
|
if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
|
|
89
|
-
|
|
133
|
+
stringifiedBody = new URLSearchParams(body as Record<string, string>).toString();
|
|
90
134
|
} else if (headers['Content-Type'] === 'application/json') {
|
|
91
|
-
|
|
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:
|
|
143
|
+
body: stringifiedBody,
|
|
100
144
|
});
|
|
101
145
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
151
|
+
try {
|
|
152
|
+
const data: T | undefined = response.body ? await response.json() : undefined;
|
|
111
153
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
160
|
+
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
119
161
|
}
|
|
120
162
|
}
|
package/test/errors.test.ts
CHANGED
|
@@ -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.
|
|
9
|
-
assert.
|
|
10
|
-
assert.
|
|
11
|
-
assert.
|
|
12
|
-
assert.
|
|
13
|
-
assert.
|
|
14
|
-
assert.
|
|
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
|
});
|
package/test/handler.test.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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', () => {
|