@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 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.
@@ -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;
@@ -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
+ }
@@ -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({ status: 201, headers: response.headers, body });
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 { status: response.status, headers: response.headers, body: undefined };
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;
@@ -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: Headers | IncomingHttpHeaders;
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({ status: 201, headers: response.headers, body });
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 { status: response.status, headers: response.headers, body: undefined };
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
  });