-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathopenapi-provider.ts
More file actions
193 lines (186 loc) · 8.42 KB
/
Copy pathopenapi-provider.ts
File metadata and controls
193 lines (186 loc) · 8.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type {
ConnectorProviderContext,
ConnectorProviderFactory,
ResolvedConnectorAuth,
} from '@objectstack/spec/integration';
import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration';
import {
createOpenApiConnector,
type OpenApiDocument,
type RestAuth,
} from './openapi-connector.js';
/**
* The provider key this package contributes (ADR-0097). A declarative
* `connectors:` entry with `provider: 'openapi'` is materialized by this factory.
*/
export const OPENAPI_PROVIDER_KEY = 'openapi';
/**
* HTTP statuses treated as **transient** when fetching a remote spec at boot
* (#3049 follow-up): a timeout / rate-limit / 5xx means the spec endpoint is
* momentarily unhealthy, not that the connector is misconfigured — so the
* instance degrades and retries rather than aborting boot. Mirrors the
* connector request-retry convention (`retryableStatusCodes` default in
* `ConnectorSchema`). Any OTHER non-2xx (400 / 401 / 403 / 404 / 410 …) means
* the request itself is wrong — a configuration fault that stays fatal.
*/
const SPEC_FETCH_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
/** Injectable dependencies for {@link createOpenApiProviderFactory} (tests). */
export interface OpenApiProviderDeps {
/** Injected fetch implementation (spec fetch + request transport); defaults to global `fetch`. */
fetchImpl?: typeof fetch;
}
/** Shape of `providerConfig` for a `provider: 'openapi'` declarative instance. */
interface OpenApiProviderConfig {
/**
* The OpenAPI 3.x document: an inline object, an http(s) URL to fetch at
* boot, or a file path resolved relative to the declaring stack/package root
* (`'./billing-openapi.json'`, #3016).
*
* A remote URL that is unreachable / transiently failing (network error,
* 408 / 429 / 5xx) degrades the instance and retries (#3049); a wrong URL
* (non-retryable 4xx) or an unparseable document stays a fatal config fault.
*/
spec?: unknown;
/** Override the base URL (else the document's `servers[0].url`). */
baseUrl?: unknown;
}
/**
* Resolve `providerConfig.spec` into a parsed OpenAPI document (ADR-0097;
* union per #3016): an inline document object (the reliable, no-I/O-at-boot
* form used by the showcase), an http(s) URL fetched at materialization, or a
* **file path** read through the host's `ctx.loadPackageFile` — which resolves
* it relative to the declaring stack/package root and confines the read to
* that root (absolute / `..`-escaping paths are rejected there).
*
* Fault classification (#3049 seam, symmetric with connector-mcp's connect
* path): a remote spec URL that is unreachable or transiently failing throws
* {@link ConnectorUpstreamUnavailableError} — the materializer degrades the
* instance and retries. Every OTHER failure (missing spec, a wrong URL,
* an unparseable document, no host file access) throws a plain error, which
* stays fatal at boot / a skipped entry on reload.
*/
async function loadOpenApiDocument(
spec: unknown,
fetchImpl: typeof fetch | undefined,
ctx: ConnectorProviderContext,
): Promise<OpenApiDocument> {
const connectorName = ctx.name;
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
return spec as OpenApiDocument;
}
if (typeof spec === 'string' && spec.length > 0) {
if (/^https?:\/\//i.test(spec)) {
const doFetch = fetchImpl ?? fetch;
let res: Response;
try {
res = await doFetch(spec);
} catch (err) {
// The spec endpoint is unreachable — DNS / connection refused /
// timeout / network error. Operational, not a config mistake: degrade
// + retry rather than fail boot.
throw new ConnectorUpstreamUnavailableError(
`connector-openapi provider: connector '${connectorName}' could not reach spec URL '${spec}': ${(err as Error).message}`,
{ cause: err },
);
}
if (!res.ok) {
if (SPEC_FETCH_RETRYABLE_STATUS.has(res.status)) {
// A transient server-side status (timeout / rate-limit / 5xx) — the
// endpoint is momentarily unhealthy, so treat it as upstream-unavailable.
throw new ConnectorUpstreamUnavailableError(
`connector-openapi provider: connector '${connectorName}' got a transient HTTP ${res.status} fetching spec '${spec}'.`,
);
}
// A non-retryable status (400 / 401 / 403 / 404 / 410 …) — the request
// is wrong (bad URL, missing/insufficient auth for the spec endpoint):
// a configuration fault that stays fatal.
throw new Error(
`connector-openapi provider: connector '${connectorName}' failed to fetch spec '${spec}' (HTTP ${res.status}).`,
);
}
// A 2xx with an unparseable / non-object body is a content fault (the
// endpoint served the wrong thing) — fatal, symmetric with the file-path
// parse below.
try {
const parsed: unknown = await res.json();
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('not a JSON object');
}
return parsed as OpenApiDocument;
} catch (err) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' fetched spec '${spec}' but it is not a parseable ` +
`OpenAPI JSON document: ${(err as Error).message}`,
);
}
}
// File path — dereferenced through the host capability so resolution stays
// anchored to (and confined within) the declaring stack/package root.
if (!ctx.loadPackageFile) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is a file path, ` +
`but this host provides no package file access — inline the OpenAPI document or use an http(s) URL.`,
);
}
let text: string;
try {
text = await ctx.loadPackageFile(spec);
} catch (err) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' failed to read providerConfig.spec '${spec}': ` +
`${(err as Error).message}`,
);
}
try {
const parsed: unknown = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('not a JSON object');
}
return parsed as OpenApiDocument;
} catch (err) {
throw new Error(
`connector-openapi provider: connector '${connectorName}' providerConfig.spec '${spec}' is not a parseable ` +
`OpenAPI JSON document: ${(err as Error).message}`,
);
}
}
throw new Error(
`connector-openapi provider: connector '${connectorName}' requires providerConfig.spec — an inline OpenAPI 3.x ` +
`document object, an http(s) URL, or a package-relative file path.`,
);
}
/**
* Build the `openapi` {@link ConnectorProviderFactory} (ADR-0097 / ADR-0023). At
* boot the automation service invokes it for each `provider: 'openapi'`
* declarative instance: it loads the OpenAPI document from `providerConfig.spec`,
* then produces the same `{ def, handlers }` bundle {@link createOpenApiConnector}
* generates for a hand-wired OpenAPI connector — one action per operation over a
* static-auth HTTP transport, with the resolved `auth` applied.
*
* Hard-fails on invalid config (missing spec, a wrong spec URL, an unparseable
* document, a bad base URL), so a misconfigured instance fails boot loudly. A
* remote spec URL that is merely unreachable / transiently failing instead
* degrades the instance and retries (#3049) — see {@link loadOpenApiDocument}.
*/
export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): ConnectorProviderFactory {
return async (ctx) => {
const cfg = (ctx.providerConfig ?? {}) as OpenApiProviderConfig;
if (cfg.baseUrl !== undefined && typeof cfg.baseUrl !== 'string') {
throw new Error(
`connector-openapi provider: connector '${ctx.name}' providerConfig.baseUrl must be a string when set.`,
);
}
const document = await loadOpenApiDocument(cfg.spec, deps.fetchImpl, ctx);
const auth = ctx.auth as ResolvedConnectorAuth | undefined as RestAuth | undefined;
return createOpenApiConnector({
name: ctx.name,
label: ctx.label,
description: ctx.description,
document,
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : undefined,
auth,
fetchImpl: deps.fetchImpl,
});
};
}