@unito/integration-sdk 3.0.0 → 4.0.0

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
+ }
@@ -1705,6 +1705,26 @@ function getApplicableFilters(context, fields) {
1705
1705
  }
1706
1706
  return applicableFilters;
1707
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
+ }
1708
1728
 
1709
1729
  exports.Api = integrationApi__namespace;
1710
1730
  exports.Cache = Cache;
@@ -1713,4 +1733,5 @@ exports.HttpErrors = httpErrors;
1713
1733
  exports.Integration = Integration;
1714
1734
  exports.NULL_LOGGER = NULL_LOGGER;
1715
1735
  exports.Provider = Provider;
1736
+ exports.buildCollectionQueryParams = buildCollectionQueryParams;
1716
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,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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@types/express": "5.x",
56
- "@unito/integration-api": "4.x",
56
+ "@unito/integration-api": "5.x",
57
57
  "busboy": "^1.6.0",
58
58
  "cachette": "4.x",
59
59
  "express": "^5.1",
package/src/helpers.ts CHANGED
@@ -35,3 +35,30 @@ export function getApplicableFilters(context: { filters: Filter[] }, fields: Fie
35
35
 
36
36
  return applicableFilters;
37
37
  }
38
+
39
+ /**
40
+ * Use this helper function to build the query params for a collection next page URL.
41
+ * It re-encodes the parsed filters and selects back into query-param form,
42
+ * so integrations can easily construct `nextPage` URLs.
43
+ *
44
+ * @example
45
+ * const params = buildCollectionQueryParams({ filters: context.filters, selects: context.selects });
46
+ * params.append('cursor', nextCursor);
47
+ * return { info: { nextPage: `/items?${params.toString()}` }, data: [...] };
48
+ */
49
+ export function buildCollectionQueryParams(params?: { filters?: Filter[]; selects?: string[] }): URLSearchParams {
50
+ const result = new URLSearchParams();
51
+
52
+ if (params?.filters?.length) {
53
+ result.set(
54
+ 'filter',
55
+ params.filters.map(f => `${f.field}${f.operator}${(f.values ?? []).map(encodeURIComponent).join('|')}`).join(','),
56
+ );
57
+ }
58
+
59
+ if (params?.selects?.length) {
60
+ result.set('select', params.selects.map(encodeURIComponent).join(','));
61
+ }
62
+
63
+ return result;
64
+ }
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export type { Secrets } from './middlewares/secrets.js';
14
14
  export type { Credentials } from './middlewares/credentials.js';
15
15
  export type { Filter } from './middlewares/filters.js';
16
16
  export * as HttpErrors from './httpErrors.js';
17
- export { getApplicableFilters } from './helpers.js';
17
+ export { buildCollectionQueryParams, getApplicableFilters } from './helpers.js';
18
18
  export * from './resources/context.js';
19
19
  export { type default as Logger, NULL_LOGGER } from './resources/logger.js';
20
20
  /* c8 ignore stop */
@@ -2,7 +2,7 @@ import { FieldValueTypes, OperatorTypes, Semantics } from '@unito/integration-ap
2
2
 
3
3
  import assert from 'node:assert/strict';
4
4
  import { describe, it } from 'node:test';
5
- import { getApplicableFilters } from '../src/helpers.js';
5
+ import { buildCollectionQueryParams, getApplicableFilters } from '../src/helpers.js';
6
6
 
