Skip to content

Commit b13c7f1

Browse files
authored
feat: add Client API compatibility gate
Pins the n8n node to BailingHub Client API v1 and verifies compatibility against the released core contract without publishing a new npm package.
1 parent 457dbb5 commit b13c7f1

8 files changed

Lines changed: 308 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,15 @@ jobs:
2121
cache: npm
2222
- run: npm ci
2323
- run: npm run verify
24+
- name: Check current BailingHub Client API contract
25+
env:
26+
BAILING_CLIENT_API_CORE_REF: ${{ github.head_ref || github.ref_name }}
27+
run: |
28+
if git ls-remote --exit-code --heads https://github.com/bailinghub/bailinghub.git "$BAILING_CLIENT_API_CORE_REF" >/dev/null 2>&1; then
29+
git clone --depth 1 --branch "$BAILING_CLIENT_API_CORE_REF" https://github.com/bailinghub/bailinghub.git /tmp/bailinghub-core
30+
else
31+
git clone --depth 1 https://github.com/bailinghub/bailinghub.git /tmp/bailinghub-core
32+
fi
33+
npm run client-api:check -- \
34+
--contract-dir /tmp/bailinghub-core/contracts/client-api/v1
2435
- run: npm pack --dry-run

compatibility/client-api.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"schema_version": "bailing.client-api.consumer.v1",
3+
"consumer": "bailinghub-n8n-node",
4+
"adapter_version": "0.1.0",
5+
"contract": "bailing.client-api",
6+
"contract_major": 1,
7+
"tested_contract_version": "1.0.0",
8+
"endpoint_contracts": {
9+
"health": {
10+
"method": "GET",
11+
"path": "/health",
12+
"authentication": "none"
13+
},
14+
"run.submit": {
15+
"method": "POST",
16+
"path": "/run",
17+
"authentication": "bearer"
18+
},
19+
"jobs.get": {
20+
"method": "GET",
21+
"path": "/jobs/{job_id}",
22+
"authentication": "bearer"
23+
}
24+
},
25+
"requests": {
26+
"run.submit": {
27+
"fields_sent": ["request_id", "route", "input"]
28+
}
29+
},
30+
"responses": {
31+
"health": {
32+
"required_fields": ["status"]
33+
},
34+
"run.submit": {
35+
"required_fields": ["job_id", "request_id", "status"]
36+
},
37+
"jobs.get": {
38+
"required_fields": ["job_id", "status"]
39+
}
40+
},
41+
"known_job_statuses": ["queued", "running", "dispatched", "done", "error", "rejected"],
42+
"terminal_job_statuses": ["done", "error", "rejected"],
43+
"handled_http_errors": [400, 401, 403, 404, 409, 413, 429, 503],
44+
"limits": {
45+
"request_id_max_length": 128,
46+
"route_max_length": 64,
47+
"input_max_length": 100000,
48+
"response_max_bytes": 1048576
49+
}
50+
}

credentials/BailingHubApi.credentials.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
Icon,
88
} from 'n8n-workflow';
99

10-
import { normalizeBaseUrl } from '../shared/client';
10+
import { CLIENT_API_ENDPOINTS, normalizeBaseUrl } from '../shared/client';
1111

