@unito/integration-sdk 2.4.4 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +80 -0
- package/dist/src/helpers.d.ts +14 -0
- package/dist/src/helpers.js +20 -0
- package/dist/src/index.cjs +37 -4
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/resources/provider.d.ts +1 -2
- package/dist/src/resources/provider.js +16 -4
- package/dist/test/helpers.test.js +95 -1
- package/dist/test/resources/provider.test.js +100 -40
- package/package.json +1 -1
- package/src/helpers.ts +27 -0
- package/src/index.ts +1 -1
- package/src/resources/provider.ts +21 -6
- package/test/helpers.test.ts +110 -1
- package/test/resources/provider.test.ts +100 -40
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What This Is
|
|
6
|
+
|
|
7
|
+
`@unito/integration-sdk` — a TypeScript framework for building REST API integrations compatible with the Unito Integration API Specification. It wraps Express 5 and provides handler routing, middleware chains for request parsing, structured logging, a Provider HTTP client for downstream APIs, and standardized error handling.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install # Install deps (also runs compile via prepare hook)
|
|
13
|
+
npm run compile # Build ESM (tsc) + CJS (rollup)
|
|
14
|
+
npm run compile:watch # Watch mode recompile
|
|
15
|
+
npm test # Run all tests
|
|
16
|
+
ONLY=Handler npm test # Run tests matching pattern "Handler"
|
|
17
|
+
npm run test:debug # Run tests with --inspect-brk
|
|
18
|
+
npm run lint # ESLint --fix + Prettier --write on src/ and test/
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Tests use **Node.js built-in test runner** (`node:test` + `node:assert/strict`) with `ts-node/esm` loader — no compilation needed to run tests. HTTP mocking uses `nock`.
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
### Request Flow
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
HTTP Request
|
|
29
|
+
→ finish (error/log hooks on response end)
|
|
30
|
+
→ express.json (1MB limit)
|
|
31
|
+
→ start (records hrtime)
|
|
32
|
+
→ correlationId (generates/extracts X-Correlation-Id)
|
|
33
|
+
→ logger (structured logger decorated with correlation ID)
|
|
34
|
+
→ credentials (decodes base64 JSON from X-Unito-Credentials header)
|
|
35
|
+
→ secrets (decodes base64 JSON from X-Unito-Secrets header)
|
|
36
|
+
→ filters / search / selects / relations (parse query params)
|
|
37
|
+
→ signal (AbortSignal from X-Unito-Operation-Deadline timestamp)
|
|
38
|
+
→ Handler routes (user-registered)
|
|
39
|
+
→ errors middleware (catches, formats HttpError responses)
|
|
40
|
+
→ notFound middleware (404 fallback)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Core Classes
|
|
44
|
+
|
|
45
|
+
**Integration** (`src/integration.ts`) — entry point. Call `addHandler(path, handlers)` to register routes, then `start()`. All handlers must be added before `start()` — the server exits on violation.
|
|
46
|
+
|
|
47
|
+
**Handler** (`src/handler.ts`) — takes a path and a handlers object, generates an Express Router. Path determines routing:
|
|
48
|
+
- `/things/:thingId` — last segment is `:variable` → item route. `getItem` at `GET /things/:thingId`, `getCollection` at `GET /things`, `createItem` at `POST /things`, etc.
|
|
49
|
+
- `/things` — no trailing variable → either item-level or collection-level ops, but not both on the same path.
|
|
50
|
+
|
|
51
|
+
Handler types (pick one per `addHandler` call):
|
|
52
|
+
- `ItemHandlers` — getItem, getCollection, createItem, updateItem, deleteItem
|
|
53
|
+
- `BlobHandlers` — getBlob, createBlob
|
|
54
|
+
- `CredentialAccountHandlers` — getCredentialAccount
|
|
55
|
+
- `ParseWebhookHandlers`, `WebhookSubscriptionHandlers`, `AcknowledgeWebhookHandlers`
|
|
56
|
+
|
|
57
|
+
**Provider** (`src/resources/provider.ts`) — HTTP client for calling downstream provider APIs. Initialized with `prepareRequest` (returns base URL + auth headers), optional `rateLimiter`, and optional `customErrorHandler`. Methods: `get`, `post`, `put`, `patch`, `delete`, `streamingGet`, `postForm`, `postStream`, `putBuffer`.
|
|
58
|
+
|
|
59
|
+
**Context** (`src/resources/context.ts`) — typed context object passed to every handler. Contains `credentials`, `secrets`, `logger`, `signal`, `params`, `query`. GetCollectionContext additionally has `filters`, `search`, `selects`, `relations`. Create/Update contexts have `body`.
|
|
60
|
+
|
|
61
|
+
**HttpErrors** (`src/httpErrors.ts`) — throw these from handlers for proper error responses. BadRequestError(400), UnauthorizedError(401), ForbiddenError(403), NotFoundError(404), TimeoutError(408), UnprocessableEntityError(422), ProviderInstanceLockedError(423), RateLimitExceededError(429). The latter two support `retryAfter`.
|
|
62
|
+
|
|
63
|
+
### Logging
|
|
64
|
+
|
|
65
|
+
Logger (`src/resources/logger.ts`) produces Datadog-compatible structured JSON in production, colored output in dev. Keys are auto-converted to snake_case. Sensitive fields (access_token, password, email, etc.) are auto-redacted. Log size truncated at 20KB (configurable via `MAX_LOG_MESSAGE_SIZE` env var).
|
|
66
|
+
|
|
67
|
+
### Cache
|
|
68
|
+
|
|
69
|
+
`Cache.create(redisUrl?)` returns a Redis-backed or local in-memory cache via the `cachette` library.
|
|
70
|
+
|
|
71
|
+
## Code Conventions
|
|
72
|
+
|
|
73
|
+
- **ES Modules** — `"type": "module"` in package.json. All internal imports use `.js` extensions (`import X from './foo.js'`).
|
|
74
|
+
- **Dual output** — ESM via `tsc`, CJS via Rollup. Published with conditional exports.
|
|
75
|
+
- **Strict TypeScript** — strict mode, noUncheckedIndexedAccess, exactOptionalPropertyTypes, no unused locals/params.
|
|
76
|
+
- **Branded types** — e.g. `type Path = string & { __brand: 'Path' }` with assertion functions (`asserts path is Path`).
|
|
77
|
+
- **No barrel files** — import directly from source modules, not from index re-exports.
|
|
78
|
+
- **Prettier** — single quotes, trailing commas, 120 char width, no parens on single-param arrows.
|
|
79
|
+
- **Test files** — `test/**/*.test.ts` mirroring src structure. Use `describe`/`it`/`beforeEach`/`afterEach` from `node:test`, assertions from `node:assert/strict`, mocking with `mock.method()`.
|
|
80
|
+
- **Express typing** — `res.locals` is typed via global Express namespace augmentation.
|
package/dist/src/helpers.d.ts
CHANGED
|
@@ -13,3 +13,17 @@ import { Filter } from './index.js';
|
|
|
13
13
|
export declare function getApplicableFilters(context: {
|
|
14
14
|
filters: Filter[];
|
|
15
15
|
}, fields: FieldSchema[]): Filter[];
|
|
16
|
+
/**
|
|
17
|
+
* Use this helper function to build the query params for a collection next page URL.
|
|
18
|
+
* It re-encodes the parsed filters and selects back into query-param form,
|
|
19
|
+
* so integrations can easily construct `nextPage` URLs.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const params = buildCollectionQueryParams({ filters: context.filters, selects: context.selects });
|
|
23
|
+
* params.append('cursor', nextCursor);
|
|
24
|
+
* return { info: { nextPage: `/items?${params.toString()}` }, data: [...] };
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildCollectionQueryParams(params?: {
|
|
27
|
+
filters?: Filter[];
|
|
28
|
+
selects?: string[];
|
|
29
|
+
}): URLSearchParams;
|
package/dist/src/helpers.js
CHANGED
|
@@ -26,3 +26,23 @@ export function getApplicableFilters(context, fields) {
|
|
|
26
26
|
}
|
|
27
27
|
return applicableFilters;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Use this helper function to build the query params for a collection next page URL.
|
|
31
|
+
* It re-encodes the parsed filters and selects back into query-param form,
|
|
32
|
+
* so integrations can easily construct `nextPage` URLs.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const params = buildCollectionQueryParams({ filters: context.filters, selects: context.selects });
|
|
36
|
+
* params.append('cursor', nextCursor);
|
|
37
|
+
* return { info: { nextPage: `/items?${params.toString()}` }, data: [...] };
|
|
38
|
+
*/
|
|
39
|
+
export function buildCollectionQueryParams(params) {
|
|
40
|
+
const result = new URLSearchParams();
|
|
41
|
+
if (params?.filters?.length) {
|
|
42
|
+
result.set('filter', params.filters.map(f => `${f.field}${f.operator}${(f.values ?? []).map(encodeURIComponent).join('|')}`).join(','));
|
|
43
|
+
}
|
|
44
|
+
if (params?.selects?.length) {
|
|
45
|
+
result.set('select', params.selects.map(encodeURIComponent).join(','));
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
package/dist/src/index.cjs
CHANGED
|
@@ -1305,7 +1305,13 @@ class Provider {
|
|
|
1305
1305
|
if (body.error) {
|
|
1306
1306
|
reject(this.handleError(400, body.error.message, options));
|
|
1307
1307
|
}
|
|
1308
|
-
resolve({
|
|
1308
|
+
resolve({
|
|
1309
|
+
status: 201,
|
|
1310
|
+
headers: Object.fromEntries(Object.entries(response.headers)
|
|
1311
|
+
.filter((entry) => entry[1] !== undefined)
|
|
1312
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1313
|
+
body,
|
|
1314
|
+
});
|
|
1309
1315
|
}
|
|
1310
1316
|
catch (error) {
|
|
1311
1317
|
reject(this.handleError(500, `Failed to parse response body: "${error}"`, options));
|
|
@@ -1404,7 +1410,9 @@ class Provider {
|
|
|
1404
1410
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
1405
1411
|
safeResolve({
|
|
1406
1412
|
status: response.statusCode || 200,
|
|
1407
|
-
headers: response.headers
|
|
1413
|
+
headers: Object.fromEntries(Object.entries(response.headers)
|
|
1414
|
+
.filter((entry) => entry[1] !== undefined)
|
|
1415
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
1408
1416
|
body,
|
|
1409
1417
|
});
|
|
1410
1418
|
}
|
|
@@ -1624,7 +1632,11 @@ class Provider {
|
|
|
1624
1632
|
}
|
|
1625
1633
|
else if (response.status === 204 || response.body === null) {
|
|
1626
1634
|
// No content: return without inspecting the body
|
|
1627
|
-
return {
|
|
1635
|
+
return {
|
|
1636
|
+
status: response.status,
|
|
1637
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
1638
|
+
body: undefined,
|
|
1639
|
+
};
|
|
1628
1640
|
}
|
|
1629
1641
|
const responseContentType = response.headers.get('content-type');
|
|
1630
1642
|
let body;
|
|
@@ -1655,7 +1667,7 @@ class Provider {
|
|
|
1655
1667
|
else {
|
|
1656
1668
|
throw this.handleError(500, 'Unsupported Content-Type', options);
|
|
1657
1669
|
}
|
|
1658
|
-
return { status: response.status, headers: response.headers, body };
|
|
1670
|
+
return { status: response.status, headers: Object.fromEntries(response.headers.entries()), body };
|
|
1659
1671
|
};
|
|
1660
1672
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
1661
1673
|
}
|
|
@@ -1693,6 +1705,26 @@ function getApplicableFilters(context, fields) {
|
|
|
1693
1705
|
}
|
|
1694
1706
|
return applicableFilters;
|
|
1695
1707
|
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Use this helper function to build the query params for a collection next page URL.
|
|
1710
|
+
* It re-encodes the parsed filters and selects back into query-param form,
|
|
1711
|
+
* so integrations can easily construct `nextPage` URLs.
|
|
1712
|
+
*
|
|
1713
|
+
* @example
|
|
1714
|
+
* const params = buildCollectionQueryParams({ filters: context.filters, selects: context.selects });
|
|
1715
|
+
* params.append('cursor', nextCursor);
|
|
1716
|
+
* return { info: { nextPage: `/items?${params.toString()}` }, data: [...] };
|
|
1717
|
+
*/
|
|
1718
|
+
function buildCollectionQueryParams(params) {
|
|
1719
|
+
const result = new URLSearchParams();
|
|
1720
|
+
if (params?.filters?.length) {
|
|
1721
|
+
result.set('filter', params.filters.map(f => `${f.field}${f.operator}${(f.values ?? []).map(encodeURIComponent).join('|')}`).join(','));
|
|
1722
|
+
}
|
|
1723
|
+
if (params?.selects?.length) {
|
|
1724
|
+
result.set('select', params.selects.map(encodeURIComponent).join(','));
|
|
1725
|
+
}
|
|
1726
|
+
return result;
|
|
1727
|
+
}
|
|
1696
1728
|
|
|
1697
1729
|
exports.Api = integrationApi__namespace;
|
|
1698
1730
|
exports.Cache = Cache;
|
|
@@ -1701,4 +1733,5 @@ exports.HttpErrors = httpErrors;
|
|
|
1701
1733
|
exports.Integration = Integration;
|
|
1702
1734
|
exports.NULL_LOGGER = NULL_LOGGER;
|
|
1703
1735
|
exports.Provider = Provider;
|
|
1736
|
+
exports.buildCollectionQueryParams = buildCollectionQueryParams;
|
|
1704
1737
|
exports.getApplicableFilters = getApplicableFilters;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -7,6 +7,6 @@ export type { Secrets } from './middlewares/secrets.js';
|
|
|
7
7
|
export type { Credentials } from './middlewares/credentials.js';
|
|
8
8
|
export type { Filter } from './middlewares/filters.js';
|
|
9
9
|
export * as HttpErrors from './httpErrors.js';
|
|
10
|
-
export { getApplicableFilters } from './helpers.js';
|
|
10
|
+
export { buildCollectionQueryParams, getApplicableFilters } from './helpers.js';
|
|
11
11
|
export * from './resources/context.js';
|
|
12
12
|
export { type default as Logger, NULL_LOGGER } from './resources/logger.js';
|
package/dist/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { default as Integration } from './integration.js';
|
|
|
5
5
|
export * from './handler.js';
|
|
6
6
|
export { Provider, } from './resources/provider.js';
|
|
7
7
|
export * as HttpErrors from './httpErrors.js';
|
|
8
|
-
export { getApplicableFilters } from './helpers.js';
|
|
8
|
+
export { buildCollectionQueryParams, getApplicableFilters } from './helpers.js';
|
|
9
9
|
export * from './resources/context.js';
|
|
10
10
|
export { NULL_LOGGER } from './resources/logger.js';
|
|
11
11
|
/* c8 ignore stop */
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import FormData from 'form-data';
|
|
2
2
|
import * as stream from 'stream';
|
|
3
|
-
import { IncomingHttpHeaders } from 'http';
|
|
4
3
|
import * as HttpErrors from '../httpErrors.js';
|
|
5
4
|
import { Credentials } from '../middlewares/credentials.js';
|
|
6
5
|
import Logger from '../resources/logger.js';
|
|
@@ -55,7 +54,7 @@ export type RequestOptions = {
|
|
|
55
54
|
export type Response<T> = {
|
|
56
55
|
body: T;
|
|
57
56
|
status: number;
|
|
58
|
-
headers:
|
|
57
|
+
headers: Record<string, string>;
|
|
59
58
|
};
|
|
60
59
|
export type PreparedRequest = {
|
|
61
60
|
url: string;
|
|
@@ -140,7 +140,13 @@ export class Provider {
|
|
|
140
140
|
if (body.error) {
|
|
141
141
|
reject(this.handleError(400, body.error.message, options));
|
|
142
142
|
}
|
|
143
|
-
resolve({
|
|
143
|
+
resolve({
|
|
144
|
+
status: 201,
|
|
145
|
+
headers: Object.fromEntries(Object.entries(response.headers)
|
|
146
|
+
.filter((entry) => entry[1] !== undefined)
|
|
147
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
148
|
+
body,
|
|
149
|
+
});
|
|
144
150
|
}
|
|
145
151
|
catch (error) {
|
|
146
152
|
reject(this.handleError(500, `Failed to parse response body: "${error}"`, options));
|
|
@@ -239,7 +245,9 @@ export class Provider {
|
|
|
239
245
|
const body = responseBody ? JSON.parse(responseBody) : undefined;
|
|
240
246
|
safeResolve({
|
|
241
247
|
status: response.statusCode || 200,
|
|
242
|
-
headers: response.headers
|
|
248
|
+
headers: Object.fromEntries(Object.entries(response.headers)
|
|
249
|
+
.filter((entry) => entry[1] !== undefined)
|
|
250
|
+
.map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
|
|
243
251
|
body,
|
|
244
252
|
});
|
|
245
253
|
}
|
|
@@ -459,7 +467,11 @@ export class Provider {
|
|
|
459
467
|
}
|
|
460
468
|
else if (response.status === 204 || response.body === null) {
|
|
461
469
|
// No content: return without inspecting the body
|
|
462
|
-
return {
|
|
470
|
+
return {
|
|
471
|
+
status: response.status,
|
|
472
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
473
|
+
body: undefined,
|
|
474
|
+
};
|
|
463
475
|
}
|
|
464
476
|
const responseContentType = response.headers.get('content-type');
|
|
465
477
|
let body;
|
|
@@ -490,7 +502,7 @@ export class Provider {
|
|
|
490
502
|
else {
|
|
491
503
|
throw this.handleError(500, 'Unsupported Content-Type', options);
|
|
492
504
|
}
|
|
493
|
-
return { status: response.status, headers: response.headers, body };
|
|
505
|
+
return { status: response.status, headers: Object.fromEntries(response.headers.entries()), body };
|
|
494
506
|
};
|
|
495
507
|
return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
|
|
496
508
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
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
|
-
import { getApplicableFilters } from '../src/helpers.js';
|
|
4
|
+
import { buildCollectionQueryParams, getApplicableFilters } from '../src/helpers.js';
|
|
5
5
|
describe('Helpers', () => {
|
|
6
6
|
describe('getApplicableFilters', () => {
|
|
7
7
|
it('returns only filters for defined fields', () => {
|
|
@@ -54,4 +54,98 @@ describe('Helpers', () => {
|
|
|
54
54
|
assert.deepEqual(actual, []);
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
|
+
describe('buildCollectionQueryParams', () => {
|
|
58
|
+
it('encodes a single filter with one value', () => {
|
|
59
|
+
const result = buildCollectionQueryParams({
|
|
60
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
|
|
61
|
+
});
|
|
62
|
+
assert.equal(result.get('filter'), 'status=active');
|
|
63
|
+
assert.equal(result.get('select'), null);
|
|
64
|
+
});
|
|
65
|
+
it('encodes filter values with special characters', () => {
|
|
66
|
+
const result = buildCollectionQueryParams({
|
|
67
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['foo,bar'] }],
|
|
68
|
+
});
|
|
69
|
+
assert.equal(result.get('filter'), 'status=foo%2Cbar');
|
|
70
|
+
});
|
|
71
|
+
it('encodes filter values containing pipe characters', () => {
|
|
72
|
+
const result = buildCollectionQueryParams({
|
|
73
|
+
filters: [{ field: 'tags', operator: OperatorTypes.EQUAL, values: ['a|b'] }],
|
|
74
|
+
});
|
|
75
|
+
assert.equal(result.get('filter'), 'tags=a%7Cb');
|
|
76
|
+
});
|
|
77
|
+
it('joins multiple values for one filter with |', () => {
|
|
78
|
+
const result = buildCollectionQueryParams({
|
|
79
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] }],
|
|
80
|
+
});
|
|
81
|
+
assert.equal(result.get('filter'), 'status=active|pending');
|
|
82
|
+
});
|
|
83
|
+
it('joins multiple filters with ,', () => {
|
|
84
|
+
const result = buildCollectionQueryParams({
|
|
85
|
+
filters: [
|
|
86
|
+
{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] },
|
|
87
|
+
{ field: 'name', operator: OperatorTypes.INCLUDE, values: ['john'] },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
assert.equal(result.get('filter'), 'status=active,name*=john');
|
|
91
|
+
});
|
|
92
|
+
it('handles IS_NULL operator (no values)', () => {
|
|
93
|
+
const result = buildCollectionQueryParams({
|
|
94
|
+
filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: [] }],
|
|
95
|
+
});
|
|
96
|
+
assert.equal(result.get('filter'), 'status!!');
|
|
97
|
+
});
|
|
98
|
+
it('handles IS_NULL operator with undefined values', () => {
|
|
99
|
+
const result = buildCollectionQueryParams({
|
|
100
|
+
filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: undefined }],
|
|
101
|
+
});
|
|
102
|
+
assert.equal(result.get('filter'), 'status!!');
|
|
103
|
+
});
|
|
104
|
+
it('handles IS_NOT_NULL operator (no values)', () => {
|
|
105
|
+
const result = buildCollectionQueryParams({
|
|
106
|
+
filters: [{ field: 'assignee', operator: OperatorTypes.IS_NOT_NULL, values: [] }],
|
|
107
|
+
});
|
|
108
|
+
assert.equal(result.get('filter'), 'assignee!');
|
|
109
|
+
});
|
|
110
|
+
it('encodes selects', () => {
|
|
111
|
+
const result = buildCollectionQueryParams({
|
|
112
|
+
selects: ['name', 'dept.id'],
|
|
113
|
+
});
|
|
114
|
+
assert.equal(result.get('select'), 'name,dept.id');
|
|
115
|
+
assert.equal(result.get('filter'), null);
|
|
116
|
+
});
|
|
117
|
+
it('encodes selects with special characters', () => {
|
|
118
|
+
const result = buildCollectionQueryParams({
|
|
119
|
+
selects: ['field with spaces'],
|
|
120
|
+
});
|
|
121
|
+
assert.equal(result.get('select'), 'field%20with%20spaces');
|
|
122
|
+
});
|
|
123
|
+
it('encodes both filters and selects', () => {
|
|
124
|
+
const result = buildCollectionQueryParams({
|
|
125
|
+
filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
|
|
126
|
+
selects: ['name', 'status'],
|
|
127
|
+
});
|
|
128
|
+
assert.equal(result.get('filter'), 'status=active');
|
|
129
|
+
assert.equal(result.get('select'), 'name,status');
|
|
130
|
+
});
|
|
131
|
+
it('returns empty URLSearchParams when called with no arguments', () => {
|
|
132
|
+
const result = buildCollectionQueryParams();
|
|
133
|
+
assert.equal(result.toString(), '');
|
|
134
|
+
});
|
|
135
|
+
it('omits filter key for empty filters array', () => {
|
|
136
|
+
const result = buildCollectionQueryParams({ filters: [] });
|
|
137
|
+
assert.equal(result.get('filter'), null);
|
|
138
|
+
});
|
|
139
|
+
it('omits select key for empty selects array', () => {
|
|
140
|
+
const result = buildCollectionQueryParams({ selects: [] });
|
|
141
|
+
assert.equal(result.get('select'), null);
|
|
142
|
+
});
|
|
143
|
+
it('can be extended with additional params (e.g. cursor)', () => {
|
|
144
|
+
const result = buildCollectionQueryParams({
|
|
145
|
+
selects: ['name'],
|
|
146
|
+
});
|
|
147
|
+
result.append('cursor', 'abc123');
|
|
148
|
+
assert.equal(result.toString(), 'select=name&cursor=abc123');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
57
151
|
});
|