@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.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/AUTHORING.md +102 -0
- package/CHANGELOG.md +14 -0
- package/README.md +100 -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 +47 -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 +28 -9
- package/src/ceremonies/index.ts +747 -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 +28 -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 +54 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/composite.ts +43 -0
- package/src/define.ts +527 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +50 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +14 -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 +10 -2
- 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 +20 -5
- 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 +610 -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 +364 -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,525 @@
|
|
|
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
|
+
"serviceAccount",
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
function levenshtein(a: string, b: string): number {
|
|
240
|
+
const m = a.length;
|
|
241
|
+
const n = b.length;
|
|
242
|
+
if (m === 0) return n;
|
|
243
|
+
if (n === 0) return m;
|
|
244
|
+
const prev = new Array<number>(n + 1);
|
|
245
|
+
const curr = new Array<number>(n + 1);
|
|
246
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
247
|
+
for (let i = 1; i <= m; i++) {
|
|
248
|
+
curr[0] = i;
|
|
249
|
+
for (let j = 1; j <= n; j++) {
|
|
250
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
251
|
+
const deletion = (prev[j] ?? 0) + 1;
|
|
252
|
+
const insertion = (curr[j - 1] ?? 0) + 1;
|
|
253
|
+
const substitution = (prev[j - 1] ?? 0) + cost;
|
|
254
|
+
curr[j] = Math.min(deletion, insertion, substitution);
|
|
255
|
+
}
|
|
256
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
|
|
257
|
+
}
|
|
258
|
+
return prev[n] ?? 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function suggestField(
|
|
262
|
+
unknown: string,
|
|
263
|
+
candidates: ReadonlySet<string>,
|
|
264
|
+
): string | undefined {
|
|
265
|
+
let best: string | undefined;
|
|
266
|
+
let bestDist = 3;
|
|
267
|
+
for (const candidate of candidates) {
|
|
268
|
+
const dist = levenshtein(unknown, candidate);
|
|
269
|
+
if (dist < bestDist) {
|
|
270
|
+
bestDist = dist;
|
|
271
|
+
best = candidate;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return best;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function rejectUnknownFields(
|
|
278
|
+
value: Record<string, unknown>,
|
|
279
|
+
allowed: ReadonlySet<string>,
|
|
280
|
+
fieldPath: string,
|
|
281
|
+
): void {
|
|
282
|
+
for (const key of Object.keys(value)) {
|
|
283
|
+
if (allowed.has(key)) continue;
|
|
284
|
+
const hint = suggestField(key, allowed);
|
|
285
|
+
throw new ValidationError(
|
|
286
|
+
hint
|
|
287
|
+
? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
|
|
288
|
+
: `Unknown field "${key}" on ${fieldPath}.`,
|
|
289
|
+
{ fix: `Remove ${fieldPath}.${key} or rename it.` },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateProviderHealthMonitor(
|
|
295
|
+
providerId: string,
|
|
296
|
+
healthMonitor: unknown,
|
|
297
|
+
): void {
|
|
298
|
+
if (healthMonitor === undefined) return;
|
|
299
|
+
if (
|
|
300
|
+
!healthMonitor ||
|
|
301
|
+
typeof healthMonitor !== "object" ||
|
|
302
|
+
Array.isArray(healthMonitor)
|
|
303
|
+
)
|
|
304
|
+
throw new ValidationError(
|
|
305
|
+
`Provider "${providerId}" has invalid healthMonitor: must be an object.`,
|
|
306
|
+
{
|
|
307
|
+
fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
rejectUnknownFields(
|
|
311
|
+
healthMonitor as Record<string, unknown>,
|
|
312
|
+
PROVIDER_HEALTH_MONITOR_FIELDS,
|
|
313
|
+
"healthMonitor",
|
|
314
|
+
);
|
|
315
|
+
const requiredSecrets = (healthMonitor as ProviderHealthMonitorConfig)
|
|
316
|
+
.requiredSecrets;
|
|
317
|
+
if (requiredSecrets !== undefined) {
|
|
318
|
+
if (!Array.isArray(requiredSecrets))
|
|
319
|
+
throw new ValidationError(
|
|
320
|
+
`Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
|
|
321
|
+
);
|
|
322
|
+
for (const [index, secret] of requiredSecrets.entries()) {
|
|
323
|
+
if (typeof secret !== "string" || secret.length === 0)
|
|
324
|
+
throw new ValidationError(
|
|
325
|
+
`Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const serviceAccount = (healthMonitor as ProviderHealthMonitorConfig)
|
|
330
|
+
.serviceAccount;
|
|
331
|
+
if (
|
|
332
|
+
serviceAccount !== undefined &&
|
|
333
|
+
(typeof serviceAccount !== "string" || serviceAccount.length === 0)
|
|
334
|
+
)
|
|
335
|
+
throw new ValidationError(
|
|
336
|
+
`Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function validateHealthCheckCase(
|
|
341
|
+
providerId: string,
|
|
342
|
+
operationName: string,
|
|
343
|
+
caseValue: unknown,
|
|
344
|
+
caseIndex: number,
|
|
345
|
+
): void {
|
|
346
|
+
const fieldPath = `operations.${operationName}.healthCheck.cases[${caseIndex}]`;
|
|
347
|
+
if (!caseValue || typeof caseValue !== "object" || Array.isArray(caseValue))
|
|
348
|
+
throw new ValidationError(
|
|
349
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
350
|
+
);
|
|
351
|
+
rejectUnknownFields(
|
|
352
|
+
caseValue as Record<string, unknown>,
|
|
353
|
+
HEALTH_CHECK_CASE_FIELDS,
|
|
354
|
+
fieldPath,
|
|
355
|
+
);
|
|
356
|
+
const c = caseValue as HealthCheckCase;
|
|
357
|
+
if (typeof c.name !== "string" || c.name.length === 0)
|
|
358
|
+
throw new ValidationError(
|
|
359
|
+
`Provider "${providerId}" ${fieldPath}.name must be a non-empty string.`,
|
|
360
|
+
);
|
|
361
|
+
if (typeof c.assertions !== "function")
|
|
362
|
+
throw new ValidationError(
|
|
363
|
+
`Provider "${providerId}" ${fieldPath}.assertions must be a function.`,
|
|
364
|
+
{
|
|
365
|
+
fix: `Set ${fieldPath}.assertions to (ctx) => { ... } that throws on failure.`,
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
if (
|
|
369
|
+
c.degradedThresholdMs !== undefined &&
|
|
370
|
+
(typeof c.degradedThresholdMs !== "number" ||
|
|
371
|
+
!Number.isFinite(c.degradedThresholdMs) ||
|
|
372
|
+
c.degradedThresholdMs <= 0)
|
|
373
|
+
)
|
|
374
|
+
throw new ValidationError(
|
|
375
|
+
`Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be a positive number.`,
|
|
376
|
+
);
|
|
377
|
+
if (
|
|
378
|
+
c.expectedStatus !== undefined &&
|
|
379
|
+
c.expectedStatus !== "ok" &&
|
|
380
|
+
c.expectedStatus !== "degraded"
|
|
381
|
+
)
|
|
382
|
+
throw new ValidationError(
|
|
383
|
+
`Provider "${providerId}" ${fieldPath}.expectedStatus must be "ok" or "degraded".`,
|
|
384
|
+
);
|
|
385
|
+
if (c.enabled !== undefined && typeof c.enabled !== "function")
|
|
386
|
+
throw new ValidationError(
|
|
387
|
+
`Provider "${providerId}" ${fieldPath}.enabled must be a function returning boolean.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function validateHealthCheckSuite(
|
|
392
|
+
providerId: string,
|
|
393
|
+
operationName: string,
|
|
394
|
+
suite: unknown,
|
|
395
|
+
): void {
|
|
396
|
+
const fieldPath = `operations.${operationName}.healthCheck`;
|
|
397
|
+
if (!suite || typeof suite !== "object" || Array.isArray(suite))
|
|
398
|
+
throw new ValidationError(
|
|
399
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
400
|
+
);
|
|
401
|
+
rejectUnknownFields(
|
|
402
|
+
suite as Record<string, unknown>,
|
|
403
|
+
HEALTH_CHECK_SUITE_FIELDS,
|
|
404
|
+
fieldPath,
|
|
405
|
+
);
|
|
406
|
+
const s = suite as HealthCheckSuite;
|
|
407
|
+
if (
|
|
408
|
+
typeof s.interval !== "string" ||
|
|
409
|
+
!PROBE_INTERVALS.includes(s.interval as ProbeInterval)
|
|
410
|
+
)
|
|
411
|
+
throw new ValidationError(
|
|
412
|
+
`Provider "${providerId}" ${fieldPath}.interval must be one of ${PROBE_INTERVALS.join(", ")}.`,
|
|
413
|
+
{
|
|
414
|
+
fix: `Set ${fieldPath}.interval to a supported probe interval.`,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
if (s.timeoutMs !== undefined) {
|
|
418
|
+
if (
|
|
419
|
+
typeof s.timeoutMs !== "number" ||
|
|
420
|
+
!Number.isInteger(s.timeoutMs) ||
|
|
421
|
+
s.timeoutMs <= 0
|
|
422
|
+
)
|
|
423
|
+
throw new ValidationError(
|
|
424
|
+
`Provider "${providerId}" ${fieldPath}.timeoutMs must be a positive integer (ms).`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
s.requiresConnection !== undefined &&
|
|
429
|
+
typeof s.requiresConnection !== "boolean"
|
|
430
|
+
)
|
|
431
|
+
throw new ValidationError(
|
|
432
|
+
`Provider "${providerId}" ${fieldPath}.requiresConnection must be a boolean.`,
|
|
433
|
+
);
|
|
434
|
+
if (!Array.isArray(s.cases) || s.cases.length === 0)
|
|
435
|
+
throw new ValidationError(
|
|
436
|
+
`Provider "${providerId}" ${fieldPath}.cases must be a non-empty array.`,
|
|
437
|
+
{
|
|
438
|
+
fix: `Add at least one HealthCheckCase to ${fieldPath}.cases.`,
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
const seenNames = new Set<string>();
|
|
442
|
+
for (const [index, caseValue] of s.cases.entries()) {
|
|
443
|
+
validateHealthCheckCase(providerId, operationName, caseValue, index);
|
|
444
|
+
const name = (caseValue as HealthCheckCase).name;
|
|
445
|
+
if (seenNames.has(name))
|
|
446
|
+
throw new ValidationError(
|
|
447
|
+
`Provider "${providerId}" ${fieldPath}.cases has duplicate case name "${name}".`,
|
|
448
|
+
{
|
|
449
|
+
fix: `Rename one of the duplicate cases to be unique within the suite.`,
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
seenNames.add(name);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function validateHealthCheckUnsupported(
|
|
457
|
+
providerId: string,
|
|
458
|
+
operationName: string,
|
|
459
|
+
unsupported: unknown,
|
|
460
|
+
): void {
|
|
461
|
+
const fieldPath = `operations.${operationName}.healthCheckUnsupported`;
|
|
462
|
+
if (
|
|
463
|
+
!unsupported ||
|
|
464
|
+
typeof unsupported !== "object" ||
|
|
465
|
+
Array.isArray(unsupported)
|
|
466
|
+
)
|
|
467
|
+
throw new ValidationError(
|
|
468
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
469
|
+
);
|
|
470
|
+
rejectUnknownFields(
|
|
471
|
+
unsupported as Record<string, unknown>,
|
|
472
|
+
HEALTH_CHECK_UNSUPPORTED_FIELDS,
|
|
473
|
+
fieldPath,
|
|
474
|
+
);
|
|
475
|
+
const u = unsupported as HealthCheckUnsupported;
|
|
476
|
+
if (typeof u.reason !== "string" || u.reason.trim().length === 0)
|
|
477
|
+
throw new ValidationError(
|
|
478
|
+
`Provider "${providerId}" ${fieldPath}.reason must be a non-empty string.`,
|
|
479
|
+
{
|
|
480
|
+
fix: `Document why the operation cannot be probed (e.g., "Destructive mutation; cannot probe in production").`,
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
if (u.trackedIn !== undefined && typeof u.trackedIn !== "string")
|
|
484
|
+
throw new ValidationError(
|
|
485
|
+
`Provider "${providerId}" ${fieldPath}.trackedIn must be a string when present.`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validateOperationHealthChecks(
|
|
490
|
+
providerId: string,
|
|
491
|
+
operations: Record<string, ProviderOperation>,
|
|
492
|
+
): void {
|
|
493
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
494
|
+
const hasCheck = operation.healthCheck !== undefined;
|
|
495
|
+
const hasUnsupported = operation.healthCheckUnsupported !== undefined;
|
|
496
|
+
if (hasCheck && hasUnsupported)
|
|
497
|
+
throw new ValidationError(
|
|
498
|
+
`Provider "${providerId}" operation "${operationName}" declares both healthCheck and healthCheckUnsupported. Choose exactly one.`,
|
|
499
|
+
{
|
|
500
|
+
fix: `Remove either operations.${operationName}.healthCheck or operations.${operationName}.healthCheckUnsupported.`,
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
if (hasCheck)
|
|
504
|
+
validateHealthCheckSuite(
|
|
505
|
+
providerId,
|
|
506
|
+
operationName,
|
|
507
|
+
operation.healthCheck,
|
|
508
|
+
);
|
|
509
|
+
if (hasUnsupported)
|
|
510
|
+
validateHealthCheckUnsupported(
|
|
511
|
+
providerId,
|
|
512
|
+
operationName,
|
|
513
|
+
operation.healthCheckUnsupported,
|
|
514
|
+
);
|
|
515
|
+
if (!hasCheck && !hasUnsupported)
|
|
516
|
+
throw new ValidationError(
|
|
517
|
+
`Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
|
|
518
|
+
{
|
|
519
|
+
fix: `Add \`healthCheck: { interval, cases: [...] }\` or \`healthCheckUnsupported: { reason: "..." }\` to operations.${operationName}.`,
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
}
|
|
38
523
|
}
|
|
39
524
|
|
|
40
525
|
function validateOperationFixtures(
|
|
@@ -42,19 +527,20 @@ function validateOperationFixtures(
|
|
|
42
527
|
operations: Record<string, ProviderOperation>,
|
|
43
528
|
): void {
|
|
44
529
|
for (const [operationName, operation] of Object.entries(operations)) {
|
|
45
|
-
if (typeof operation.handler !== "function")
|
|
530
|
+
if (typeof operation.handler !== "function")
|
|
46
531
|
throw new ValidationError(
|
|
47
532
|
`Operation handler must be defined for provider "${providerId}" operation "${operationName}"`,
|
|
48
533
|
{
|
|
49
534
|
fix: `Add operations.${operationName}.handler as an async function with signature (ctx, input) => Promise<output>`,
|
|
50
535
|
},
|
|
51
536
|
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
537
|
if (operation.fixtures?.request !== undefined) {
|
|
55
|
-
const result =
|
|
56
|
-
|
|
57
|
-
|
|
538
|
+
const result = safeParseSchemaSync(
|
|
539
|
+
operation.input,
|
|
540
|
+
operation.fixtures.request,
|
|
541
|
+
`operations.${operationName}.fixtures.request`,
|
|
542
|
+
);
|
|
543
|
+
if (!result.success)
|
|
58
544
|
throw new ValidationError(
|
|
59
545
|
`Fixture request does not match input schema for provider "${providerId}" operation "${operationName}"`,
|
|
60
546
|
{
|
|
@@ -62,13 +548,14 @@ function validateOperationFixtures(
|
|
|
62
548
|
zodError: result.error,
|
|
63
549
|
},
|
|
64
550
|
);
|
|
65
|
-
}
|
|
66
551
|
}
|
|
67
|
-
|
|
68
552
|
if (operation.fixtures?.response !== undefined) {
|
|
69
|
-
const result =
|
|
70
|
-
|
|
71
|
-
|
|
553
|
+
const result = safeParseSchemaSync(
|
|
554
|
+
operation.output,
|
|
555
|
+
operation.fixtures.response,
|
|
556
|
+
`operations.${operationName}.fixtures.response`,
|
|
557
|
+
);
|
|
558
|
+
if (!result.success)
|
|
72
559
|
throw new ValidationError(
|
|
73
560
|
`Fixture response does not match output schema for provider "${providerId}" operation "${operationName}"`,
|
|
74
561
|
{
|
|
@@ -76,7 +563,6 @@ function validateOperationFixtures(
|
|
|
76
563
|
zodError: result.error,
|
|
77
564
|
},
|
|
78
565
|
);
|
|
79
|
-
}
|
|
80
566
|
}
|
|
81
567
|
}
|
|
82
568
|
}
|
|
@@ -85,53 +571,53 @@ export function defineProvider<
|
|
|
85
571
|
TOperations extends Record<string, ProviderOperation>,
|
|
86
572
|
>(
|
|
87
573
|
config: ProviderConfig<TOperations>,
|
|
88
|
-
): ProviderDefinition & { operations: TOperations } {
|
|
89
|
-
|
|
574
|
+
): ProviderDefinition & { operations: OperationMapConfig<TOperations> } {
|
|
575
|
+
validateProviderShape(config);
|
|
576
|
+
if (!CONNECTOR_ID_REGEX.test(config.id))
|
|
90
577
|
throw new ProviderError(`Invalid provider id: "${config.id}"`, {
|
|
91
|
-
fix: 'Use lowercase alphanumeric with dashes, e.g., "
|
|
578
|
+
fix: 'Use lowercase alphanumeric with dashes, e.g., "airkorea-realtime"',
|
|
92
579
|
});
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (Object.keys(config.operations).length === 0) {
|
|
580
|
+
if (Object.keys(config.operations).length === 0)
|
|
96
581
|
throw new ProviderError(
|
|
97
582
|
`Provider "${config.id}" must define at least one operation`,
|
|
98
583
|
{ fix: "Add at least one operation to the operations object" },
|
|
99
584
|
);
|
|
100
|
-
|
|
101
|
-
|
|
585
|
+
validateOperationIds(config.id, config.operations);
|
|
586
|
+
validateOperationAnnotations(config.id, config.operations);
|
|
587
|
+
validateOperationHealthChecks(config.id, config.operations);
|
|
588
|
+
validateProviderHealthMonitor(config.id, config.healthMonitor);
|
|
102
589
|
validateOperationFixtures(config.id, config.operations);
|
|
103
|
-
|
|
104
|
-
if (config.runtime === "browser" && !config.browser) {
|
|
590
|
+
if (config.runtime === "browser" && !config.browser)
|
|
105
591
|
throw new ProviderError(
|
|
106
592
|
`Provider "${config.id}" must define browser.engine when runtime is "browser"`,
|
|
107
593
|
{
|
|
108
594
|
fix: 'Add browser: { engine: "nodriver" } or another supported engine',
|
|
109
595
|
},
|
|
110
596
|
);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (config.browser && config.runtime !== "browser") {
|
|
597
|
+
if (config.browser && config.runtime !== "browser")
|
|
114
598
|
throw new ProviderError(
|
|
115
599
|
`Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
|
|
116
600
|
{ fix: 'Set runtime: "browser" or remove the browser config' },
|
|
117
601
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (config.proxy && !config.stealth) {
|
|
602
|
+
if (config.proxy && !config.stealth)
|
|
121
603
|
console.warn(
|
|
122
604
|
`[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
|
|
123
605
|
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
606
|
return {
|
|
127
607
|
id: config.id,
|
|
128
608
|
version: config.version,
|
|
129
609
|
runtime: config.runtime,
|
|
610
|
+
allowedHosts: config.allowedHosts,
|
|
130
611
|
stealth: config.stealth,
|
|
131
612
|
proxy: config.proxy,
|
|
132
613
|
browser: config.browser,
|
|
133
614
|
auth: config.auth,
|
|
615
|
+
reviewed: config.reviewed,
|
|
616
|
+
secrets: config.secrets,
|
|
617
|
+
credential: config.credential,
|
|
618
|
+
context: config.context,
|
|
134
619
|
meta: config.meta,
|
|
135
620
|
operations: config.operations,
|
|
621
|
+
healthMonitor: config.healthMonitor,
|
|
136
622
|
};
|
|
137
623
|
}
|
package/src/dev.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { serve } from "./server/serve";
|
|
2
2
|
import type { ProviderDefinition } from "./types";
|
|
3
3
|
|
|
4
4
|
export interface DevServerOptions {
|
|
@@ -11,14 +11,10 @@ export function createDevServer(
|
|
|
11
11
|
options?: DevServerOptions,
|
|
12
12
|
): { start: () => void } {
|
|
13
13
|
const port = options?.port ?? 3900;
|
|
14
|
-
const server = createProviderServer(provider, {
|
|
15
|
-
port,
|
|
16
|
-
sessionDbPath: options?.sessionDbPath,
|
|
17
|
-
});
|
|
18
14
|
|
|
19
15
|
return {
|
|
20
16
|
start: () => {
|
|
21
|
-
|
|
17
|
+
void serve(provider, { port });
|
|
22
18
|
console.log(
|
|
23
19
|
`[apifuse dev] ${provider.id}@${provider.version} running at http://localhost:${port}`,
|
|
24
20
|
);
|