1212
export class BailingHubApi implements ICredentialType {
1313
name = 'bailingHubApi';
@@ -66,8 +66,9 @@ export class BailingHubApi implements ICredentialType {
6666

6767
test: ICredentialTestRequest = {
6868
request: {
69+
method: CLIENT_API_ENDPOINTS.health.method,
6970
baseURL: '={{$credentials.baseUrl.replace(/\\/$/, "")}}',
70-
url: '/health',
71+
url: CLIENT_API_ENDPOINTS.health.path,
7172
headers: {
7273
Authorization: '=Bearer {{$credentials.clientToken}}',
7374
},

docs/COMPATIBILITY.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
| Adapter version | BailingHub contract | n8n | Status |
44
| --- | --- | --- | --- |
5-
| 0.1.x | `bailing.contract.v2.13` compatible Client API | To be fixed by release E2E evidence | Development |
5+
| 0.1.x | `bailing.client-api.v1` | Creator Portal manual review | Submitted |
66

77
## Required BailingHub Surface
88

@@ -15,5 +15,8 @@ Unknown additive fields are ignored. Existing fields are filtered into a stable
1515
The adapter fails closed on unknown status values, malformed job IDs, invalid result shapes,
1616
oversized responses, and non-object JSON.
1717

18-
The exact n8n version used for the clean-install E2E test is recorded here before the first
19-
npm release.
18+
The machine-readable adapter claim lives at `compatibility/client-api.json`. CI checks it
19+
against the current BailingHub core contract with `scripts/check-client-api-contract.mjs`;
20+
core CI also checks this adapter before accepting a Client API change. The gate verifies
21+
endpoint method, path, authentication shape, request limits, consumed fields, statuses, and
22+
classified HTTP failures rather than relying on a version label alone.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@
3636
"lint": "n8n-node lint",
3737
"lint:fix": "n8n-node lint --fix",
3838
"test": "npm run build && node --test tests/*.test.cjs",
39+
"client-api:check": "node scripts/check-client-api-contract.mjs",
3940
"verify": "npm run lint && npm test",
4041
"prepublishOnly": "npm run verify"
4142
},
4243
"files": [
4344
"dist/credentials",
4445
"dist/nodes",
45-
"dist/shared"
46+
"dist/shared",
47+
"compatibility/client-api.json"
4648
],
4749
"publishConfig": {
4850
"access": "public",
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
4+
const root = resolve(import.meta.dirname, '..');
5+
const contractArg = process.argv.indexOf('--contract-dir');
6+
if (contractArg < 0 || !process.argv[contractArg + 1]) {
7+
throw new Error('--contract-dir is required');
8+
}
9+
const contractDir = resolve(process.argv[contractArg + 1]);
10+
11+
function readJson(path) {
12+
const value = JSON.parse(readFileSync(path, 'utf8'));
13+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
14+
throw new Error(`${path} must contain a JSON object`);
15+
}
16+
return value;
17+
}
18+
19+
function semanticVersion(value) {
20+
const parts = String(value).split('.');
21+
if (parts.length !== 3 || parts.some((part) => !/^\d+$/.test(part))) {
22+
throw new Error(`invalid semantic version: ${value}`);
23+
}
24+
return parts.map(Number);
25+
}
26+
27+
function compareVersions(left, right) {
28+
for (let index = 0; index < 3; index += 1) {
29+
if (left[index] !== right[index]) return left[index] - right[index];
30+
}
31+
return 0;
32+
}
33+
34+
function schemaFields(schema, key) {
35+
const value = schema[key] ?? (key === 'required' ? [] : undefined);
36+
if (key === 'required') {
37+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
38+
throw new Error('schema required must be an array of strings');
39+
}
40+
return new Set(value);
41+
}
42+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
43+
throw new Error('schema properties must be an object');
44+
}
45+
return new Set(Object.keys(value));
46+
}
47+
48+
function propertyMaxLength(schema, field) {
49+
const value = schema.properties?.[field];
50+
const maximum = value?.maxLength;
51+
if (!Number.isInteger(maximum) || maximum < 1) {
52+
throw new Error(`${field} must declare a positive maxLength`);
53+
}
54+
return maximum;
55+
}
56+
57+
const declaration = readJson(resolve(root, 'compatibility/client-api.json'));
58+
const manifest = readJson(resolve(contractDir, 'manifest.json'));
59+
const packageJson = readJson(resolve(root, 'package.json'));
60+
const vectors = readJson(resolve(contractDir, manifest.vectors ?? 'vectors.json'));
61+
62+
if (manifest.contract !== declaration.contract) {
63+
throw new Error('contract id does not match the adapter declaration');
64+
}
65+
if (manifest.major !== declaration.contract_major) {
66+
throw new Error('Client API major version is incompatible');
67+
}
68+
const currentVersion = semanticVersion(manifest.version);
69+
const testedVersion = semanticVersion(declaration.tested_contract_version);
70+
if (
71+
currentVersion[0] !== testedVersion[0] ||
72+
compareVersions(currentVersion, testedVersion) < 0
73+
) {
74+
throw new Error('current Client API version predates the adapter compatibility floor');
75+
}
76+
if (declaration.adapter_version !== packageJson.version) {
77+
throw new Error('adapter_version must match package.json');
78+
}
79+
80+
const endpoints = new Map();
81+
for (const endpoint of manifest.endpoints ?? []) {
82+
if (!endpoint || typeof endpoint.id !== 'string') {
83+
throw new Error('every manifest endpoint must have an id');
84+
}
85+
endpoints.set(endpoint.id, endpoint);
86+
}
87+
const endpointContracts = declaration.endpoint_contracts;
88+
if (!endpointContracts || typeof endpointContracts !== 'object' || Array.isArray(endpointContracts)) {
89+
throw new Error('adapter must declare endpoint_contracts');
90+
}
91+
for (const [endpointId, claim] of Object.entries(endpointContracts)) {
92+
const endpoint = endpoints.get(endpointId);
93+
if (!endpoint || !claim || typeof claim !== 'object' || Array.isArray(claim)) {
94+
throw new Error(`missing required endpoint: ${endpointId}`);
95+
}
96+
for (const key of ['method', 'path', 'authentication']) {
97+
if (claim[key] !== endpoint[key]) {
98+
throw new Error(
99+
`${endpointId}: ${key} changed from ${JSON.stringify(claim[key])} ` +
100+
`to ${JSON.stringify(endpoint[key])}`,
101+
);
102+
}
103+
}
104+
}
105+
106+
for (const [endpointId, claim] of Object.entries(declaration.requests ?? {})) {
107+
const endpoint = endpoints.get(endpointId);
108+
const schema = readJson(resolve(contractDir, endpoint.request_schema));
109+
const sent = new Set(claim.fields_sent ?? []);
110+
const required = schemaFields(schema, 'required');
111+
const properties = schemaFields(schema, 'properties');
112+
const missing = [...required].filter((field) => !sent.has(field));
113+
const unknown = [...sent].filter((field) => !properties.has(field));
114+
if (missing.length) throw new Error(`${endpointId}: adapter omits ${missing.join(', ')}`);
115+
if (unknown.length) throw new Error(`${endpointId}: adapter sends unknown ${unknown.join(', ')}`);
116+
}
117+
118+
const runRequest = readJson(
119+
resolve(contractDir, endpoints.get('run.submit').request_schema),
120+
);
121+
const limits = declaration.limits;
122+
if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
123+
throw new Error('adapter limits must be an object');
124+
}
125+
for (const [limitName, field] of [
126+
['request_id_max_length', 'request_id'],
127+
['route_max_length', 'route'],
128+
['input_max_length', 'input'],
129+
]) {
130+
const adapterLimit = limits[limitName];
131+
const contractLimit = propertyMaxLength(runRequest, field);
132+
if (!Number.isInteger(adapterLimit) || adapterLimit < 1) {
133+
throw new Error(`${limitName} must be a positive integer`);
134+
}
135+
if (adapterLimit > contractLimit) {
136+
throw new Error(
137+
`${limitName} allows ${adapterLimit}, above contract maximum ${contractLimit}`,
138+
);
139+
}
140+
}
141+
142+
for (const [endpointId, claim] of Object.entries(declaration.responses ?? {})) {
143+
const endpoint = endpoints.get(endpointId);
144+
const schema = readJson(resolve(contractDir, endpoint.response_schema));
145+
const guaranteed = schemaFields(schema, 'required');
146+
const missing = (claim.required_fields ?? []).filter((field) => !guaranteed.has(field));
147+
if (missing.length) {
148+
throw new Error(`${endpointId}: contract no longer guarantees ${missing.join(', ')}`);
149+
}
150+
}
151+
152+
if (
153+
JSON.stringify(declaration.known_job_statuses) !==
154+
JSON.stringify(manifest.job_statuses?.known)
155+
) {
156+
throw new Error('adapter known statuses differ from the Client API contract');
157+
}
158+
if (
159+
JSON.stringify(declaration.terminal_job_statuses) !==
160+
JSON.stringify(manifest.job_statuses?.terminal)
161+
) {
162+
throw new Error('adapter terminal statuses differ from the Client API contract');
163+
}
164+
165+
const handledErrors = new Set(declaration.handled_http_errors ?? []);
166+
const missingErrors = Object.keys(manifest.http_errors ?? {})
167+
.map(Number)
168+
.filter((status) => !handledErrors.has(status));
169+
if (missingErrors.length) {
170+
throw new Error(`adapter does not classify HTTP errors ${missingErrors.join(', ')}`);
171+
}
172+
173+
if (!Array.isArray(vectors.cases) || vectors.cases.length === 0) {
174+
throw new Error('Client API vectors must contain cases');
175+
}
176+
for (const vector of vectors.cases) {
177+
if (typeof vector.schema !== 'string') throw new Error('vector schema must be a string');
178+
readFileSync(resolve(contractDir, vector.schema));
179+
}
180+
181+
console.log(
182+
`PASS: ${declaration.consumer} ${declaration.adapter_version} ` +
183+
`accepts ${manifest.contract} ${manifest.version}`,
184+
);