7
7
  describe('Helpers', () => {
8
8
  describe('getApplicableFilters', () => {
@@ -72,4 +72,113 @@ describe('Helpers', () => {
72
72
  assert.deepEqual(actual, []);
73
73
  });
74
74
  });
75
+
76
+ describe('buildCollectionQueryParams', () => {
77
+ it('encodes a single filter with one value', () => {
78
+ const result = buildCollectionQueryParams({
79
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
80
+ });
81
+ assert.equal(result.get('filter'), 'status=active');
82
+ assert.equal(result.get('select'), null);
83
+ });
84
+
85
+ it('encodes filter values with special characters', () => {
86
+ const result = buildCollectionQueryParams({
87
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['foo,bar'] }],
88
+ });
89
+ assert.equal(result.get('filter'), 'status=foo%2Cbar');
90
+ });
91
+
92
+ it('encodes filter values containing pipe characters', () => {
93
+ const result = buildCollectionQueryParams({
94
+ filters: [{ field: 'tags', operator: OperatorTypes.EQUAL, values: ['a|b'] }],
95
+ });
96
+ assert.equal(result.get('filter'), 'tags=a%7Cb');
97
+ });
98
+
99
+ it('joins multiple values for one filter with |', () => {
100
+ const result = buildCollectionQueryParams({
101
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] }],
102
+ });
103
+ assert.equal(result.get('filter'), 'status=active|pending');
104
+ });
105
+
106
+ it('joins multiple filters with ,', () => {
107
+ const result = buildCollectionQueryParams({
108
+ filters: [
109
+ { field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] },
110
+ { field: 'name', operator: OperatorTypes.INCLUDE, values: ['john'] },
111
+ ],
112
+ });
113
+ assert.equal(result.get('filter'), 'status=active,name*=john');
114
+ });
115
+
116
+ it('handles IS_NULL operator (no values)', () => {
117
+ const result = buildCollectionQueryParams({
118
+ filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: [] }],
119
+ });
120
+ assert.equal(result.get('filter'), 'status!!');
121
+ });
122
+
123
+ it('handles IS_NULL operator with undefined values', () => {
124
+ const result = buildCollectionQueryParams({
125
+ filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: undefined }],
126
+ });
127
+ assert.equal(result.get('filter'), 'status!!');
128
+ });
129
+
130
+ it('handles IS_NOT_NULL operator (no values)', () => {
131
+ const result = buildCollectionQueryParams({
132
+ filters: [{ field: 'assignee', operator: OperatorTypes.IS_NOT_NULL, values: [] }],
133
+ });
134
+ assert.equal(result.get('filter'), 'assignee!');
135
+ });
136
+
137
+ it('encodes selects', () => {
138
+ const result = buildCollectionQueryParams({
139
+ selects: ['name', 'dept.id'],
140
+ });
141
+ assert.equal(result.get('select'), 'name,dept.id');
142
+ assert.equal(result.get('filter'), null);
143
+ });
144
+
145
+ it('encodes selects with special characters', () => {
146
+ const result = buildCollectionQueryParams({
147
+ selects: ['field with spaces'],
148
+ });
149
+ assert.equal(result.get('select'), 'field%20with%20spaces');
150
+ });
151
+
152
+ it('encodes both filters and selects', () => {
153
+ const result = buildCollectionQueryParams({
154
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
155
+ selects: ['name', 'status'],
156
+ });
157
+ assert.equal(result.get('filter'), 'status=active');
158
+ assert.equal(result.get('select'), 'name,status');
159
+ });
160
+
161
+ it('returns empty URLSearchParams when called with no arguments', () => {
162
+ const result = buildCollectionQueryParams();
163
+ assert.equal(result.toString(), '');
164
+ });
165
+
166
+ it('omits filter key for empty filters array', () => {
167
+ const result = buildCollectionQueryParams({ filters: [] });
168
+ assert.equal(result.get('filter'), null);
169
+ });
170
+
171
+ it('omits select key for empty selects array', () => {
172
+ const result = buildCollectionQueryParams({ selects: [] });
173
+ assert.equal(result.get('select'), null);
174
+ });
175
+
176
+ it('can be extended with additional params (e.g. cursor)', () => {
177
+ const result = buildCollectionQueryParams({
178
+ selects: ['name'],
179
+ });
180
+ result.append('cursor', 'abc123');
181
+ assert.equal(result.toString(), 'select=name&cursor=abc123');
182
+ });
183
+ });
75
184
  });