@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.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/AUTHORING.md +93 -0
- package/CHANGELOG.md +21 -0
- package/README.md +133 -28
- package/bin/apifuse-check.ts +78 -71
- package/bin/apifuse-create.ts +12 -0
- package/bin/apifuse-dev.ts +24 -61
- package/bin/apifuse-pack-check.ts +87 -0
- package/bin/apifuse-pack-smoke.ts +122 -0
- package/bin/apifuse-perf.ts +33 -32
- package/bin/apifuse-record.ts +17 -7
- package/bin/apifuse-test.ts +6 -4
- package/bin/apifuse.ts +36 -35
- package/package.json +29 -9
- package/src/ceremonies/index.ts +768 -0
- package/src/cli/commands.ts +87 -0
- package/src/cli/create.ts +845 -0
- package/src/cli/templates/provider/Dockerfile.tpl +7 -0
- package/src/cli/templates/provider/README.md.tpl +41 -0
- package/src/cli/templates/provider/dev.ts.tpl +5 -0
- package/src/cli/templates/provider/index.test.ts.tpl +13 -0
- package/src/cli/templates/provider/index.ts.tpl +58 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/config/loader.ts +61 -1
- package/src/define.ts +565 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +44 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +13 -0
- package/src/runtime/auth-flow.ts +67 -0
- package/src/runtime/credential.ts +95 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +13 -14
- package/src/runtime/http.ts +36 -12
- package/src/runtime/insights.ts +3 -3
- package/src/runtime/key-derivation.ts +122 -0
- package/src/runtime/keyring.ts +148 -0
- package/src/runtime/namespace.ts +33 -0
- package/src/runtime/prevalidate.ts +252 -0
- package/src/runtime/tls.ts +41 -17
- package/src/runtime/waterfall.ts +0 -1
- package/src/schema.ts +77 -0
- package/src/serve.ts +1 -664
- package/src/server/index.ts +22 -0
- package/src/server/serve.ts +624 -0
- package/src/server/types.ts +78 -0
- package/src/stealth/profiles.ts +10 -93
- package/src/testing/run.ts +391 -32
- package/src/types.ts +390 -41
- package/bin/apifuse-init.ts +0 -387
- package/src/__tests__/auth.test.ts +0 -396
- package/src/__tests__/browser-auth.test.ts +0 -180
- package/src/__tests__/browser.test.ts +0 -632
- package/src/__tests__/define.test.ts +0 -225
- package/src/__tests__/errors.test.ts +0 -69
- package/src/__tests__/executor.test.ts +0 -214
- package/src/__tests__/http.test.ts +0 -238
- package/src/__tests__/insights.test.ts +0 -210
- package/src/__tests__/instrumentation.test.ts +0 -290
- package/src/__tests__/otlp.test.ts +0 -141
- package/src/__tests__/perf.test.ts +0 -60
- package/src/__tests__/providers-yaml.test.ts +0 -135
- package/src/__tests__/proxy.test.ts +0 -359
- package/src/__tests__/recipes.test.ts +0 -36
- package/src/__tests__/serve.test.ts +0 -233
- package/src/__tests__/session.test.ts +0 -231
- package/src/__tests__/state.test.ts +0 -100
- package/src/__tests__/stealth.test.ts +0 -57
- package/src/__tests__/testing.test.ts +0 -97
- package/src/__tests__/tls.test.ts +0 -345
- package/src/__tests__/types.test.ts +0 -142
- package/src/__tests__/utils.test.ts +0 -62
- package/src/__tests__/waterfall.test.ts +0 -270
- package/src/config/providers-yaml.ts +0 -370
- package/src/index.test.ts +0 -1
- package/src/protocol.ts +0 -183
- package/src/runtime/auth.ts +0 -245
- package/src/runtime/session.ts +0 -573
- package/src/runtime/state.ts +0 -124
package/src/define.ts
CHANGED
|
@@ -1,40 +1,563 @@
|
|
|
1
|
-
import type { ZodType } from "zod";
|
|
2
1
|
import { ProviderError, ValidationError } from "./errors";
|
|
2
|
+
import { safeParseSchemaSync } from "./schema";
|
|
3
3
|
import type {
|
|
4
4
|
AuthConfig,
|
|
5
5
|
BrowserEngine,
|
|
6
|
+
ContextDeclaration,
|
|
7
|
+
CredentialDeclaration,
|
|
8
|
+
HealthCheckCase,
|
|
9
|
+
HealthCheckSuite,
|
|
10
|
+
HealthCheckUnsupported,
|
|
11
|
+
InferSchemaOutput,
|
|
6
12
|
OperationDefinition,
|
|
13
|
+
ProbeInterval,
|
|
7
14
|
ProviderDefinition,
|
|
15
|
+
ProviderHealthMonitorConfig,
|
|
16
|
+
ProviderReviewed,
|
|
17
|
+
ProviderSecretDeclaration,
|
|
18
|
+
SchemaLike,
|
|
8
19
|
StealthPlatform,
|
|
9
20
|
} from "./types";
|
|
21
|
+
import {
|
|
22
|
+
OPERATION_TIMEOUT_MS_MAX,
|
|
23
|
+
OPERATION_TIMEOUT_MS_MIN,
|
|
24
|
+
PROBE_INTERVALS,
|
|
25
|
+
} from "./types";
|
|
10
26
|
|
|
11
27
|
const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
|
|
28
|
+
const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
29
|
+
const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
|
|
30
|
+
const VALID_AUTH_MODES = [
|
|
31
|
+
"none",
|
|
32
|
+
"platform-managed",
|
|
33
|
+
"credentials",
|
|
34
|
+
"oauth2",
|
|
35
|
+
] as const;
|
|
36
|
+
const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
|
|
12
37
|
|
|
13
|
-
type ProviderOperation = OperationDefinition<
|
|
38
|
+
type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
|
|
39
|
+
type OperationConfig<
|
|
40
|
+
TInput extends SchemaLike,
|
|
41
|
+
TOutput extends SchemaLike,
|
|
42
|
+
> = Omit<OperationDefinition<TInput, TOutput>, "handler"> & {
|
|
43
|
+
handler(
|
|
44
|
+
ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
|
|
45
|
+
input: InferSchemaOutput<TInput>,
|
|
46
|
+
): Promise<InferSchemaOutput<TOutput>>;
|
|
47
|
+
};
|
|
48
|
+
type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
|
|
49
|
+
{
|
|
50
|
+
[K in keyof TOperations]: TOperations[K] extends OperationDefinition<
|
|
51
|
+
infer TInput,
|
|
52
|
+
infer TOutput
|
|
53
|
+
>
|
|
54
|
+
? OperationConfig<TInput, TOutput>
|
|
55
|
+
: never;
|
|
56
|
+
};
|
|
14
57
|
|
|
15
58
|
export interface ProviderConfig<
|
|
16
59
|
TOperations extends Record<string, ProviderOperation>,
|
|
17
60
|
> {
|
|
18
61
|
id: string;
|
|
19
62
|
version: string;
|
|
20
|
-
runtime: "standard" | "browser";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
platform: StealthPlatform;
|
|
24
|
-
};
|
|
63
|
+
runtime: "standard" | "shared" | "browser";
|
|
64
|
+
allowedHosts?: string[];
|
|
65
|
+
stealth?: { profile: string; platform: StealthPlatform };
|
|
25
66
|
proxy?: boolean;
|
|
26
|
-
browser?: {
|
|
27
|
-
engine: BrowserEngine;
|
|
28
|
-
};
|
|
67
|
+
browser?: { engine: BrowserEngine };
|
|
29
68
|
auth?: AuthConfig;
|
|
69
|
+
reviewed?: ProviderReviewed;
|
|
70
|
+
secrets?: ProviderSecretDeclaration[];
|
|
71
|
+
credential?: CredentialDeclaration;
|
|
72
|
+
context?: ContextDeclaration;
|
|
30
73
|
meta: {
|
|
31
74
|
displayName: string;
|
|
32
75
|
description?: string;
|
|
33
76
|
category: string;
|
|
34
77
|
tags?: string[];
|
|
35
78
|
icon?: string;
|
|
79
|
+
docTitle?: string;
|
|
80
|
+
docDescription?: string;
|
|
81
|
+
docSummary?: string;
|
|
82
|
+
normalizationNotes?: string[];
|
|
83
|
+
environment?: "staging";
|
|
84
|
+
purpose?: string;
|
|
36
85
|
};
|
|
37
|
-
operations: TOperations
|
|
86
|
+
operations: OperationMapConfig<TOperations>;
|
|
87
|
+
healthMonitor?: ProviderHealthMonitorConfig;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Define one provider operation with schema-driven handler inference. */
|
|
91
|
+
export function defineOperation<
|
|
92
|
+
TInput extends SchemaLike,
|
|
93
|
+
TOutput extends SchemaLike,
|
|
94
|
+
>(
|
|
95
|
+
operation: OperationConfig<TInput, TOutput>,
|
|
96
|
+
): OperationDefinition<TInput, TOutput> {
|
|
97
|
+
return operation;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertObjectConfig(
|
|
101
|
+
value: unknown,
|
|
102
|
+
): asserts value is Record<string, unknown> {
|
|
103
|
+
if (!value || typeof value !== "object") {
|
|
104
|
+
throw new ProviderError(
|
|
105
|
+
"defineProvider config must be an object. Offending field: config",
|
|
106
|
+
{
|
|
107
|
+
fix: "Pass defineProvider({ id, version, runtime, meta, operations })",
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function assertRequiredField(
|
|
113
|
+
config: Record<string, unknown>,
|
|
114
|
+
field: string,
|
|
115
|
+
providerId?: string,
|
|
116
|
+
): void {
|
|
117
|
+
if (!Object.hasOwn(config, field) || config[field] === undefined) {
|
|
118
|
+
const prefix = providerId ? `Provider "${providerId}"` : "Provider config";
|
|
119
|
+
throw new ProviderError(`${prefix} is missing required field "${field}"`, {
|
|
120
|
+
fix: `Add ${field} to defineProvider({ ... })`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function assertLiteralField<TValue extends string>(
|
|
125
|
+
value: string,
|
|
126
|
+
field: string,
|
|
127
|
+
validValues: readonly TValue[],
|
|
128
|
+
providerId: string,
|
|
129
|
+
): asserts value is TValue {
|
|
130
|
+
if (!validValues.some((validValue) => validValue === value)) {
|
|
131
|
+
throw new ProviderError(
|
|
132
|
+
`Provider "${providerId}" has invalid ${field}: "${value}". Expected one of: ${validValues.join(", ")}`,
|
|
133
|
+
{
|
|
134
|
+
fix: `Set ${field} to one of ${validValues.map((item) => `"${item}"`).join(", ")}`,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function validateProviderShape(config: unknown): void {
|
|
140
|
+
assertObjectConfig(config);
|
|
141
|
+
assertRequiredField(config, "id");
|
|
142
|
+
assertRequiredField(config, "version", String(config.id));
|
|
143
|
+
assertRequiredField(config, "runtime", String(config.id));
|
|
144
|
+
assertRequiredField(config, "meta", String(config.id));
|
|
145
|
+
assertRequiredField(config, "operations", String(config.id));
|
|
146
|
+
if (typeof config.runtime === "string")
|
|
147
|
+
assertLiteralField(
|
|
148
|
+
config.runtime,
|
|
149
|
+
"runtime",
|
|
150
|
+
VALID_RUNTIMES,
|
|
151
|
+
String(config.id),
|
|
152
|
+
);
|
|
153
|
+
const auth = config.auth;
|
|
154
|
+
if (
|
|
155
|
+
auth &&
|
|
156
|
+
typeof auth === "object" &&
|
|
157
|
+
"mode" in auth &&
|
|
158
|
+
typeof auth.mode === "string"
|
|
159
|
+
)
|
|
160
|
+
assertLiteralField(
|
|
161
|
+
auth.mode,
|
|
162
|
+
"auth.mode",
|
|
163
|
+
VALID_AUTH_MODES,
|
|
164
|
+
String(config.id),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
function validateOperationIds(
|
|
168
|
+
providerId: string,
|
|
169
|
+
operations: Record<string, ProviderOperation>,
|
|
170
|
+
): void {
|
|
171
|
+
for (const operationName of Object.keys(operations)) {
|
|
172
|
+
if (!OPERATION_ID_REGEX.test(operationName))
|
|
173
|
+
throw new ProviderError(
|
|
174
|
+
`Provider "${providerId}" has invalid operations.${operationName}: operation ids must be URL-safe and cannot contain slashes.`,
|
|
175
|
+
{
|
|
176
|
+
fix: `Rename operations.${operationName} to a lowercase URL-safe id such as "search-items"`,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
if (RESERVED_OPERATION_IDS.has(operationName))
|
|
180
|
+
throw new ProviderError(
|
|
181
|
+
`Provider "${providerId}" operation "${operationName}" conflicts with a reserved server path.`,
|
|
182
|
+
{
|
|
183
|
+
fix: `Rename operations.${operationName} to avoid /${operationName}`,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function validateOperationAnnotations(
|
|
189
|
+
providerId: string,
|
|
190
|
+
operations: Record<string, ProviderOperation>,
|
|
191
|
+
): void {
|
|
192
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
193
|
+
const annotations = operation.annotations;
|
|
194
|
+
if (!annotations) continue;
|
|
195
|
+
const timeoutMs = annotations.timeoutMs;
|
|
196
|
+
if (timeoutMs === undefined) continue;
|
|
197
|
+
const field = `operations.${operationName}.annotations.timeoutMs`;
|
|
198
|
+
if (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs))
|
|
199
|
+
throw new ValidationError(
|
|
200
|
+
`Provider "${providerId}" has invalid ${field}: must be an integer number of milliseconds.`,
|
|
201
|
+
{
|
|
202
|
+
fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] (milliseconds).`,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
if (
|
|
206
|
+
timeoutMs < OPERATION_TIMEOUT_MS_MIN ||
|
|
207
|
+
timeoutMs > OPERATION_TIMEOUT_MS_MAX
|
|
208
|
+
)
|
|
209
|
+
throw new ValidationError(
|
|
210
|
+
`Provider "${providerId}" has invalid ${field}: ${timeoutMs} is outside [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms.`,
|
|
211
|
+
{
|
|
212
|
+
fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms (the upper bound stays below the gateway/ALB ceiling).`,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const HEALTH_CHECK_SUITE_FIELDS = new Set([
|
|
219
|
+
"interval",
|
|
220
|
+
"timeoutMs",
|
|
221
|
+
"cases",
|
|
222
|
+
"requiresConnection",
|
|
223
|
+
]);
|
|
224
|
+
const HEALTH_CHECK_CASE_FIELDS = new Set([
|
|
225
|
+
"name",
|
|
226
|
+
"description",
|
|
227
|
+
"input",
|
|
228
|
+
"assertions",
|
|
229
|
+
"degradedThresholdMs",
|
|
230
|
+
"expectedStatus",
|
|
231
|
+
"enabled",
|
|
232
|
+
]);
|
|
233
|
+
const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
|
|
234
|
+
const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
|
|
235
|
+
"requiredSecrets",
|
|
236
|
+
"probeOverrides",
|
|
237
|
+
"serviceAccount",
|
|
238
|
+
]);
|
|
239
|
+
const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set(["interval"]);
|
|
240
|
+
|
|
241
|
+
function levenshtein(a: string, b: string): number {
|
|
242
|
+
const m = a.length;
|
|
243
|
+
const n = b.length;
|
|
244
|
+
if (m === 0) return n;
|
|
245
|
+
if (n === 0) return m;
|
|
246
|
+
const prev = new Array<number>(n + 1);
|
|
247
|
+
const curr = new Array<number>(n + 1);
|
|
248
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
249
|
+
for (let i = 1; i <= m; i++) {
|
|
250
|
+
curr[0] = i;
|
|
251
|
+
for (let j = 1; j <= n; j++) {
|
|
252
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
253
|
+
const deletion = (prev[j] ?? 0) + 1;
|
|
254
|
+
const insertion = (curr[j - 1] ?? 0) + 1;
|
|
255
|
+
const substitution = (prev[j - 1] ?? 0) + cost;
|
|
256
|
+
curr[j] = Math.min(deletion, insertion, substitution);
|
|
257
|
+
}
|
|
258
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
|
|
259
|
+
}
|
|
260
|
+
return prev[n] ?? 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function suggestField(
|
|
264
|
+
unknown: string,
|
|
265
|
+
candidates: ReadonlySet<string>,
|
|
266
|
+
): string | undefined {
|
|
267
|
+
let best: string | undefined;
|
|
268
|
+
let bestDist = 3;
|
|
269
|
+
for (const candidate of candidates) {
|
|
270
|
+
const dist = levenshtein(unknown, candidate);
|
|
271
|
+
if (dist < bestDist) {
|
|
272
|
+
bestDist = dist;
|
|
273
|
+
best = candidate;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return best;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function rejectUnknownFields(
|
|
280
|
+
value: Record<string, unknown>,
|
|
281
|
+
allowed: ReadonlySet<string>,
|
|
282
|
+
fieldPath: string,
|
|
283
|
+
): void {
|
|
284
|
+
for (const key of Object.keys(value)) {
|
|
285
|
+
if (allowed.has(key)) continue;
|
|
286
|
+
const hint = suggestField(key, allowed);
|
|
287
|
+
throw new ValidationError(
|
|
288
|
+
hint
|
|
289
|
+
? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
|
|
290
|
+
: `Unknown field "${key}" on ${fieldPath}.`,
|
|
291
|
+
{ fix: `Remove ${fieldPath}.${key} or rename it.` },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function validateProviderHealthMonitor(
|
|
297
|
+
providerId: string,
|
|
298
|
+
healthMonitor: unknown,
|
|
299
|
+
): void {
|
|
300
|
+
if (healthMonitor === undefined) return;
|
|
301
|
+
if (
|
|
302
|
+
!healthMonitor ||
|
|
303
|
+
typeof healthMonitor !== "object" ||
|
|
304
|
+
Array.isArray(healthMonitor)
|
|
305
|
+
)
|
|
306
|
+
throw new ValidationError(
|
|
307
|
+
`Provider "${providerId}" has invalid healthMonitor: must be an object.`,
|
|
308
|
+
{
|
|
309
|
+
fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
|
|
313
|
+
rejectUnknownFields(
|
|
314
|
+
healthMonitorRecord,
|
|
315
|
+
PROVIDER_HEALTH_MONITOR_FIELDS,
|
|
316
|
+
"healthMonitor",
|
|
317
|
+
);
|
|
318
|
+
const requiredSecrets = healthMonitorRecord.requiredSecrets;
|
|
319
|
+
if (requiredSecrets !== undefined) {
|
|
320
|
+
if (!Array.isArray(requiredSecrets))
|
|
321
|
+
throw new ValidationError(
|
|
322
|
+
`Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
|
|
323
|
+
);
|
|
324
|
+
for (const [index, secret] of requiredSecrets.entries()) {
|
|
325
|
+
if (typeof secret !== "string" || secret.length === 0)
|
|
326
|
+
throw new ValidationError(
|
|
327
|
+
`Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const probeOverrides = healthMonitorRecord.probeOverrides;
|
|
332
|
+
if (probeOverrides !== undefined) {
|
|
333
|
+
if (
|
|
334
|
+
!probeOverrides ||
|
|
335
|
+
typeof probeOverrides !== "object" ||
|
|
336
|
+
Array.isArray(probeOverrides)
|
|
337
|
+
)
|
|
338
|
+
throw new ValidationError(
|
|
339
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
|
|
340
|
+
);
|
|
341
|
+
for (const [probeId, override] of Object.entries(probeOverrides)) {
|
|
342
|
+
if (probeId.length === 0)
|
|
343
|
+
throw new ValidationError(
|
|
344
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
|
|
345
|
+
);
|
|
346
|
+
if (!override || typeof override !== "object" || Array.isArray(override))
|
|
347
|
+
throw new ValidationError(
|
|
348
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
|
|
349
|
+
);
|
|
350
|
+
const overrideRecord = Object.fromEntries(Object.entries(override));
|
|
351
|
+
rejectUnknownFields(
|
|
352
|
+
overrideRecord,
|
|
353
|
+
PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
|
|
354
|
+
`healthMonitor.probeOverrides["${probeId}"]`,
|
|
355
|
+
);
|
|
356
|
+
const interval = overrideRecord.interval;
|
|
357
|
+
const validProbeIntervals: readonly string[] = PROBE_INTERVALS;
|
|
358
|
+
if (
|
|
359
|
+
interval !== undefined &&
|
|
360
|
+
(typeof interval !== "string" ||
|
|
361
|
+
!validProbeIntervals.includes(interval))
|
|
362
|
+
)
|
|
363
|
+
throw new ValidationError(
|
|
364
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be one of ${PROBE_INTERVALS.join(", ")}.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const serviceAccount = healthMonitorRecord.serviceAccount;
|
|
369
|
+
if (
|
|
370
|
+
serviceAccount !== undefined &&
|
|
371
|
+
(typeof serviceAccount !== "string" || serviceAccount.length === 0)
|
|
372
|
+
)
|
|
373
|
+
throw new ValidationError(
|
|
374
|
+
`Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function validateHealthCheckCase(
|
|
379
|
+
providerId: string,
|
|
380
|
+
operationName: string,
|
|
381
|
+
caseValue: unknown,
|
|
382
|
+
caseIndex: number,
|
|
383
|
+
): void {
|
|
384
|
+
const fieldPath = `operations.${operationName}.healthCheck.cases[${caseIndex}]`;
|
|
385
|
+
if (!caseValue || typeof caseValue !== "object" || Array.isArray(caseValue))
|
|
386
|
+
throw new ValidationError(
|
|
387
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
388
|
+
);
|
|
389
|
+
rejectUnknownFields(
|
|
390
|
+
caseValue as Record<string, unknown>,
|
|
391
|
+
HEALTH_CHECK_CASE_FIELDS,
|
|
392
|
+
fieldPath,
|
|
393
|
+
);
|
|
394
|
+
const c = caseValue as HealthCheckCase;
|
|
395
|
+
if (typeof c.name !== "string" || c.name.length === 0)
|
|
396
|
+
throw new ValidationError(
|
|
397
|
+
`Provider "${providerId}" ${fieldPath}.name must be a non-empty string.`,
|
|
398
|
+
);
|
|
399
|
+
if (typeof c.assertions !== "function")
|
|
400
|
+
throw new ValidationError(
|
|
401
|
+
`Provider "${providerId}" ${fieldPath}.assertions must be a function.`,
|
|
402
|
+
{
|
|
403
|
+
fix: `Set ${fieldPath}.assertions to (ctx) => { ... } that throws on failure.`,
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
if (
|
|
407
|
+
c.degradedThresholdMs !== undefined &&
|
|
408
|
+
(typeof c.degradedThresholdMs !== "number" ||
|
|
409
|
+
!Number.isFinite(c.degradedThresholdMs) ||
|
|
410
|
+
c.degradedThresholdMs <= 0)
|
|
411
|
+
)
|
|
412
|
+
throw new ValidationError(
|
|
413
|
+
`Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be a positive number.`,
|
|
414
|
+
);
|
|
415
|
+
if (
|
|
416
|
+
c.expectedStatus !== undefined &&
|
|
417
|
+
c.expectedStatus !== "ok" &&
|
|
418
|
+
c.expectedStatus !== "degraded"
|
|
419
|
+
)
|
|
420
|
+
throw new ValidationError(
|
|
421
|
+
`Provider "${providerId}" ${fieldPath}.expectedStatus must be "ok" or "degraded".`,
|
|
422
|
+
);
|
|
423
|
+
if (c.enabled !== undefined && typeof c.enabled !== "function")
|
|
424
|
+
throw new ValidationError(
|
|
425
|
+
`Provider "${providerId}" ${fieldPath}.enabled must be a function returning boolean.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function validateHealthCheckSuite(
|
|
430
|
+
providerId: string,
|
|
431
|
+
operationName: string,
|
|
432
|
+
suite: unknown,
|
|
433
|
+
): void {
|
|
434
|
+
const fieldPath = `operations.${operationName}.healthCheck`;
|
|
435
|
+
if (!suite || typeof suite !== "object" || Array.isArray(suite))
|
|
436
|
+
throw new ValidationError(
|
|
437
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
438
|
+
);
|
|
439
|
+
rejectUnknownFields(
|
|
440
|
+
suite as Record<string, unknown>,
|
|
441
|
+
HEALTH_CHECK_SUITE_FIELDS,
|
|
442
|
+
fieldPath,
|
|
443
|
+
);
|
|
444
|
+
const s = suite as HealthCheckSuite;
|
|
445
|
+
if (
|
|
446
|
+
typeof s.interval !== "string" ||
|
|
447
|
+
!PROBE_INTERVALS.includes(s.interval as ProbeInterval)
|
|
448
|
+
)
|
|
449
|
+
throw new ValidationError(
|
|
450
|
+
`Provider "${providerId}" ${fieldPath}.interval must be one of ${PROBE_INTERVALS.join(", ")}.`,
|
|
451
|
+
{
|
|
452
|
+
fix: `Set ${fieldPath}.interval to a supported probe interval.`,
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
if (s.timeoutMs !== undefined) {
|
|
456
|
+
if (
|
|
457
|
+
typeof s.timeoutMs !== "number" ||
|
|
458
|
+
!Number.isInteger(s.timeoutMs) ||
|
|
459
|
+
s.timeoutMs <= 0
|
|
460
|
+
)
|
|
461
|
+
throw new ValidationError(
|
|
462
|
+
`Provider "${providerId}" ${fieldPath}.timeoutMs must be a positive integer (ms).`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
s.requiresConnection !== undefined &&
|
|
467
|
+
typeof s.requiresConnection !== "boolean"
|
|
468
|
+
)
|
|
469
|
+
throw new ValidationError(
|
|
470
|
+
`Provider "${providerId}" ${fieldPath}.requiresConnection must be a boolean.`,
|
|
471
|
+
);
|
|
472
|
+
if (!Array.isArray(s.cases) || s.cases.length === 0)
|
|
473
|
+
throw new ValidationError(
|
|
474
|
+
`Provider "${providerId}" ${fieldPath}.cases must be a non-empty array.`,
|
|
475
|
+
{
|
|
476
|
+
fix: `Add at least one HealthCheckCase to ${fieldPath}.cases.`,
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
const seenNames = new Set<string>();
|
|
480
|
+
for (const [index, caseValue] of s.cases.entries()) {
|
|
481
|
+
validateHealthCheckCase(providerId, operationName, caseValue, index);
|
|
482
|
+
const name = (caseValue as HealthCheckCase).name;
|
|
483
|
+
if (seenNames.has(name))
|
|
484
|
+
throw new ValidationError(
|
|
485
|
+
`Provider "${providerId}" ${fieldPath}.cases has duplicate case name "${name}".`,
|
|
486
|
+
{
|
|
487
|
+
fix: `Rename one of the duplicate cases to be unique within the suite.`,
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
seenNames.add(name);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function validateHealthCheckUnsupported(
|
|
495
|
+
providerId: string,
|
|
496
|
+
operationName: string,
|
|
497
|
+
unsupported: unknown,
|
|
498
|
+
): void {
|
|
499
|
+
const fieldPath = `operations.${operationName}.healthCheckUnsupported`;
|
|
500
|
+
if (
|
|
501
|
+
!unsupported ||
|
|
502
|
+
typeof unsupported !== "object" ||
|
|
503
|
+
Array.isArray(unsupported)
|
|
504
|
+
)
|
|
505
|
+
throw new ValidationError(
|
|
506
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
507
|
+
);
|
|
508
|
+
rejectUnknownFields(
|
|
509
|
+
unsupported as Record<string, unknown>,
|
|
510
|
+
HEALTH_CHECK_UNSUPPORTED_FIELDS,
|
|
511
|
+
fieldPath,
|
|
512
|
+
);
|
|
513
|
+
const u = unsupported as HealthCheckUnsupported;
|
|
514
|
+
if (typeof u.reason !== "string" || u.reason.trim().length === 0)
|
|
515
|
+
throw new ValidationError(
|
|
516
|
+
`Provider "${providerId}" ${fieldPath}.reason must be a non-empty string.`,
|
|
517
|
+
{
|
|
518
|
+
fix: `Document why the operation cannot be probed (e.g., "Destructive mutation; cannot probe in production").`,
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
if (u.trackedIn !== undefined && typeof u.trackedIn !== "string")
|
|
522
|
+
throw new ValidationError(
|
|
523
|
+
`Provider "${providerId}" ${fieldPath}.trackedIn must be a string when present.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function validateOperationHealthChecks(
|
|
528
|
+
providerId: string,
|
|
529
|
+
operations: Record<string, ProviderOperation>,
|
|
530
|
+
): void {
|
|
531
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
532
|
+
const hasCheck = operation.healthCheck !== undefined;
|
|
533
|
+
const hasUnsupported = operation.healthCheckUnsupported !== undefined;
|
|
534
|
+
if (hasCheck && hasUnsupported)
|
|
535
|
+
throw new ValidationError(
|
|
536
|
+
`Provider "${providerId}" operation "${operationName}" declares both healthCheck and healthCheckUnsupported. Choose exactly one.`,
|
|
537
|
+
{
|
|
538
|
+
fix: `Remove either operations.${operationName}.healthCheck or operations.${operationName}.healthCheckUnsupported.`,
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
if (hasCheck)
|
|
542
|
+
validateHealthCheckSuite(
|
|
543
|
+
providerId,
|
|
544
|
+
operationName,
|
|
545
|
+
operation.healthCheck,
|
|
546
|
+
);
|
|
547
|
+
if (hasUnsupported)
|
|
548
|
+
validateHealthCheckUnsupported(
|
|
549
|
+
providerId,
|
|
550
|
+
operationName,
|
|
551
|
+
operation.healthCheckUnsupported,
|
|
552
|
+
);
|
|
553
|
+
if (!hasCheck && !hasUnsupported)
|
|
554
|
+
throw new ValidationError(
|
|
555
|
+
`Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
|
|
556
|
+
{
|
|
557
|
+
fix: `Add \`healthCheck: { interval, cases: [...] }\` or \`healthCheckUnsupported: { reason: "..." }\` to operations.${operationName}.`,
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
}
|
|
38
561
|
}
|
|
39
562
|
|
|
40
563
|
function validateOperationFixtures(
|
|
@@ -42,19 +565,20 @@ function validateOperationFixtures(
|
|
|
42
565
|
operations: Record<string, ProviderOperation>,
|
|
43
566
|
): void {
|
|
44
567
|
for (const [operationName, operation] of Object.entries(operations)) {
|
|
45
|
-
if (typeof operation.handler !== "function")
|
|
568
|
+
if (typeof operation.handler !== "function")
|
|
46
569
|
throw new ValidationError(
|
|
47
570
|
`Operation handler must be defined for provider "${providerId}" operation "${operationName}"`,
|
|
48
571
|
{
|
|
49
572
|
fix: `Add operations.${operationName}.handler as an async function with signature (ctx, input) => Promise<output>`,
|
|
50
573
|
},
|
|
51
574
|
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
575
|
if (operation.fixtures?.request !== undefined) {
|
|
55
|
-
const result =
|
|
56
|
-
|
|
57
|
-
|
|
576
|
+
const result = safeParseSchemaSync(
|
|
577
|
+
operation.input,
|
|
578
|
+
operation.fixtures.request,
|
|
579
|
+
`operations.${operationName}.fixtures.request`,
|
|
580
|
+
);
|
|
581
|
+
if (!result.success)
|
|
58
582
|
throw new ValidationError(
|
|
59
583
|
`Fixture request does not match input schema for provider "${providerId}" operation "${operationName}"`,
|
|
60
584
|
{
|
|
@@ -62,13 +586,14 @@ function validateOperationFixtures(
|
|
|
62
586
|
zodError: result.error,
|
|
63
587
|
},
|
|
64
588
|
);
|
|
65
|
-
}
|
|
66
589
|
}
|
|
67
|
-
|
|
68
590
|
if (operation.fixtures?.response !== undefined) {
|
|
69
|
-
const result =
|
|
70
|
-
|
|
71
|
-
|
|
591
|
+
const result = safeParseSchemaSync(
|
|
592
|
+
operation.output,
|
|
593
|
+
operation.fixtures.response,
|
|
594
|
+
`operations.${operationName}.fixtures.response`,
|
|
595
|
+
);
|
|
596
|
+
if (!result.success)
|
|
72
597
|
throw new ValidationError(
|
|
73
598
|
`Fixture response does not match output schema for provider "${providerId}" operation "${operationName}"`,
|
|
74
599
|
{
|
|
@@ -76,7 +601,6 @@ function validateOperationFixtures(
|
|
|
76
601
|
zodError: result.error,
|
|
77
602
|
},
|
|
78
603
|
);
|
|
79
|
-
}
|
|
80
604
|
}
|
|
81
605
|
}
|
|
82
606
|
}
|
|
@@ -85,53 +609,53 @@ export function defineProvider<
|
|
|
85
609
|
TOperations extends Record<string, ProviderOperation>,
|
|
86
610
|
>(
|
|
87
611
|
config: ProviderConfig<TOperations>,
|
|
88
|
-
): ProviderDefinition & { operations: TOperations } {
|
|
89
|
-
|
|
612
|
+
): ProviderDefinition & { operations: OperationMapConfig<TOperations> } {
|
|
613
|
+
validateProviderShape(config);
|
|
614
|
+
if (!CONNECTOR_ID_REGEX.test(config.id))
|
|
90
615
|
throw new ProviderError(`Invalid provider id: "${config.id}"`, {
|
|
91
|
-
fix: 'Use lowercase alphanumeric with dashes, e.g., "
|
|
616
|
+
fix: 'Use lowercase alphanumeric with dashes, e.g., "airkorea-realtime"',
|
|
92
617
|
});
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (Object.keys(config.operations).length === 0) {
|
|
618
|
+
if (Object.keys(config.operations).length === 0)
|
|
96
619
|
throw new ProviderError(
|
|
97
620
|
`Provider "${config.id}" must define at least one operation`,
|
|
98
621
|
{ fix: "Add at least one operation to the operations object" },
|
|
99
622
|
);
|
|
100
|
-
|
|
101
|
-
|
|
623
|
+
validateOperationIds(config.id, config.operations);
|
|
624
|
+
validateOperationAnnotations(config.id, config.operations);
|
|
625
|
+
validateOperationHealthChecks(config.id, config.operations);
|
|
626
|
+
validateProviderHealthMonitor(config.id, config.healthMonitor);
|
|
102
627
|
validateOperationFixtures(config.id, config.operations);
|
|
103
|
-
|
|
104
|
-
if (config.runtime === "browser" && !config.browser) {
|
|
628
|
+
if (config.runtime === "browser" && !config.browser)
|
|
105
629
|
throw new ProviderError(
|
|
106
630
|
`Provider "${config.id}" must define browser.engine when runtime is "browser"`,
|
|
107
631
|
{
|
|
108
632
|
fix: 'Add browser: { engine: "nodriver" } or another supported engine',
|
|
109
633
|
},
|
|
110
634
|
);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (config.browser && config.runtime !== "browser") {
|
|
635
|
+
if (config.browser && config.runtime !== "browser")
|
|
114
636
|
throw new ProviderError(
|
|
115
637
|
`Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
|
|
116
638
|
{ fix: 'Set runtime: "browser" or remove the browser config' },
|
|
117
639
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (config.proxy && !config.stealth) {
|
|
640
|
+
if (config.proxy && !config.stealth)
|
|
121
641
|
console.warn(
|
|
122
642
|
`[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
|
|
123
643
|
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
644
|
return {
|
|
127
645
|
id: config.id,
|
|
128
646
|
version: config.version,
|
|
129
647
|
runtime: config.runtime,
|
|
648
|
+
allowedHosts: config.allowedHosts,
|
|
130
649
|
stealth: config.stealth,
|
|
131
650
|
proxy: config.proxy,
|
|
132
651
|
browser: config.browser,
|
|
133
652
|
auth: config.auth,
|
|
653
|
+
reviewed: config.reviewed,
|
|
654
|
+
secrets: config.secrets,
|
|
655
|
+
credential: config.credential,
|
|
656
|
+
context: config.context,
|
|
134
657
|
meta: config.meta,
|
|
135
658
|
operations: config.operations,
|
|
659
|
+
healthMonitor: config.healthMonitor,
|
|
136
660
|
};
|
|
137
661
|
}
|