@objectstack/connector-openapi 17.0.0-rc.0 → 17.0.0-rc.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.
@@ -1,414 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Connector } from '@objectstack/spec/integration';
4
-
5
- /**
6
- * OpenAPI connector generator — turns a declarative OpenAPI 3.x document into a
7
- * {@link Connector} definition + handler map (ADR-0023).
8
- *
9
- * Each OpenAPI operation maps to one connector action; a single generic handler
10
- * (closing over the operation's method + path template) drives one shared HTTP
11
- * request implementation. That transport mirrors `@objectstack/connector-rest`
12
- * (build URL from base+path+query, apply static auth, JSON-encode the body,
13
- * normalise the response to `{ status, ok, body }`) — kept inline so this package
14
- * stays self-contained, depending only on `@objectstack/core` + `@objectstack/spec`
15
- * like its sibling connectors. The output is an ordinary `type: 'api'` connector,
16
- * registered via `engine.registerConnector(def, handlers)` exactly like a
17
- * hand-written one — the registry, the `connector_action` node, the discovery
18
- * route, and the Studio palette never know it came from OpenAPI.
19
- *
20
- * Open-source scope: **static** auth only (`none` / `api-key` / `basic` /
21
- * `bearer`), with credentials supplied by the caller. Managed OAuth2, credential
22
- * vaulting, and per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022).
23
- */
24
-
25
- /** Static auth understood by the generated connector (the open-source subset). */
26
- export type RestAuth = Extract<Connector['authentication'], { type: 'none' | 'api-key' | 'basic' | 'bearer' }>;
27
-
28
- /** An action on a Connector definition (derived to avoid guessing export names). */
29
- type ConnectorAction = NonNullable<Connector['actions']>[number];
30
-
31
- /** Handler signature accepted by the connector registry (ADR-0018 §Addendum). */
32
- type ConnectorHandler = (input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>;
33
-
34
- /** A connector definition paired with its action handlers, ready for registerConnector(). */
35
- export interface OpenApiConnectorBundle {
36
- def: Connector;
37
- handlers: Record<string, ConnectorHandler>;
38
- }
39
-
40
- /** A free-form JSON Schema fragment (matches ConnectorAction input/outputSchema). */
41
- export type JsonSchema = Record<string, unknown>;
42
-
43
- /** Minimal subset of an OpenAPI 3.x document consumed by the generator.
44
- * The caller is responsible for loading and de-referencing ($ref) the doc. */
45
- export interface OpenApiDocument {
46
- openapi?: string;
47
- info?: { title?: string; description?: string; version?: string };
48
- servers?: { url: string }[];
49
- paths?: Record<string, OpenApiPathItem>;
50
- components?: { securitySchemes?: Record<string, OpenApiSecurityScheme> };
51
- }
52
-
53
- export interface OpenApiPathItem {
54
- [method: string]: OpenApiOperation | unknown;
55
- }
56
-
57
- export interface OpenApiOperation {
58
- operationId?: string;
59
- summary?: string;
60
- description?: string;
61
- tags?: string[];
62
- parameters?: OpenApiParameter[];
63
- requestBody?: OpenApiRequestBody;
64
- responses?: Record<string, OpenApiResponse>;
65
- }
66
-
67
- export interface OpenApiParameter {
68
- name: string;
69
- in: 'path' | 'query' | 'header' | 'cookie';
70
- required?: boolean;
71
- description?: string;
72
- schema?: JsonSchema;
73
- }
74
-
75
- export interface OpenApiRequestBody {
76
- required?: boolean;
77
- description?: string;
78
- content?: Record<string, { schema?: JsonSchema }>;
79
- }
80
-
81
- export interface OpenApiResponse {
82
- description?: string;
83
- content?: Record<string, { schema?: JsonSchema }>;
84
- }
85
-
86
- export interface OpenApiSecurityScheme {
87
- type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
88
- name?: string;
89
- in?: 'header' | 'query' | 'cookie';
90
- scheme?: string;
91
- }
92
-
93
- /** Flattened view of a single operation, passed to the `include` predicate. */
94
- export interface OperationInfo {
95
- operationId?: string;
96
- method: string;
97
- path: string;
98
- tags?: string[];
99
- summary?: string;
100
- description?: string;
101
- }
102
-
103
- /** Configuration for {@link createOpenApiConnector}. */
104
- export interface OpenApiConnectorConfig {
105
- /** Connector machine name (snake_case). Defaults to a slug of info.title. */
106
- name?: string;
107
- /** Human-friendly label. Defaults to info.title (then name). */
108
- label?: string;
109
- /** Description. Defaults to info.description. */
110
- description?: string;
111
- /** Icon identifier for the Studio palette. Defaults to `globe`. */
112
- icon?: string;
113
- /** The parsed OpenAPI 3.x document (caller loads/derefs it). */
114
- document: OpenApiDocument;
115
- /** Override the base URL (else servers[0].url). */
116
- baseUrl?: string;
117
- /** Static auth with credentials. Defaults to `{ type: 'none' }`. */
118
- auth?: RestAuth;
119
- /** Headers merged into every request (request-level headers win). */
120
- defaultHeaders?: Record<string, string>;
121
- /** Only include operations for which this predicate returns true (allowlist). */
122
- include?: (op: OperationInfo) => boolean;
123
- /** Injected fetch implementation (defaults to global `fetch`). */
124
- fetchImpl?: typeof fetch;
125
- }
126
-
127
- /** OpenAPI HTTP method keys, in a deterministic order. */
128
- const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'] as const;
129
-
130
- /** Input passed to the shared request transport. */
131
- interface RequestInput {
132
- method: string;
133
- path: string;
134
- headers?: Record<string, string>;
135
- query?: Record<string, string>;
136
- body?: unknown;
137
- }
138
-
139
- /**
140
- * Build an OpenAPI connector definition and its handler map.
141
- *
142
- * @returns the `Connector` definition (`def`) and a `handlers` record keyed by
143
- * action key, suitable for `engine.registerConnector(def, handlers)`.
144
- */
145
- export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiConnectorBundle {
146
- const { document, include } = config;
147
- const auth: RestAuth = config.auth ?? { type: 'none' };
148
- const doFetch = config.fetchImpl ?? fetch;
149
- const name = config.name ?? slug(document.info?.title ?? 'openapi_connector');
150
- const label = config.label ?? document.info?.title ?? titleize(name);
151
- const description = config.description ?? document.info?.description;
152
- const baseUrl = config.baseUrl ?? document.servers?.[0]?.url;
153
- if (!baseUrl) {
154
- throw new Error('createOpenApiConnector: no base URL — provide config.baseUrl or document.servers[0].url');
155
- }
156
-
157
- // One shared transport (mirrors connector-rest) reused by every action handler.
158
- async function request(input: RequestInput): Promise<Record<string, unknown>> {
159
- const method = input.method.toUpperCase();
160
- const headers: Record<string, string> = { ...config.defaultHeaders, ...input.headers };
161
- const query: Record<string, string> = { ...input.query };
162
- applyAuth(auth, headers, query);
163
-
164
- const url = buildUrl(baseUrl as string, input.path, query);
165
- const hasBody = input.body !== undefined && method !== 'GET' && method !== 'HEAD';
166
- if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) {
167
- headers['Content-Type'] = 'application/json';
168
- }
169
-
170
- const response = await doFetch(url, {
171
- method,
172
- headers,
173
- body: hasBody ? JSON.stringify(input.body) : undefined,
174
- });
175
-
176
- const contentType = response.headers.get('content-type') ?? '';
177
- const parsed = contentType.includes('application/json') ? await response.json() : await response.text();
178
- return { status: response.status, ok: response.ok, body: parsed };
179
- }
180
-
181
- const actions: ConnectorAction[] = [];
182
- const handlers: Record<string, ConnectorHandler> = {};
183
- const seenKeys = new Set<string>();
184
-
185
- for (const op of collectOperations(document)) {
186
- if (include && !include(toInfo(op))) continue;
187
- const key = uniqueKey(op.operationId ?? slug(`${op.method}_${op.path}`), seenKeys);
188
-
189
- actions.push({
190
- key,
191
- label: op.summary ?? titleize(key),
192
- description: op.description,
193
- inputSchema: buildInputSchema(op),
194
- outputSchema: buildOutputSchema(op),
195
- });
196
-
197
- handlers[key] = async (input: Record<string, unknown>) => {
198
- const req = input as { path?: unknown; query?: unknown; header?: unknown; body?: unknown };
199
- return request({
200
- method: op.method,
201
- path: interpolatePath(op.path, asRecord(req.path)),
202
- query: stringifyValues(asRecord(req.query)),
203
- headers: stringifyValues(asRecord(req.header)),
204
- body: req.body,
205
- });
206
- };
207
- }
208
-
209
- const def: Connector = {
210
- name,
211
- label,
212
- type: 'api',
213
- description,
214
- icon: config.icon ?? 'globe',
215
- authentication: auth,
216
- // Defaulted by ConnectorSchema; set explicitly so the literal satisfies
217
- // the (post-parse) Connector output type (mirrors connector-rest/mcp).
218
- status: 'active',
219
- enabled: true,
220
- connectionTimeoutMs: 30000,
221
- requestTimeoutMs: 30000,
222
- actions,
223
- };
224
-
225
- return { def, handlers };
226
- }
227
-
228
- interface Op extends OpenApiOperation {
229
- method: string;
230
- path: string;
231
- }
232
-
233
- /** Flatten paths × methods into a deterministic list of operations. */
234
- function collectOperations(doc: OpenApiDocument): Op[] {
235
- const ops: Op[] = [];
236
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
237
- if (!item || typeof item !== 'object') continue;
238
- const record = item as Record<string, unknown>;
239
- for (const method of HTTP_METHODS) {
240
- const operation = record[method] as OpenApiOperation | undefined;
241
- if (!operation || typeof operation !== 'object') continue;
242
- ops.push({ ...operation, method, path });
243
- }
244
- }
245
- return ops;
246
- }
247
-
248
- function toInfo(op: Op): OperationInfo {
249
- return {
250
- operationId: op.operationId,
251
- method: op.method,
252
- path: op.path,
253
- tags: op.tags,
254
- summary: op.summary,
255
- description: op.description,
256
- };
257
- }
258
-
259
- /**
260
- * Assemble the action inputSchema from an operation's parameters + requestBody.
261
- * Produces { type: 'object', properties: { path, query, header, body }, required }
262
- * where only non-empty sections are emitted.
263
- */
264
- function buildInputSchema(op: OpenApiOperation): JsonSchema | undefined {
265
- const sections: Record<'path' | 'query' | 'header', { props: Record<string, JsonSchema>; required: string[] }> = {
266
- path: { props: {}, required: [] },
267
- query: { props: {}, required: [] },
268
- header: { props: {}, required: [] },
269
- };
270
-
271
- for (const p of op.parameters ?? []) {
272
- if (!p || typeof p !== 'object' || '$ref' in p) continue;
273
- if (p.in !== 'path' && p.in !== 'query' && p.in !== 'header') continue;
274
- const sec = sections[p.in];
275
- sec.props[p.name] = p.schema ?? (p.description ? { type: 'string', description: p.description } : { type: 'string' });
276
- if (p.required) sec.required.push(p.name);
277
- }
278
-
279
- const properties: Record<string, JsonSchema> = {};
280
- const required: string[] = [];
281
- for (const where of ['path', 'query', 'header'] as const) {
282
- const sec = sections[where];
283
- if (Object.keys(sec.props).length === 0) continue;
284
- const schema: JsonSchema = { type: 'object', properties: sec.props };
285
- if (sec.required.length) schema.required = sec.required;
286
- properties[where] = schema;
287
- // Path params are always required when present; others only if any are.
288
- if (where === 'path' || sec.required.length) required.push(where);
289
- }
290
-
291
- const bodySchema = extractRequestBodySchema(op.requestBody);
292
- if (bodySchema) {
293
- properties.body = bodySchema;
294
- if (op.requestBody && !('$ref' in op.requestBody) && op.requestBody.required) required.push('body');
295
- }
296
-
297
- if (Object.keys(properties).length === 0) return undefined;
298
- const schema: JsonSchema = { type: 'object', properties };
299
- if (required.length) schema.required = required;
300
- return schema;
301
- }
302
-
303
- /** Pick the success response's JSON schema (200 → first 2xx → default). */
304
- function buildOutputSchema(op: OpenApiOperation): JsonSchema | undefined {
305
- const responses = op.responses;
306
- if (!responses) return undefined;
307
- let code: string | undefined;
308
- if (responses['200']) code = '200';
309
- else code = Object.keys(responses).find((c) => /^2\d\d$/.test(c));
310
- if (!code && responses['default']) code = 'default';
311
- if (!code) return undefined;
312
- const resp = responses[code];
313
- if (!resp || typeof resp !== 'object' || '$ref' in resp) return undefined;
314
- return pickJsonSchema(resp.content);
315
- }
316
-
317
- /** Extract the requestBody JSON schema (prefers application/json). */
318
- function extractRequestBodySchema(rb: OpenApiRequestBody | undefined): JsonSchema | undefined {
319
- if (!rb || typeof rb !== 'object' || '$ref' in rb) return undefined;
320
- return pickJsonSchema(rb.content);
321
- }
322
-
323
- /** Choose the application/json schema, falling back to the first content type. */
324
- function pickJsonSchema(content: Record<string, { schema?: JsonSchema }> | undefined): JsonSchema | undefined {
325
- if (!content) return undefined;
326
- const chosen = content['application/json'] ?? Object.values(content)[0];
327
- return chosen?.schema;
328
- }
329
-
330
- /** Build the request URL from base + path + query, encoding query params. */
331
- function buildUrl(baseUrl: string, path: string, query: Record<string, string>): string {
332
- const base = baseUrl.replace(/\/+$/, '');
333
- const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : '';
334
- const url = new URL(base + suffix);
335
- for (const [key, value] of Object.entries(query)) {
336
- if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
337
- }
338
- return url.toString();
339
- }
340
-
341
- /** Apply static auth to the outgoing headers / query (mirrors connector-rest). */
342
- function applyAuth(auth: RestAuth, headers: Record<string, string>, query: Record<string, string>): void {
343
- switch (auth.type) {
344
- case 'none':
345
- return;
346
- case 'bearer':
347
- headers['Authorization'] = `Bearer ${auth.token}`;
348
- return;
349
- case 'basic': {
350
- const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
351
- headers['Authorization'] = `Basic ${encoded}`;
352
- return;
353
- }
354
- case 'api-key':
355
- if (auth.paramName) query[auth.paramName] = auth.key;
356
- else headers[auth.headerName ?? 'X-API-Key'] = auth.key;
357
- return;
358
- }
359
- }
360
-
361
- /** Interpolate {name} path templates with encoded values from the input. */
362
- function interpolatePath(template: string, pathParams: Record<string, unknown>): string {
363
- return template.replace(/\{([^}]+)\}/g, (_match, key: string) => {
364
- const value = pathParams[key];
365
- return value === undefined || value === null ? `{${key}}` : encodeURIComponent(String(value));
366
- });
367
- }
368
-
369
- /** Coerce a record of mixed values into string values, dropping null/undefined. */
370
- function stringifyValues(rec: Record<string, unknown>): Record<string, string> {
371
- const out: Record<string, string> = {};
372
- for (const [k, v] of Object.entries(rec)) {
373
- if (v === undefined || v === null) continue;
374
- out[k] = String(v);
375
- }
376
- return out;
377
- }
378
-
379
- /** Return v if it is a plain object, else an empty record. */
380
- function asRecord(v: unknown): Record<string, unknown> {
381
- return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
382
- }
383
-
384
- /** Ensure a deterministically unique action key within the connector. */
385
- function uniqueKey(base: string, seen: Set<string>): string {
386
- let candidate = base;
387
- if (seen.has(candidate)) {
388
- let i = 2;
389
- while (seen.has(`${base}_${i}`)) i++;
390
- candidate = `${base}_${i}`;
391
- }
392
- seen.add(candidate);
393
- return candidate;
394
- }
395
-
396
- /** Slugify a string into a snake_case machine name (`/^[a-z_][a-z0-9_]*$/`). */
397
- function slug(s: string): string {
398
- const out = s
399
- .normalize('NFKD')
400
- .replace(/[^a-zA-Z0-9]+/g, '_')
401
- .replace(/^_+|_+$/g, '')
402
- .toLowerCase();
403
- if (!out) return 'connector';
404
- return /^[a-z_]/.test(out) ? out : `op_${out}`;
405
- }
406
-
407
- /** Title-case a snake_case key for a default label (`get_pets` → `Get Pets`). */
408
- function titleize(name: string): string {
409
- return name
410
- .split('_')
411
- .filter(Boolean)
412
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
413
- .join(' ');
414
- }
@@ -1,169 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
- //
3
- // ADR-0097 — the `openapi` provider factory: materialize a declarative
4
- // `provider: 'openapi'` connector instance from a spec document + resolved auth.
5
-
6
- import { describe, it, expect } from 'vitest';
7
- import type { ConnectorProviderContext } from '@objectstack/spec/integration';
8
- import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
9
- import { createOpenApiProviderFactory, OPENAPI_PROVIDER_KEY } from './openapi-provider.js';
10
-
11
- const petstore = {
12
- openapi: '3.0.0',
13
- info: { title: 'Petstore', version: '1.0.0' },
14
- servers: [{ url: 'https://petstore.example.com' }],
15
- paths: {
16
- '/pets': {
17
- get: { operationId: 'listPets', summary: 'List pets', responses: { '200': { description: 'ok' } } },
18
- },
19
- },
20
- };
21
-
22
- function ctx(partial: Partial<ConnectorProviderContext> & Pick<ConnectorProviderContext, 'providerConfig'>): ConnectorProviderContext {
23
- return { name: 'pets', label: 'Pets', type: 'api', ...partial };
24
- }
25
-
26
- describe('openapi provider factory (ADR-0097)', () => {
27
- it('advertises the openapi provider key', () => {
28
- expect(OPENAPI_PROVIDER_KEY).toBe('openapi');
29
- });
30
-
31
- it('materializes actions from an inline OpenAPI document (no network at boot)', async () => {
32
- const factory = createOpenApiProviderFactory();
33
- const { def, handlers } = await factory(ctx({ providerConfig: { spec: petstore } }));
34
- expect(def.name).toBe('pets');
35
- expect(Object.keys(handlers)).toEqual(['listPets']);
36
- expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
37
- });
38
-
39
- it('fetches the document when spec is an http(s) URL, via the injected fetch', async () => {
40
- let fetched: string | undefined;
41
- const fetchImpl = (async (url: string) => {
42
- fetched = url;
43
- return { ok: true, status: 200, json: async () => petstore } as unknown as Response;
44
- }) as unknown as typeof fetch;
45
- const factory = createOpenApiProviderFactory({ fetchImpl });
46
- const { def } = await factory(ctx({ providerConfig: { spec: 'https://petstore.example.com/openapi.json' } }));
47
- expect(fetched).toBe('https://petstore.example.com/openapi.json');
48
- expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
49
- });
50
-
51
- it('throws when spec is missing', async () => {
52
- const factory = createOpenApiProviderFactory();
53
- await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.spec/);
54
- });
55
-
56
- // ── File-path specs (#3016 — ADR-0097 follow-up) ────────────────────────
57
-
58
- it('reads a file-path spec through the host loadPackageFile capability', async () => {
59
- const requested: string[] = [];
60
- const loadPackageFile = async (rel: string) => {
61
- requested.push(rel);
62
- return JSON.stringify(petstore);
63
- };
64
- const factory = createOpenApiProviderFactory();
65
- const { def, handlers } = await factory(
66
- ctx({ providerConfig: { spec: './specs/petstore.json' }, loadPackageFile }),
67
- );
68
- expect(requested).toEqual(['./specs/petstore.json']);
69
- expect(def.actions?.map((a) => a.key)).toEqual(['listPets']);
70
- expect(Object.keys(handlers)).toEqual(['listPets']);
71
- });
72
-
73
- it('surfaces a loader failure (missing file / traversal rejection) with the connector name', async () => {
74
- const loadPackageFile = async (rel: string) => {
75
- throw new Error(`package file ref '${rel}' could not be read`);
76
- };
77
- const factory = createOpenApiProviderFactory();
78
- await expect(
79
- factory(ctx({ providerConfig: { spec: './missing.json' }, loadPackageFile })),
80
- ).rejects.toThrow(/'pets' failed to read providerConfig\.spec '\.\/missing\.json'.*could not be read/s);
81
- });
82
-
83
- it('rejects an unparseable file-path spec with a clear message', async () => {
84
- const factory = createOpenApiProviderFactory();
85
- await expect(
86
- factory(ctx({ providerConfig: { spec: './broken.json' }, loadPackageFile: async () => 'not-json{' })),
87
- ).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
88
- await expect(
89
- factory(ctx({ providerConfig: { spec: './array.json' }, loadPackageFile: async () => '[1,2]' })),
90
- ).rejects.toThrow(/not a parseable.*OpenAPI JSON document/s);
91
- });
92
-
93
- it('rejects a file-path spec with a clear message when the host has no package file access', async () => {
94
- const factory = createOpenApiProviderFactory();
95
- await expect(
96
- factory(ctx({ providerConfig: { spec: './petstore.json' } })),
97
- ).rejects.toThrow(/no package file access/);
98
- });
99
- });
100
-
101
- // ── #3049 follow-up: remote-URL fetch fault classification ──────────────────
102
- //
103
- // A remote spec URL that is unreachable / transiently failing is an OPERATIONAL
104
- // fault: the factory throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker so the
105
- // materializer degrades + retries the instance instead of failing boot
106
- // (symmetric with connector-mcp's connect path). A wrong URL (non-retryable
107
- // 4xx) or an unparseable document stays a plain, fatal configuration fault.
108
-
109
- describe('openapi provider — remote spec fetch fault classification (#3049)', () => {
110
- const url = 'https://petstore.example.com/openapi.json';
111
- const cfg = { providerConfig: { spec: url } };
112
-
113
- it('classifies a network failure (fetch rejects) as upstream-unavailable, keeping the cause', async () => {
114
- const boom = new Error('connect ECONNREFUSED 93.184.216.34:443');
115
- const fetchImpl = (async () => { throw boom; }) as unknown as typeof fetch;
116
- const factory = createOpenApiProviderFactory({ fetchImpl });
117
-
118
- const err = await factory(ctx(cfg)).then(
119
- () => { throw new Error('expected rejection'); },
120
- (e: unknown) => e,
121
- );
122
- expect(isConnectorUpstreamUnavailable(err)).toBe(true);
123
- expect((err as Error).message).toMatch(/'pets' could not reach spec URL/);
124
- expect((err as Error).message).toContain('ECONNREFUSED');
125
- expect((err as { cause?: unknown }).cause).toBe(boom);
126
- });
127
-
128
- it.each([408, 429, 500, 502, 503, 504])(
129
- 'classifies a transient HTTP %i as upstream-unavailable (retryable)',
130
- async (status) => {
131
- const fetchImpl = (async () => ({ ok: false, status, json: async () => ({}) }) as unknown as Response) as unknown as typeof fetch;
132
- const factory = createOpenApiProviderFactory({ fetchImpl });
133
- const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
134
- expect(isConnectorUpstreamUnavailable(err), `HTTP ${status} should degrade`).toBe(true);
135
- expect((err as Error).message).toContain(String(status));
136
- },
137
- );
138
-
139
- it.each([400, 401, 403, 404, 410])(
140
- 'keeps a non-retryable HTTP %i a plain (fatal) configuration fault',
141
- async (status) => {
142
- const fetchImpl = (async () => ({ ok: false, status, json: async () => ({}) }) as unknown as Response) as unknown as typeof fetch;
143
- const factory = createOpenApiProviderFactory({ fetchImpl });
144
- const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
145
- expect(isConnectorUpstreamUnavailable(err), `HTTP ${status} should stay fatal`).toBe(false);
146
- expect((err as Error).message).toMatch(/failed to fetch spec/);
147
- },
148
- );
149
-
150
- it('keeps a 2xx-but-unparseable body a plain (fatal) content fault, not upstream-unavailable', async () => {
151
- const fetchImpl = (async () => ({
152
- ok: true,
153
- status: 200,
154
- json: async () => { throw new SyntaxError('Unexpected token < in JSON'); },
155
- }) as unknown as Response) as unknown as typeof fetch;
156
- const factory = createOpenApiProviderFactory({ fetchImpl });
157
- const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
158
- expect(isConnectorUpstreamUnavailable(err)).toBe(false);
159
- expect((err as Error).message).toMatch(/not a parseable.*OpenAPI JSON document/s);
160
- });
161
-
162
- it('keeps a 2xx non-object body (array) a plain (fatal) content fault', async () => {
163
- const fetchImpl = (async () => ({ ok: true, status: 200, json: async () => [1, 2] }) as unknown as Response) as unknown as typeof fetch;
164
- const factory = createOpenApiProviderFactory({ fetchImpl });
165
- const err = await factory(ctx(cfg)).then(() => { throw new Error('expected rejection'); }, (e: unknown) => e);
166
- expect(isConnectorUpstreamUnavailable(err)).toBe(false);
167
- expect((err as Error).message).toMatch(/not a parseable.*OpenAPI JSON document/s);
168
- });
169
- });