shared/client.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
1-
const TERMINAL_STATUSES = new Set(['done', 'error', 'rejected']);
2-
const KNOWN_STATUSES = new Set(['queued', 'running', 'dispatched', ...TERMINAL_STATUSES]);
1+
export const CLIENT_API_TERMINAL_STATUSES = ['done', 'error', 'rejected'] as const;
2+
export const CLIENT_API_KNOWN_STATUSES = [
3+
'queued',
4+
'running',
5+
'dispatched',
6+
...CLIENT_API_TERMINAL_STATUSES,
7+
] as const;
8+
export const CLIENT_API_LIMITS = {
9+
request_id_max_length: 128,
10+
route_max_length: 64,
11+
input_max_length: 100_000,
12+
response_max_bytes: 1024 * 1024,
13+
} as const;
14+
export const CLIENT_API_ENDPOINTS = {
15+
health: { method: 'GET', path: '/health', authentication: 'none' },
16+
'run.submit': { method: 'POST', path: '/run', authentication: 'bearer' },
17+
'jobs.get': {
18+
method: 'GET',
19+
path: '/jobs/{job_id}',
20+
authentication: 'bearer',
21+
},
22+
} as const;
23+
const TERMINAL_STATUSES = new Set<string>(CLIENT_API_TERMINAL_STATUSES);
24+
const KNOWN_STATUSES = new Set<string>(CLIENT_API_KNOWN_STATUSES);
325
const ROUTE_PATTERN = /^[a-z0-9][a-z0-9_-]{1,63}$/;
426
const JOB_ID_PATTERN =
527
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
628
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
7-
const MAX_RESPONSE_BYTES = 1024 * 1024;
29+
const MAX_RESPONSE_BYTES = CLIENT_API_LIMITS.response_max_bytes;
830

