@unito/integration-sdk 2.0.0 → 2.0.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/dist/src/handler.js +1 -0
- package/dist/src/index.cjs +17 -4
- package/dist/src/integration.js +3 -1
- package/dist/src/middlewares/filters.js +2 -2
- package/dist/src/middlewares/search.d.ts +10 -0
- package/dist/src/middlewares/search.js +11 -0
- package/dist/src/resources/context.d.ts +10 -0
- package/dist/src/resources/provider.js +2 -2
- package/dist/test/helpers.test.js +14 -14
- package/dist/test/middlewares/filters.test.js +4 -4
- package/dist/test/middlewares/search.test.d.ts +1 -0
- package/dist/test/middlewares/search.test.js +21 -0
- package/dist/test/resources/provider.test.js +87 -0
- package/package.json +10 -4
- package/src/handler.ts +1 -0
- package/src/integration.ts +3 -1
- package/src/middlewares/filters.ts +2 -2
- package/src/middlewares/search.ts +31 -0
- package/src/resources/context.ts +10 -0
- package/src/resources/provider.ts +2 -2
- package/test/helpers.test.ts +14 -14
- package/test/middlewares/filters.test.ts +4 -4
- package/test/middlewares/search.test.ts +34 -0
- package/test/resources/provider.test.ts +105 -0
package/dist/src/handler.js
CHANGED
package/dist/src/index.cjs
CHANGED
|
@@ -523,6 +523,7 @@ class Handler {
|
|
|
523
523
|
const collection = await handler({
|
|
524
524
|
credentials: res.locals.credentials,
|
|
525
525
|
secrets: res.locals.secrets,
|
|
526
|
+
search: res.locals.search,
|
|
526
527
|
selects: res.locals.selects,
|
|
527
528
|
filters: res.locals.filters,
|
|
528
529
|
logger: res.locals.logger,
|
|
@@ -828,7 +829,7 @@ function onError(err, _req, res, next) {
|
|
|
828
829
|
// a subset of the symbol of another operator.
|
|
829
830
|
//
|
|
830
831
|
// For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
|
|
831
|
-
const ORDERED_OPERATORS = Object.values(integrationApi.
|
|
832
|
+
const ORDERED_OPERATORS = Object.values(integrationApi.OperatorTypes).sort((o1, o2) => o2.length - o1.length);
|
|
832
833
|
function extractFilters(req, res, next) {
|
|
833
834
|
const rawFilters = req.query.filter;
|
|
834
835
|
res.locals.filters = [];
|
|
@@ -929,6 +930,17 @@ function extractSecrets(req, res, next) {
|
|
|
929
930
|
next();
|
|
930
931
|
}
|
|
931
932
|
|
|
933
|
+
function extractSearch(req, res, next) {
|
|
934
|
+
const rawSearch = req.query.search;
|
|
935
|
+
if (typeof rawSearch === 'string') {
|
|
936
|
+
res.locals.search = rawSearch;
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
res.locals.search = null;
|
|
940
|
+
}
|
|
941
|
+
next();
|
|
942
|
+
}
|
|
943
|
+
|
|
932
944
|
function extractSelects(req, res, next) {
|
|
933
945
|
const rawSelect = req.query.select;
|
|
934
946
|
if (typeof rawSelect === 'string') {
|
|
@@ -1061,7 +1073,7 @@ class Integration {
|
|
|
1061
1073
|
// Express Server initialization
|
|
1062
1074
|
const app = express();
|
|
1063
1075
|
// Parse query strings with https://github.com/ljharb/qs.
|
|
1064
|
-
app.set('query parser', '
|
|
1076
|
+
app.set('query parser', 'simple');
|
|
1065
1077
|
app.use(express.json());
|
|
1066
1078
|
// Must be one of the first handlers (to catch all the errors).
|
|
1067
1079
|
app.use(onFinish);
|
|
@@ -1081,6 +1093,7 @@ class Integration {
|
|
|
1081
1093
|
app.use(extractCredentials);
|
|
1082
1094
|
app.use(extractSecrets);
|
|
1083
1095
|
app.use(extractFilters);
|
|
1096
|
+
app.use(extractSearch);
|
|
1084
1097
|
app.use(extractSelects);
|
|
1085
1098
|
app.use(extractRelations);
|
|
1086
1099
|
app.use(extractOperationDeadline);
|
|
@@ -1393,11 +1406,11 @@ class Provider {
|
|
|
1393
1406
|
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it
|
|
1394
1407
|
body = response.body;
|
|
1395
1408
|
}
|
|
1396
|
-
else if (headers.Accept
|
|
1409
|
+
else if (headers.Accept?.match(/application\/.*json/)) {
|
|
1397
1410
|
// Validate that the response content type is at least similar to what we expect
|
|
1398
1411
|
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
1399
1412
|
// Default to application/json if no Content-Type header is provided
|
|
1400
|
-
if (responseContentType && !responseContentType.
|
|
1413
|
+
if (responseContentType && !responseContentType.match(/application\/.*json/)) {
|
|
1401
1414
|
const textResult = await response.text();
|
|
1402
1415
|
throw this.handleError(500, `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
|
|
1403
1416
|
Original response (${response.status}): "${textResult}"`, options);
|
package/dist/src/integration.js
CHANGED
|
@@ -10,6 +10,7 @@ import notFoundMiddleware from './middlewares/notFound.js';
|
|
|
10
10
|
import loggerMiddleware from './middlewares/logger.js';
|
|
11
11
|
import startMiddleware from './middlewares/start.js';
|
|
12
12
|
import secretsMiddleware from './middlewares/secrets.js';
|
|
13
|
+
import searchMiddleware from './middlewares/search.js';
|
|
13
14
|
import selectsMiddleware from './middlewares/selects.js';
|
|
14
15
|
import relationsMiddleware from './middlewares/relations.js';
|
|
15
16
|
import signalMiddleware from './middlewares/signal.js';
|
|
@@ -111,7 +112,7 @@ export default class Integration {
|
|
|
111
112
|
// Express Server initialization
|
|
112
113
|
const app = express();
|
|
113
114
|
// Parse query strings with https://github.com/ljharb/qs.
|
|
114
|
-
app.set('query parser', '
|
|
115
|
+
app.set('query parser', 'simple');
|
|
115
116
|
app.use(express.json());
|
|
116
117
|
// Must be one of the first handlers (to catch all the errors).
|
|
117
118
|
app.use(finishMiddleware);
|
|
@@ -131,6 +132,7 @@ export default class Integration {
|
|
|
131
132
|
app.use(credentialsMiddleware);
|
|
132
133
|
app.use(secretsMiddleware);
|
|
133
134
|
app.use(filtersMiddleware);
|
|
135
|
+
app.use(searchMiddleware);
|
|
134
136
|
app.use(selectsMiddleware);
|
|
135
137
|
app.use(relationsMiddleware);
|
|
136
138
|
app.use(signalMiddleware);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OperatorTypes } from '@unito/integration-api';
|
|
2
2
|
// The operators are ordered by their symbol length, in descending order.
|
|
3
3
|
// This is necessary because the symbol of an operator can be
|
|
4
4
|
// a subset of the symbol of another operator.
|
|
5
5
|
//
|
|
6
6
|
// For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
|
|
7
|
-
const ORDERED_OPERATORS = Object.values(
|
|
7
|
+
const ORDERED_OPERATORS = Object.values(OperatorTypes).sort((o1, o2) => o2.length - o1.length);
|
|
8
8
|
function extractFilters(req, res, next) {
|
|
9
9
|
const rawFilters = req.query.filter;
|
|
10
10
|
res.locals.filters = [];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
declare global {
|
|
3
|
+
namespace Express {
|
|
4
|
+
interface Locals {
|
|
5
|
+
search: string | null;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
declare function extractSearch(req: Request, res: Response, next: NextFunction): void;
|
|
10
|
+
export default extractSearch;
|
|
@@ -90,6 +90,16 @@ export type GetCollectionContext<P extends Maybe<Params> = Empty, Q extends Quer
|
|
|
90
90
|
* @see {@link Filter}
|
|
91
91
|
*/
|
|
92
92
|
filters: Filter[];
|
|
93
|
+
/**
|
|
94
|
+
* Parsed search query param yielding a free-text search query.
|
|
95
|
+
*
|
|
96
|
+
* Given a search query param:
|
|
97
|
+
* `search=John`
|
|
98
|
+
*
|
|
99
|
+
* Context.search will be:
|
|
100
|
+
* 'John'
|
|
101
|
+
*/
|
|
102
|
+
search: string | null;
|
|
93
103
|
/**
|
|
94
104
|
* Parsed select query param yielding a list of fields to select.
|
|
95
105
|
*
|
|
@@ -286,11 +286,11 @@ export class Provider {
|
|
|
286
286
|
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it
|
|
287
287
|
body = response.body;
|
|
288
288
|
}
|
|
289
|
-
else if (headers.Accept
|
|
289
|
+
else if (headers.Accept?.match(/application\/.*json/)) {
|
|
290
290
|
// Validate that the response content type is at least similar to what we expect
|
|
291
291
|
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
292
292
|
// Default to application/json if no Content-Type header is provided
|
|
293
|
-
if (responseContentType && !responseContentType.
|
|
293
|
+
if (responseContentType && !responseContentType.match(/application\/.*json/)) {
|
|
294
294
|
const textResult = await response.text();
|
|
295
295
|
throw this.handleError(500, `Unsupported content-type, expected 'application/json' but got '${responseContentType}'.
|
|
296
296
|
Original response (${response.status}): "${textResult}"`, options);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FieldValueTypes, OperatorTypes, Semantics } from '@unito/integration-api';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { describe, it } from 'node:test';
|
|
4
4
|
import { getApplicableFilters } from '../src/helpers.js';
|
|
@@ -7,48 +7,48 @@ describe('Helpers', () => {
|
|
|
7
7
|
it('returns only filters for defined fields', () => {
|
|
8
8
|
const actual = getApplicableFilters({
|
|
9
9
|
filters: [
|
|
10
|
-
{ field: 'status', operator:
|
|
11
|
-
{ field: 'email', operator:
|
|
10
|
+
{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] },
|
|
11
|
+
{ field: 'email', operator: OperatorTypes.IS_NOT_NULL, values: [] },
|
|
12
12
|
],
|
|
13
13
|
}, [
|
|
14
14
|
{
|
|
15
15
|
name: 'status',
|
|
16
16
|
label: 'Status',
|
|
17
|
-
type:
|
|
17
|
+
type: FieldValueTypes.STRING,
|
|
18
18
|
},
|
|
19
19
|
]);
|
|
20
|
-
const expected = [{ field: 'status', operator:
|
|
20
|
+
const expected = [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] }];
|
|
21
21
|
assert.deepEqual(actual, expected);
|
|
22
22
|
});
|
|
23
23
|
it('translates semantics into field names', () => {
|
|
24
24
|
const actual = getApplicableFilters({
|
|
25
25
|
filters: [
|
|
26
|
-
{ field: 'semantic:displayName', operator:
|
|
27
|
-
{ field: 'semantic:createdAt', operator:
|
|
26
|
+
{ field: 'semantic:displayName', operator: OperatorTypes.START_WITH, values: ['Bob'] },
|
|
27
|
+
{ field: 'semantic:createdAt', operator: OperatorTypes.EQUAL, values: ['2021-01-01'] },
|
|
28
28
|
],
|
|
29
29
|
}, [
|
|
30
30
|
{
|
|
31
31
|
name: 'name',
|
|
32
32
|
label: 'Name',
|
|
33
|
-
type:
|
|
34
|
-
semantic:
|
|
33
|
+
type: FieldValueTypes.STRING,
|
|
34
|
+
semantic: Semantics.DISPLAY_NAME,
|
|
35
35
|
},
|
|
36
36
|
]);
|
|
37
|
-
const expected = [{ field: 'name', operator:
|
|
37
|
+
const expected = [{ field: 'name', operator: OperatorTypes.START_WITH, values: ['Bob'] }];
|
|
38
38
|
assert.deepEqual(actual, expected);
|
|
39
39
|
});
|
|
40
40
|
it('gracefully handle garbage', () => {
|
|
41
41
|
const actual = getApplicableFilters({
|
|
42
42
|
filters: [
|
|
43
|
-
{ field: '...', operator:
|
|
44
|
-
{ field: ':', operator:
|
|
45
|
-
{ field: '', operator:
|
|
43
|
+
{ field: '...', operator: OperatorTypes.EQUAL, values: [] },
|
|
44
|
+
{ field: ':', operator: OperatorTypes.EQUAL, values: [] },
|
|
45
|
+
{ field: '', operator: OperatorTypes.EQUAL, values: [] },
|
|
46
46
|
],
|
|
47
47
|
}, [
|
|
48
48
|
{
|
|
49
49
|
name: 'status',
|
|
50
50
|
label: 'Status',
|
|
51
|
-
type:
|
|
51
|
+
type: FieldValueTypes.STRING,
|
|
52
52
|
},
|
|
53
53
|
]);
|
|
54
54
|
assert.deepEqual(actual, []);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OperatorTypes } from '@unito/integration-api';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { describe, it } from 'node:test';
|
|
4
4
|
import extractFilters from '../../src/middlewares/filters.js';
|
|
5
5
|
describe('filters middleware', () => {
|
|
6
6
|
it('properly parse operators', () => {
|
|
7
|
-
Object.values(
|
|
7
|
+
Object.values(OperatorTypes).forEach(operator => {
|
|
8
8
|
const request = {
|
|
9
9
|
query: { filter: `aKey${operator}value` },
|
|
10
10
|
};
|
|
@@ -20,7 +20,7 @@ describe('filters middleware', () => {
|
|
|
20
20
|
const response = { locals: {} };
|
|
21
21
|
extractFilters(request, response, () => { });
|
|
22
22
|
assert.deepEqual(response.locals, {
|
|
23
|
-
filters: [{ field: 'aKey', operator:
|
|
23
|
+
filters: [{ field: 'aKey', operator: OperatorTypes.IS_NULL, values: [] }],
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
it('decodes URI components', () => {
|
|
@@ -28,7 +28,7 @@ describe('filters middleware', () => {
|
|
|
28
28
|
const response = { locals: {} };
|
|
29
29
|
extractFilters(request, response, () => { });
|
|
30
30
|
assert.deepEqual(response.locals, {
|
|
31
|
-
filters: [{ field: 'status', operator:
|
|
31
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['foo,bar!!,?baz=!>qux'] }],
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
it('no data', () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import extractSearch from '../../src/middlewares/search.js';
|
|
4
|
+
describe('search middleware', () => {
|
|
5
|
+
it('data', () => {
|
|
6
|
+
const request = { query: { search: 'foo bar spam baz' } };
|
|
7
|
+
const response = { locals: {} };
|
|
8
|
+
extractSearch(request, response, () => { });
|
|
9
|
+
assert.deepEqual(response.locals, {
|
|
10
|
+
search: 'foo bar spam baz',
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it('no data', () => {
|
|
14
|
+
const request = { query: {} };
|
|
15
|
+
const response = { locals: {} };
|
|
16
|
+
extractSearch(request, response, () => { });
|
|
17
|
+
assert.deepEqual(response.locals, {
|
|
18
|
+
search: null,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -79,6 +79,93 @@ describe('Provider', () => {
|
|
|
79
79
|
]);
|
|
80
80
|
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: '' });
|
|
81
81
|
});
|
|
82
|
+
it('should accept application/schema+json type response', async (context) => {
|
|
83
|
+
const response = new Response('{"data": "value"}', {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { 'Content-Type': 'application/schema+json; charset=UTF-8' },
|
|
86
|
+
});
|
|
87
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
88
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
89
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
90
|
+
logger: logger,
|
|
91
|
+
signal: new AbortController().signal,
|
|
92
|
+
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/schema+json; charset=UTF-8' },
|
|
93
|
+
});
|
|
94
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
95
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
96
|
+
'www.myApi.com/endpoint',
|
|
97
|
+
{
|
|
98
|
+
method: 'GET',
|
|
99
|
+
body: null,
|
|
100
|
+
signal: new AbortController().signal,
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: 'application/schema+json; charset=UTF-8',
|
|
103
|
+
'X-Custom-Provider-Header': 'value',
|
|
104
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
105
|
+
'X-Additional-Header': 'value1',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
110
|
+
});
|
|
111
|
+
it('should accept application/swagger+json type response', async (context) => {
|
|
112
|
+
const response = new Response('{"data": "value"}', {
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { 'Content-Type': 'application/swagger+json; charset=UTF-8' },
|
|
115
|
+
});
|
|
116
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
117
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
118
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
119
|
+
logger: logger,
|
|
120
|
+
signal: new AbortController().signal,
|
|
121
|
+
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/swagger+json; charset=UTF-8' },
|
|
122
|
+
});
|
|
123
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
124
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
125
|
+
'www.myApi.com/endpoint',
|
|
126
|
+
{
|
|
127
|
+
method: 'GET',
|
|
128
|
+
body: null,
|
|
129
|
+
signal: new AbortController().signal,
|
|
130
|
+
headers: {
|
|
131
|
+
Accept: 'application/swagger+json; charset=UTF-8',
|
|
132
|
+
'X-Custom-Provider-Header': 'value',
|
|
133
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
134
|
+
'X-Additional-Header': 'value1',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
139
|
+
});
|
|
140
|
+
it('should accept application/vnd.oracle.resource+json type response', async (context) => {
|
|
141
|
+
const response = new Response('{"data": "value"}', {
|
|
142
|
+
status: 200,
|
|
143
|
+
headers: { 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' },
|
|
144
|
+
});
|
|
145
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
146
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
147
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
148
|
+
logger: logger,
|
|
149
|
+
signal: new AbortController().signal,
|
|
150
|
+
additionnalheaders: { 'X-Additional-Header': 'value1' },
|
|
151
|
+
});
|
|
152
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
153
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
154
|
+
'www.myApi.com/endpoint',
|
|
155
|
+
{
|
|
156
|
+
method: 'GET',
|
|
157
|
+
body: null,
|
|
158
|
+
signal: new AbortController().signal,
|
|
159
|
+
headers: {
|
|
160
|
+
Accept: 'application/json',
|
|
161
|
+
'X-Custom-Provider-Header': 'value',
|
|
162
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
163
|
+
'X-Additional-Header': 'value1',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
168
|
+
});
|
|
82
169
|
it('should return the raw response body if specified', async (context) => {
|
|
83
170
|
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
|
|
84
171
|
status: 200,
|
package/package.json
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unito/integration-sdk",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Integration SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/src/index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
"./package.json": "./package.json",
|
|
9
9
|
".": {
|
|
10
|
-
"import":
|
|
11
|
-
|
|
10
|
+
"import": {
|
|
11
|
+
"types": "./dist/src/index.d.ts",
|
|
12
|
+
"default": "./dist/src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"require": {
|
|
15
|
+
"types": "./dist/src/index.d.ts",
|
|
16
|
+
"default": "./dist/src/index.cjs"
|
|
17
|
+
}
|
|
12
18
|
}
|
|
13
19
|
},
|
|
14
20
|
"license": "LicenseRef-LICENSE",
|
|
@@ -46,7 +52,7 @@
|
|
|
46
52
|
"typescript": "5.x"
|
|
47
53
|
},
|
|
48
54
|
"dependencies": {
|
|
49
|
-
"@unito/integration-api": "
|
|
55
|
+
"@unito/integration-api": "4.x",
|
|
50
56
|
"busboy": "^1.6.0",
|
|
51
57
|
"cachette": "2.x",
|
|
52
58
|
"express": "^5.x",
|
package/src/handler.ts
CHANGED
|
@@ -297,6 +297,7 @@ export class Handler {
|
|
|
297
297
|
const collection = await handler({
|
|
298
298
|
credentials: res.locals.credentials,
|
|
299
299
|
secrets: res.locals.secrets,
|
|
300
|
+
search: res.locals.search,
|
|
300
301
|
selects: res.locals.selects,
|
|
301
302
|
filters: res.locals.filters,
|
|
302
303
|
logger: res.locals.logger,
|
package/src/integration.ts
CHANGED
|
@@ -12,6 +12,7 @@ import notFoundMiddleware from './middlewares/notFound.js';
|
|
|
12
12
|
import loggerMiddleware from './middlewares/logger.js';
|
|
13
13
|
import startMiddleware from './middlewares/start.js';
|
|
14
14
|
import secretsMiddleware from './middlewares/secrets.js';
|
|
15
|
+
import searchMiddleware from './middlewares/search.js';
|
|
15
16
|
import selectsMiddleware from './middlewares/selects.js';
|
|
16
17
|
import relationsMiddleware from './middlewares/relations.js';
|
|
17
18
|
import signalMiddleware from './middlewares/signal.js';
|
|
@@ -124,7 +125,7 @@ export default class Integration {
|
|
|
124
125
|
const app: express.Application = express();
|
|
125
126
|
|
|
126
127
|
// Parse query strings with https://github.com/ljharb/qs.
|
|
127
|
-
app.set('query parser', '
|
|
128
|
+
app.set('query parser', 'simple');
|
|
128
129
|
|
|
129
130
|
app.use(express.json());
|
|
130
131
|
|
|
@@ -149,6 +150,7 @@ export default class Integration {
|
|
|
149
150
|
app.use(credentialsMiddleware);
|
|
150
151
|
app.use(secretsMiddleware);
|
|
151
152
|
app.use(filtersMiddleware);
|
|
153
|
+
app.use(searchMiddleware);
|
|
152
154
|
app.use(selectsMiddleware);
|
|
153
155
|
app.use(relationsMiddleware);
|
|
154
156
|
app.use(signalMiddleware);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { OperatorType } from '@unito/integration-api';
|
|
1
|
+
import { OperatorType, OperatorTypes } from '@unito/integration-api';
|
|
2
2
|
import { Request, Response, NextFunction } from 'express';
|
|
3
3
|
|
|
4
4
|
declare global {
|
|
@@ -32,7 +32,7 @@ export type Filter = {
|
|
|
32
32
|
// a subset of the symbol of another operator.
|
|
33
33
|
//
|
|
34
34
|
// For example, the symbol "=" (EQUAL) is a subset of the symbol "!=" (NOT_EQUAL).
|
|
35
|
-
const ORDERED_OPERATORS = Object.values(
|
|
35
|
+
const ORDERED_OPERATORS = Object.values(OperatorTypes).sort((o1, o2) => o2.length - o1.length);
|
|
36
36
|
|
|
37
37
|
function extractFilters(req: Request, res: Response, next: NextFunction) {
|
|
38
38
|
const rawFilters = req.query.filter;
|
|
@@ -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
|
+
// search=foo
|
|
10
|
+
//
|
|
11
|
+
// ... it becomes available as follow in handlers:
|
|
12
|
+
//
|
|
13
|
+
// 'foo'
|
|
14
|
+
search: string | null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractSearch(req: Request, res: Response, next: NextFunction) {
|
|
20
|
+
const rawSearch = req.query.search;
|
|
21
|
+
|
|
22
|
+
if (typeof rawSearch === 'string') {
|
|
23
|
+
res.locals.search = rawSearch;
|
|
24
|
+
} else {
|
|
25
|
+
res.locals.search = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
next();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default extractSearch;
|
package/src/resources/context.ts
CHANGED
|
@@ -96,6 +96,16 @@ export type GetCollectionContext<P extends Maybe<Params> = Empty, Q extends Quer
|
|
|
96
96
|
* @see {@link Filter}
|
|
97
97
|
*/
|
|
98
98
|
filters: Filter[];
|
|
99
|
+
/**
|
|
100
|
+
* Parsed search query param yielding a free-text search query.
|
|
101
|
+
*
|
|
102
|
+
* Given a search query param:
|
|
103
|
+
* `search=John`
|
|
104
|
+
*
|
|
105
|
+
* Context.search will be:
|
|
106
|
+
* 'John'
|
|
107
|
+
*/
|
|
108
|
+
search: string | null;
|
|
99
109
|
/**
|
|
100
110
|
* Parsed select query param yielding a list of fields to select.
|
|
101
111
|
*
|
|
@@ -402,11 +402,11 @@ export class Provider {
|
|
|
402
402
|
if (options.rawBody || headers.Accept === 'application/octet-stream') {
|
|
403
403
|
// When we expect octet-stream, we accept any Content-Type the provider sends us, we just want to stream it
|
|
404
404
|
body = response.body as T;
|
|
405
|
-
} else if (headers.Accept
|
|
405
|
+
} else if (headers.Accept?.match(/application\/.*json/)) {
|
|
406
406
|
// Validate that the response content type is at least similar to what we expect
|
|
407
407
|
// (Provider's response Content-Type might be more specific, e.g. application/json;charset=utf-8)
|
|
408
408
|
// Default to application/json if no Content-Type header is provided
|
|
409
|
-
if (responseContentType && !responseContentType.
|
|
409
|
+
if (responseContentType && !responseContentType.match(/application\/.*json/)) {
|
|
410
410
|
const textResult = await response.text();
|
|
411
411
|
throw this.handleError(
|
|
412
412
|
500,
|
package/test/helpers.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FieldValueTypes, OperatorTypes, Semantics } from '@unito/integration-api';
|
|
2
2
|
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { describe, it } from 'node:test';
|
|
@@ -10,20 +10,20 @@ describe('Helpers', () => {
|
|
|
10
10
|
const actual = getApplicableFilters(
|
|
11
11
|
{
|
|
12
12
|
filters: [
|
|
13
|
-
{ field: 'status', operator:
|
|
14
|
-
{ field: 'email', operator:
|
|
13
|
+
{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] },
|
|
14
|
+
{ field: 'email', operator: OperatorTypes.IS_NOT_NULL, values: [] },
|
|
15
15
|
],
|
|
16
16
|
},
|
|
17
17
|
[
|
|
18
18
|
{
|
|
19
19
|
name: 'status',
|
|
20
20
|
label: 'Status',
|
|
21
|
-
type:
|
|
21
|
+
type: FieldValueTypes.STRING,
|
|
22
22
|
},
|
|
23
23
|
],
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
const expected = [{ field: 'status', operator:
|
|
26
|
+
const expected = [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] }];
|
|
27
27
|
|
|
28
28
|
assert.deepEqual(actual, expected);
|
|
29
29
|
});
|
|
@@ -32,21 +32,21 @@ describe('Helpers', () => {
|
|
|
32
32
|
const actual = getApplicableFilters(
|
|
33
33
|
{
|
|
34
34
|
filters: [
|
|
35
|
-
{ field: 'semantic:displayName', operator:
|
|
36
|
-
{ field: 'semantic:createdAt', operator:
|
|
35
|
+
{ field: 'semantic:displayName', operator: OperatorTypes.START_WITH, values: ['Bob'] },
|
|
36
|
+
{ field: 'semantic:createdAt', operator: OperatorTypes.EQUAL, values: ['2021-01-01'] },
|
|
37
37
|
],
|
|
38
38
|
},
|
|
39
39
|
[
|
|
40
40
|
{
|
|
41
41
|
name: 'name',
|
|
42
42
|
label: 'Name',
|
|
43
|
-
type:
|
|
44
|
-
semantic:
|
|
43
|
+
type: FieldValueTypes.STRING,
|
|
44
|
+
semantic: Semantics.DISPLAY_NAME,
|
|
45
45
|
},
|
|
46
46
|
],
|
|
47
47
|
);
|
|
48
48
|
|
|
49
|
-
const expected = [{ field: 'name', operator:
|
|
49
|
+
const expected = [{ field: 'name', operator: OperatorTypes.START_WITH, values: ['Bob'] }];
|
|
50
50
|
|
|
51
51
|
assert.deepEqual(actual, expected);
|
|
52
52
|
});
|
|
@@ -55,16 +55,16 @@ describe('Helpers', () => {
|
|
|
55
55
|
const actual = getApplicableFilters(
|
|
56
56
|
{
|
|
57
57
|
filters: [
|
|
58
|
-
{ field: '...', operator:
|
|
59
|
-
{ field: ':', operator:
|
|
60
|
-
{ field: '', operator:
|
|
58
|
+
{ field: '...', operator: OperatorTypes.EQUAL, values: [] },
|
|
59
|
+
{ field: ':', operator: OperatorTypes.EQUAL, values: [] },
|
|
60
|
+
{ field: '', operator: OperatorTypes.EQUAL, values: [] },
|
|
61
61
|
],
|
|
62
62
|
},
|
|
63
63
|
[
|
|
64
64
|
{
|
|
65
65
|
name: 'status',
|
|
66
66
|
label: 'Status',
|
|
67
|
-
type:
|
|
67
|
+
type: FieldValueTypes.STRING,
|
|
68
68
|
},
|
|
69
69
|
],
|
|
70
70
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OperatorTypes } from '@unito/integration-api';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { describe, it } from 'node:test';
|
|
@@ -6,7 +6,7 @@ import extractFilters from '../../src/middlewares/filters.js';
|
|
|
6
6
|
|
|
7
7
|
describe('filters middleware', () => {
|
|
8
8
|
it('properly parse operators', () => {
|
|
9
|
-
Object.values(
|
|
9
|
+
Object.values(OperatorTypes).forEach(operator => {
|
|
10
10
|
const request = {
|
|
11
11
|
query: { filter: `aKey${operator}value` },
|
|
12
12
|
} as express.Request<
|
|
@@ -41,7 +41,7 @@ describe('filters middleware', () => {
|
|
|
41
41
|
extractFilters(request, response, () => {});
|
|
42
42
|
|
|
43
43
|
assert.deepEqual(response.locals, {
|
|
44
|
-
filters: [{ field: 'aKey', operator:
|
|
44
|
+
filters: [{ field: 'aKey', operator: OperatorTypes.IS_NULL, values: [] }],
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
@@ -58,7 +58,7 @@ describe('filters middleware', () => {
|
|
|
58
58
|
extractFilters(request, response, () => {});
|
|
59
59
|
|
|
60
60
|
assert.deepEqual(response.locals, {
|
|
61
|
-
filters: [{ field: 'status', operator:
|
|
61
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['foo,bar!!,?baz=!>qux'] }],
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import extractSearch from '../../src/middlewares/search.js';
|
|
5
|
+
|
|
6
|
+
describe('search middleware', () => {
|
|
7
|
+
it('data', () => {
|
|
8
|
+
const request = { query: { search: 'foo bar spam baz' } } as express.Request<
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
any,
|
|
11
|
+
object,
|
|
12
|
+
object,
|
|
13
|
+
{ search: string }
|
|
14
|
+
>;
|
|
15
|
+
const response = { locals: {} } as express.Response;
|
|
16
|
+
|
|
17
|
+
extractSearch(request, response, () => {});
|
|
18
|
+
|
|
19
|
+
assert.deepEqual(response.locals, {
|
|
20
|
+
search: 'foo bar spam baz',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('no data', () => {
|
|
25
|
+
const request = { query: {} } as express.Request;
|
|
26
|
+
const response = { locals: {} } as express.Response;
|
|
27
|
+
|
|
28
|
+
extractSearch(request, response, () => {});
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(response.locals, {
|
|
31
|
+
search: null,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -94,6 +94,111 @@ describe('Provider', () => {
|
|
|
94
94
|
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: '' });
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it('should accept application/schema+json type response', async context => {
|
|
98
|
+
const response = new Response('{"data": "value"}', {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { 'Content-Type': 'application/schema+json; charset=UTF-8' },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
104
|
+
|
|
105
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
106
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
107
|
+
logger: logger,
|
|
108
|
+
signal: new AbortController().signal,
|
|
109
|
+
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/schema+json; charset=UTF-8' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
115
|
+
'www.myApi.com/endpoint',
|
|
116
|
+
{
|
|
117
|
+
method: 'GET',
|
|
118
|
+
body: null,
|
|
119
|
+
signal: new AbortController().signal,
|
|
120
|
+
headers: {
|
|
121
|
+
Accept: 'application/schema+json; charset=UTF-8',
|
|
122
|
+
'X-Custom-Provider-Header': 'value',
|
|
123
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
124
|
+
'X-Additional-Header': 'value1',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should accept application/swagger+json type response', async context => {
|
|
133
|
+
const response = new Response('{"data": "value"}', {
|
|
134
|
+
status: 200,
|
|
135
|
+
headers: { 'Content-Type': 'application/swagger+json; charset=UTF-8' },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
139
|
+
|
|
140
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
141
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
142
|
+
logger: logger,
|
|
143
|
+
signal: new AbortController().signal,
|
|
144
|
+
additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/swagger+json; charset=UTF-8' },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
150
|
+
'www.myApi.com/endpoint',
|
|
151
|
+
{
|
|
152
|
+
method: 'GET',
|
|
153
|
+
body: null,
|
|
154
|
+
signal: new AbortController().signal,
|
|
155
|
+
headers: {
|
|
156
|
+
Accept: 'application/swagger+json; charset=UTF-8',
|
|
157
|
+
'X-Custom-Provider-Header': 'value',
|
|
158
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
159
|
+
'X-Additional-Header': 'value1',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should accept application/vnd.oracle.resource+json type response', async context => {
|
|
168
|
+
const response = new Response('{"data": "value"}', {
|
|
169
|
+
status: 200,
|
|
170
|
+
headers: { 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
|
|
174
|
+
|
|
175
|
+
const actualResponse = await provider.get('/endpoint', {
|
|
176
|
+
credentials: { apiKey: 'apikey#1111' },
|
|
177
|
+
logger: logger,
|
|
178
|
+
signal: new AbortController().signal,
|
|
179
|
+
additionnalheaders: { 'X-Additional-Header': 'value1' },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(fetchMock.mock.calls.length, 1);
|
|
183
|
+
|
|
184
|
+
assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
|
|
185
|
+
'www.myApi.com/endpoint',
|
|
186
|
+
{
|
|
187
|
+
method: 'GET',
|
|
188
|
+
body: null,
|
|
189
|
+
signal: new AbortController().signal,
|
|
190
|
+
headers: {
|
|
191
|
+
Accept: 'application/json',
|
|
192
|
+
'X-Custom-Provider-Header': 'value',
|
|
193
|
+
'X-Provider-Credential-Header': 'apikey#1111',
|
|
194
|
+
'X-Additional-Header': 'value1',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
|
|
200
|
+
});
|
|
201
|
+
|
|
97
202
|
it('should return the raw response body if specified', async context => {
|
|
98
203
|
const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
|
|
99
204
|
status: 200,
|