931
export type BailingHubHttpRequest = (options: {
1032
method: 'GET' | 'POST';
@@ -110,7 +132,7 @@ function normalizeJob(value: unknown, requireRequestId = false): BailingHubJob {
110132
if (requireRequestId && !requestId) {
111133
throw new BailingHubClientError('BailingHub returned a job without request_id.');
112134
}
113-
if (requestId.length > 128) {
135+
if (requestId.length > CLIENT_API_LIMITS.request_id_max_length) {
114136
throw new BailingHubClientError('BailingHub returned an invalid request_id.');
115137
}
116138

@@ -210,15 +232,20 @@ export class BailingHubClient {
210232
) {}
211233

212234
async submitJob(requestIdValue: unknown, routeValue: unknown, inputValue: unknown) {
213-
const requestId = requireText(requestIdValue, 'Request ID', 128);
214-
const route = requireText(routeValue, 'Route', 64);
215-
const input = requireText(inputValue, 'Input', 100_000);
235+
const requestId = requireText(
236+
requestIdValue,
237+
'Request ID',
238+
CLIENT_API_LIMITS.request_id_max_length,
239+
);
240+
const route = requireText(routeValue, 'Route', CLIENT_API_LIMITS.route_max_length);
241+
const input = requireText(inputValue, 'Input', CLIENT_API_LIMITS.input_max_length);
216242
if (!ROUTE_PATTERN.test(route)) {
217243
throw new Error('Route must match ^[a-z0-9][a-z0-9_-]{1,63}$.');
218244
}
245+
const endpoint = CLIENT_API_ENDPOINTS['run.submit'];
219246
const response = await this.perform({
220-
method: 'POST',
221-
url: `${this.baseUrl}/run`,
247+
method: endpoint.method,
248+
url: `${this.baseUrl}${endpoint.path}`,
222249
body: { request_id: requestId, route, input },
223250
timeout: 15_000,
224251
});
@@ -230,9 +257,10 @@ export class BailingHubClient {
230257
if (!JOB_ID_PATTERN.test(jobId)) {
231258
throw new Error('Job ID must be a UUID returned by BailingHub.');
232259
}
260+
const endpoint = CLIENT_API_ENDPOINTS['jobs.get'];
233261
const response = await this.perform({
234-
method: 'GET',
235-
url: `${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`,
262+
method: endpoint.method,
263+
url: `${this.baseUrl}${endpoint.path.replace('{job_id}', encodeURIComponent(jobId))}`,
236264
timeout: 15_000,
237265
});
238266
return normalizeJob(response);

0 commit comments

Comments
 (0)