@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68
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 +28 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +207 -31
- package/bin/apifuse-submit-check.ts +273 -74
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +493 -2
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +2 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +43 -29
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/lint.ts +541 -1
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +2 -1
- package/src/runtime/provider.ts +15 -0
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +56 -30
- package/src/server/state-backend-report.ts +185 -0
package/AUTHORING.md
CHANGED
|
@@ -680,6 +680,31 @@ The registered mappings are:
|
|
|
680
680
|
| Unregistered input `ValidationError` code | 400 |
|
|
681
681
|
| Other unregistered `ProviderError` code | 500 |
|
|
682
682
|
|
|
683
|
+
A registered code that names an upstream outcome also carries a category. When
|
|
684
|
+
a `ProviderError` (or any subclass, `ValidationError` included) with one of
|
|
685
|
+
these codes sets no `category`, the server reports the code's category in
|
|
686
|
+
`X-ApiFuse-Error-Observability`, and the public `source` follows from it:
|
|
687
|
+
|
|
688
|
+
| Error code | Category when unset | `source` |
|
|
689
|
+
| --- | --- | --- |
|
|
690
|
+
| `NOT_FOUND`, `not_found`, `NO_DATA` | `upstream_http` | `upstream_failure` |
|
|
691
|
+
| `RATE_LIMITED`, `UPSTREAM_RATE_LIMIT`, `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | `upstream_rate_limited` | `upstream_rule` |
|
|
692
|
+
| `UPSTREAM_REJECTED` | `upstream_rejected` | `upstream_rule` |
|
|
693
|
+
| `UPSTREAM_ERROR` | `upstream_http` | `upstream_failure` |
|
|
694
|
+
| `BLOCKED` | `anti_bot_blocked` | `upstream_failure` |
|
|
695
|
+
| `UPSTREAM_AUTH_ERROR` | `upstream_auth` | `upstream_failure` |
|
|
696
|
+
| `UPSTREAM_SCHEMA_ERROR` | `upstream_schema_drift` | `upstream_failure` |
|
|
697
|
+
|
|
698
|
+
So `throw new ProviderError("…", { code: "UPSTREAM_SCHEMA_ERROR" })` is enough
|
|
699
|
+
for the health monitor to grade a drifted upstream `down`; without the
|
|
700
|
+
category it would be served as `source: "apifuse"` and graded unknown. An
|
|
701
|
+
explicit `category` always wins, an operation-declared rejection status
|
|
702
|
+
(409/410/422) still classifies as `upstream_rejected`, and retryability does
|
|
703
|
+
not follow the category: it stays the instance option, then the declaration,
|
|
704
|
+
then `false`. Every other code, including `AUTH_REQUIRED`, `INVALID_REQUEST`,
|
|
705
|
+
and your own codes, is classified as before (`provider_error` unless you set a
|
|
706
|
+
category).
|
|
707
|
+
|
|
683
708
|
An unregistered non-validation `ProviderError` code returns HTTP 500 and emits
|
|
684
709
|
the greppable `unregistered_provider_error_code` signal with the code in the
|
|
685
710
|
structured failure log. A matching operation declaration, including one that
|
|
@@ -701,7 +726,9 @@ SDK-owned and keep their 502/504 mapping.
|
|
|
701
726
|
refused a *platform-managed* service key. The caller holds no credential, so
|
|
702
727
|
401 ("re-authenticate") asks for something the caller cannot do, and 502
|
|
703
728
|
("the upstream is sick") promises a recovery that will never arrive. It is a
|
|
704
|
-
deployment/config defect, exactly like `MISSING_SECRET`.
|
|
729
|
+
deployment/config defect, exactly like `MISSING_SECRET`. The status is about
|
|
730
|
+
what the caller can do; the attribution is still the upstream's, since the
|
|
731
|
+
upstream is what refused the key, so the category is `upstream_auth`.
|
|
705
732
|
- `UPSTREAM_SCHEMA_ERROR` is non-retryable: a retry returns the same payload
|
|
706
733
|
the provider already could not normalize.
|
|
707
734
|
- `INVALID_REQUEST` is the spelling for caller-side bad input. `INVALID_INPUT`,
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @apifuse/provider-sdk Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0-beta.68
|
|
4
|
+
|
|
5
|
+
- Release candidate for main commit d630a344c18b2b192edfd09ef92bc30a482b6b8d.
|
|
6
|
+
|
|
7
|
+
## 2.2.0-beta.67
|
|
8
|
+
|
|
9
|
+
- Release candidate for main commit 9ef92b86b293253597801e29764d76beb0a3f3b7.
|
|
10
|
+
|
|
3
11
|
## 2.2.0-beta.66
|
|
4
12
|
|
|
5
13
|
- Release candidate for main commit e7f93a6c71e3bca3a680861c0968e22dbe5d1d40.
|
package/README.md
CHANGED
|
@@ -309,10 +309,14 @@ apifuse bounty-check [path]
|
|
|
309
309
|
apifuse perf providers/korea-air-quality --operation realtime --params '{"stationName":"종로구"}'
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
-
`apifuse record` is for real upstream-backed operations that
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
312
|
+
`apifuse record` is for real upstream-backed operations that call the upstream
|
|
313
|
+
through `ctx.http` or `ctx.stealth`. Absolute request URLs need no declaration; a
|
|
314
|
+
relative URL resolves the way `serve` resolves it — against the operation's
|
|
315
|
+
`upstream.baseUrl`, else the provider's first declared one (`ctx.stealth` then
|
|
316
|
+
falls back to `https://<allowedHosts[0]>`). A relative `ctx.http` URL with no base
|
|
317
|
+
is refused with the `upstream.baseUrl` to declare. The generated local-only `ping`
|
|
318
|
+
operation intentionally has no upstream and should be replaced before recording
|
|
319
|
+
fixtures.
|
|
316
320
|
|
|
317
321
|
## Bounty submission readiness
|
|
318
322
|
|
package/bin/apifuse-record.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
createBypassProviderCache,
|
|
11
|
+
createCredentialContext,
|
|
11
12
|
createHttpClient,
|
|
12
13
|
createInProcessProviderEngine,
|
|
13
14
|
createOcrClientFromEnv,
|
|
@@ -27,7 +28,16 @@ import {
|
|
|
27
28
|
TransportError,
|
|
28
29
|
ValidationError,
|
|
29
30
|
} from "../src/index.js";
|
|
31
|
+
import {
|
|
32
|
+
type LocalConnection,
|
|
33
|
+
LOCAL_CONNECTION_ID,
|
|
34
|
+
LOCAL_CONNECTION_SECRETS_ENV,
|
|
35
|
+
providerRequiresConnection,
|
|
36
|
+
resolveLocalConnection,
|
|
37
|
+
} from "../src/cli/local-connection.js";
|
|
30
38
|
import { createCliResolverRuntime } from "../src/cli/resolver-runtime.js";
|
|
39
|
+
import { isAbsoluteUrl } from "../src/runtime/http.js";
|
|
40
|
+
import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../src/runtime/provider.js";
|
|
31
41
|
import { assertProcessEngineModeSupported } from "../src/runtime/engine-mode.js";
|
|
32
42
|
import type { JsonValue } from "../src/contract-json.js";
|
|
33
43
|
import {
|
|
@@ -71,6 +81,10 @@ type CliArgs = {
|
|
|
71
81
|
operation?: string;
|
|
72
82
|
params: string;
|
|
73
83
|
sanitize: boolean;
|
|
84
|
+
/** Repeated `--credential key=value`, in order. */
|
|
85
|
+
credentials: string[];
|
|
86
|
+
/** `--no-credential`: run without a connection even when auth declares one. */
|
|
87
|
+
noCredential: boolean;
|
|
74
88
|
};
|
|
75
89
|
|
|
76
90
|
type ProviderRuntime = ProviderDefinition;
|
|
@@ -81,6 +95,11 @@ const HELP_TEXT = `Usage: apifuse record [path] --operation <operation> --params
|
|
|
81
95
|
|
|
82
96
|
Calls a real upstream-backed operation through ctx.http or ctx.stealth and writes __fixtures__/raw.json.
|
|
83
97
|
|
|
98
|
+
Absolute request URLs need no declaration. A relative URL resolves the way serve resolves it: against
|
|
99
|
+
the operation's upstream.baseUrl, else the provider's first declared one (ctx.stealth then falls back
|
|
100
|
+
to https://<allowedHosts[0]>). A relative ctx.http URL with no base anywhere is refused and names
|
|
101
|
+
the upstream.baseUrl to declare.
|
|
102
|
+
|
|
84
103
|
Streaming responses are recorded as evidence (status, selected headers, full-body SHA-256 and byte
|
|
85
104
|
count, plus a ${STREAM_PREVIEW_BYTES}-byte base64 preview). Test replay is evidence-only: ctx.http.stream exposes the
|
|
86
105
|
preview as its body and the original body_sha256/body_bytes as response metadata.
|
|
@@ -94,8 +113,18 @@ Options:
|
|
|
94
113
|
--append preserve the existing fixture and append this capture
|
|
95
114
|
--sanitize redact common token/header fields (default)
|
|
96
115
|
--no-sanitize disable common-field redaction (sensitiveParams are always redacted)
|
|
116
|
+
--credential <k>=<v> credential the gateway would inject (repeatable)
|
|
117
|
+
--no-credential record without a credential even when auth declares one
|
|
97
118
|
--help, -h show this help
|
|
98
119
|
|
|
120
|
+
Credentials:
|
|
121
|
+
A provider whose auth.mode is not "none" reads a credential the gateway
|
|
122
|
+
resolves and injects at invocation time -- for "platform-managed" that is the
|
|
123
|
+
platform-owned key, which is not a provider secret and is not in the
|
|
124
|
+
checkout. Supply it with --credential, or with
|
|
125
|
+
${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'. The keys are the ones
|
|
126
|
+
the provider passes to ctx.credential.get(...).
|
|
127
|
+
|
|
99
128
|
Example:
|
|
100
129
|
apifuse record providers/korea-air-quality --operation realtime --params '{"stationName":"jongno"}'`;
|
|
101
130
|
|
|
@@ -111,10 +140,13 @@ export async function main() {
|
|
|
111
140
|
const operation = provider.operations[operationName];
|
|
112
141
|
const parsedParams = await parseParams(operation, args.params);
|
|
113
142
|
|
|
143
|
+
const connection = resolveRecordConnection(provider, args);
|
|
144
|
+
|
|
114
145
|
capture = createCaptureContext(
|
|
115
146
|
provider,
|
|
116
|
-
|
|
147
|
+
resolveRecordBaseUrls(provider, operationName),
|
|
117
148
|
args.sanitize,
|
|
149
|
+
connection,
|
|
118
150
|
);
|
|
119
151
|
|
|
120
152
|
console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
|
|
@@ -123,6 +155,7 @@ export async function main() {
|
|
|
123
155
|
try {
|
|
124
156
|
result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
|
|
125
157
|
} catch (operationError) {
|
|
158
|
+
capture.throwIfRefused();
|
|
126
159
|
let partial: unknown;
|
|
127
160
|
try {
|
|
128
161
|
partial = await capture.getCapturedRaw();
|
|
@@ -147,6 +180,9 @@ export async function main() {
|
|
|
147
180
|
);
|
|
148
181
|
if (telemetryLine) console.log(telemetryLine);
|
|
149
182
|
}
|
|
183
|
+
// Checked on success too: an operation that caught the refusal and carried
|
|
184
|
+
// on would otherwise save a fixture missing the request it never made.
|
|
185
|
+
capture.throwIfRefused();
|
|
150
186
|
const captured = await capture.getCapturedRaw();
|
|
151
187
|
|
|
152
188
|
if (captured === undefined) {
|
|
@@ -181,6 +217,41 @@ export async function main() {
|
|
|
181
217
|
}
|
|
182
218
|
}
|
|
183
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The connection this run attaches, or a hard failure when the provider needs
|
|
222
|
+
* one and none is configured.
|
|
223
|
+
*
|
|
224
|
+
* Failing here rather than at the handler is the whole point. Before this, a
|
|
225
|
+
* platform-managed provider recorded with `credential: { mode: "none" }`, so
|
|
226
|
+
* `ctx.credential.get(...)` returned `undefined` and the run died on the
|
|
227
|
+
* provider's own `MISSING_SECRET` — indistinguishable, to the author, from an
|
|
228
|
+
* upstream outage or a bad key. It reported as a capability the recorder
|
|
229
|
+
* lacked only if you already knew to look.
|
|
230
|
+
*/
|
|
231
|
+
function resolveRecordConnection(
|
|
232
|
+
provider: ProviderRuntime,
|
|
233
|
+
args: CliArgs,
|
|
234
|
+
): LocalConnection | undefined {
|
|
235
|
+
if (args.noCredential) {
|
|
236
|
+
if (providerRequiresConnection(provider)) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`[apifuse record] --no-credential: recording ${provider.id} without the credential its auth.mode "${provider.auth?.mode}" declares.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const resolution = resolveLocalConnection(provider, {
|
|
245
|
+
overrides: args.credentials,
|
|
246
|
+
});
|
|
247
|
+
if (!resolution.ok)
|
|
248
|
+
throw new ProviderError(resolution.reason, {
|
|
249
|
+
code: "CREDENTIAL_UNAVAILABLE",
|
|
250
|
+
fix: "Supply the credential with --credential or the environment variable named above.",
|
|
251
|
+
});
|
|
252
|
+
return resolution.connection;
|
|
253
|
+
}
|
|
254
|
+
|
|
184
255
|
function normalizeArgs(argv: string[]): string[] {
|
|
185
256
|
return argv[0] === "record" ? argv.slice(1) : argv;
|
|
186
257
|
}
|
|
@@ -191,6 +262,8 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
191
262
|
let params = "{}";
|
|
192
263
|
let sanitize = true;
|
|
193
264
|
let append = false;
|
|
265
|
+
const credentials: string[] = [];
|
|
266
|
+
let noCredential = false;
|
|
194
267
|
|
|
195
268
|
for (let index = 0; index < argv.length; index += 1) {
|
|
196
269
|
const arg = argv[index];
|
|
@@ -247,6 +320,26 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
247
320
|
continue;
|
|
248
321
|
}
|
|
249
322
|
|
|
323
|
+
if (arg === "--credential") {
|
|
324
|
+
const value = argv[index + 1];
|
|
325
|
+
if (!value) {
|
|
326
|
+
throw new Error("Missing value for --credential.");
|
|
327
|
+
}
|
|
328
|
+
credentials.push(value);
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (arg.startsWith("--credential=")) {
|
|
334
|
+
credentials.push(arg.slice("--credential=".length));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (arg === "--no-credential") {
|
|
339
|
+
noCredential = true;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
250
343
|
if (arg.startsWith("-")) {
|
|
251
344
|
throw new Error(`Unknown option: ${arg}`);
|
|
252
345
|
}
|
|
@@ -259,7 +352,15 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
259
352
|
throw new Error(`Unexpected argument: ${arg}`);
|
|
260
353
|
}
|
|
261
354
|
|
|
262
|
-
return {
|
|
355
|
+
return {
|
|
356
|
+
append,
|
|
357
|
+
providerPath,
|
|
358
|
+
operation,
|
|
359
|
+
params,
|
|
360
|
+
sanitize,
|
|
361
|
+
credentials,
|
|
362
|
+
noCredential,
|
|
363
|
+
};
|
|
263
364
|
}
|
|
264
365
|
|
|
265
366
|
/**
|
|
@@ -444,22 +545,58 @@ async function parseParams(
|
|
|
444
545
|
return operation.input ? parseSchema(operation.input, parsed, "record.params") : parsed;
|
|
445
546
|
}
|
|
446
547
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
548
|
+
/**
|
|
549
|
+
* The bases the recorder's `ctx.http` and `ctx.stealth` resolve relative
|
|
550
|
+
* request URLs against. An absent base means only absolute URLs resolve.
|
|
551
|
+
*/
|
|
552
|
+
export type RecordBaseUrls = {
|
|
553
|
+
readonly http?: string;
|
|
554
|
+
readonly stealth?: string;
|
|
555
|
+
};
|
|
454
556
|
|
|
455
|
-
|
|
557
|
+
/**
|
|
558
|
+
* `upstream.baseUrl` only resolves relative request URLs (and seeds the stealth
|
|
559
|
+
* cookie jar's default origin); the recorder uses it for nothing else, so an
|
|
560
|
+
* operation that requests absolute URLs records without one. The operation's
|
|
561
|
+
* own base wins; otherwise the recorder binds the bases `serve` binds, so a
|
|
562
|
+
* relative URL resolves in a recording exactly as it does in production.
|
|
563
|
+
*/
|
|
564
|
+
export function resolveRecordBaseUrls(
|
|
565
|
+
provider: ProviderRuntime,
|
|
566
|
+
operationName: string,
|
|
567
|
+
): RecordBaseUrls {
|
|
568
|
+
const declared = provider.operations[operationName]?.upstream?.baseUrl;
|
|
569
|
+
const own = typeof declared === "string" && declared.length > 0 ? declared : undefined;
|
|
570
|
+
const http = own ?? getProviderBaseUrl(provider);
|
|
571
|
+
const stealth = own ?? getProviderStealthBaseUrl(provider);
|
|
572
|
+
return { ...(http ? { http } : {}), ...(stealth ? { stealth } : {}) };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** A request the recorder cannot resolve without a declaration; fails the run even if the operation catches it. */
|
|
576
|
+
class RecordRefusalError extends Error {
|
|
577
|
+
constructor(message: string) {
|
|
578
|
+
super(message);
|
|
579
|
+
this.name = "RecordRefusalError";
|
|
580
|
+
}
|
|
456
581
|
}
|
|
457
582
|
|
|
458
583
|
export function createCaptureContext(
|
|
459
584
|
provider: ProviderRuntime,
|
|
460
|
-
|
|
585
|
+
baseUrls: RecordBaseUrls,
|
|
461
586
|
sanitize: boolean,
|
|
587
|
+
connection?: LocalConnection,
|
|
462
588
|
) {
|
|
589
|
+
let refusal: RecordRefusalError | undefined;
|
|
590
|
+
const refuse = (message: string): never => {
|
|
591
|
+
refusal ??= new RecordRefusalError(message);
|
|
592
|
+
throw refusal;
|
|
593
|
+
};
|
|
594
|
+
const assertResolvableHttpUrl = (url: string) => {
|
|
595
|
+
if (baseUrls.http || isAbsoluteUrl(url)) return;
|
|
596
|
+
refuse(
|
|
597
|
+
`ctx.http cannot resolve the relative URL "${requestPathForFixture(url)}": no operation of provider "${provider.id}" declares upstream.baseUrl. Declare upstream.baseUrl on the recorded operation, or request an absolute URL.`,
|
|
598
|
+
);
|
|
599
|
+
};
|
|
463
600
|
let nextCaptureOrder = 0;
|
|
464
601
|
let nextStreamOrdinal = 0;
|
|
465
602
|
let capturedRaw: JsonValue | undefined;
|
|
@@ -477,6 +614,25 @@ export function createCaptureContext(
|
|
|
477
614
|
provider,
|
|
478
615
|
cache,
|
|
479
616
|
);
|
|
617
|
+
// The injected credential is the most sensitive value in the run, and the
|
|
618
|
+
// recorder prints provider errors and writes fixtures. Seed its VALUES into
|
|
619
|
+
// the redaction set before anything can execute, so an upstream error that
|
|
620
|
+
// echoes the key, and any fixture that captured it, are redacted by the same
|
|
621
|
+
// machinery that already covers declared query secrets.
|
|
622
|
+
//
|
|
623
|
+
// Values only, never the key names: `sensitiveParamNames` means "declared
|
|
624
|
+
// query-key position", and `redactFixture` deliberately skips common-field
|
|
625
|
+
// sanitization for a key that appears in it. Seeding `access_token` there
|
|
626
|
+
// would therefore write a *newly issued* upstream `access_token` — which
|
|
627
|
+
// does not equal the injected value — to raw.json in plaintext. A credential
|
|
628
|
+
// key that really is a query parameter is added to the name set by
|
|
629
|
+
// `captureSensitiveRequestValues` when the provider sends it, which is the
|
|
630
|
+
// only place that claim is true.
|
|
631
|
+
for (const value of Object.values(connection?.secrets ?? {})) {
|
|
632
|
+
if (value.length > 0) sensitiveParamValues.add(value);
|
|
633
|
+
}
|
|
634
|
+
registerSensitiveValues([...sensitiveParamValues]);
|
|
635
|
+
|
|
480
636
|
const captureSensitiveParams = (url: string, options?: SensitiveRequestOptions) => {
|
|
481
637
|
try {
|
|
482
638
|
captureSensitiveRequestValues(url, options, sensitiveParamNames, sensitiveParamValues);
|
|
@@ -500,7 +656,8 @@ export function createCaptureContext(
|
|
|
500
656
|
rawCaptures.push({ order, value: json });
|
|
501
657
|
};
|
|
502
658
|
|
|
503
|
-
const http = captureHttpClient(createHttpClient(
|
|
659
|
+
const http = captureHttpClient(createHttpClient(baseUrls.http), {
|
|
660
|
+
assertResolvableUrl: assertResolvableHttpUrl,
|
|
504
661
|
reserveOrder: reserveCaptureOrder,
|
|
505
662
|
reserveStreamOrdinal: () => {
|
|
506
663
|
nextStreamOrdinal += 1;
|
|
@@ -509,7 +666,7 @@ export function createCaptureContext(
|
|
|
509
666
|
onSensitiveParams: captureSensitiveParams,
|
|
510
667
|
onResponse: (order, response) => retainRawCapture(order, response.data),
|
|
511
668
|
onStreamResponse: (order, ordinal, requestUrl, method, response) => {
|
|
512
|
-
const resolvedRequestUrl = new URL(requestUrl,
|
|
669
|
+
const resolvedRequestUrl = new URL(requestUrl, baseUrls.http).toString();
|
|
513
670
|
const request = {
|
|
514
671
|
ordinal,
|
|
515
672
|
method,
|
|
@@ -532,36 +689,46 @@ export function createCaptureContext(
|
|
|
532
689
|
capturedSse = {
|
|
533
690
|
order,
|
|
534
691
|
method,
|
|
535
|
-
path: requestPathForFixture(new URL(requestUrl,
|
|
692
|
+
path: requestPathForFixture(new URL(requestUrl, baseUrls.http).toString()),
|
|
536
693
|
};
|
|
537
694
|
},
|
|
538
695
|
});
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
696
|
+
// Without a base `serve` binds no stealth transport either, so every call is refused.
|
|
697
|
+
const refuseStealth = (): never =>
|
|
698
|
+
refuse(
|
|
699
|
+
`ctx.stealth has no base URL: provider "${provider.id}" declares neither upstream.baseUrl nor allowedHosts. Declare allowedHosts on the provider (serve binds ctx.stealth to https://<allowedHosts[0]>) or upstream.baseUrl on the recorded operation.`,
|
|
700
|
+
);
|
|
701
|
+
const stealth: StealthClient = baseUrls.stealth
|
|
702
|
+
? proxyStealthClient(
|
|
703
|
+
createStealthClient(baseUrls.stealth, {
|
|
704
|
+
...(provider.stealth ? { stealth: provider.stealth } : {}),
|
|
705
|
+
}),
|
|
706
|
+
captureSensitiveParams,
|
|
707
|
+
(order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
|
|
708
|
+
reserveCaptureOrder,
|
|
709
|
+
)
|
|
710
|
+
: { fetch: async () => refuseStealth(), createSession: refuseStealth };
|
|
547
711
|
|
|
548
712
|
const providerEnvironment = createProviderEnvironment(
|
|
549
713
|
process.env,
|
|
550
714
|
provider.secrets?.map((secret) => secret.name) ?? [],
|
|
551
715
|
);
|
|
552
716
|
const env = { get: (key: string) => readDiagnosticEnv(key, providerEnvironment) };
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
};
|
|
717
|
+
// Built exactly the way `serve` builds it from a gateway-supplied
|
|
718
|
+
// `request.connection`, so the provider takes its production branch rather
|
|
719
|
+
// than the always-undefined one a stub credential forces.
|
|
720
|
+
const credential = createCredentialContext({
|
|
721
|
+
...(provider.credential?.keys ? { allowedKeys: provider.credential.keys } : {}),
|
|
722
|
+
...(connection ? { mode: connection.mode, values: connection.secrets } : {}),
|
|
723
|
+
});
|
|
560
724
|
const state = createMemoryProviderRuntimeState();
|
|
561
725
|
const candidates: ProviderEngineBindingCandidates = {
|
|
562
726
|
env,
|
|
563
727
|
credential,
|
|
564
|
-
request: {
|
|
728
|
+
request: {
|
|
729
|
+
headers: {},
|
|
730
|
+
connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
|
|
731
|
+
},
|
|
565
732
|
http,
|
|
566
733
|
cache,
|
|
567
734
|
state,
|
|
@@ -595,7 +762,10 @@ export function createCaptureContext(
|
|
|
595
762
|
resolver,
|
|
596
763
|
handle: createHandleContext({
|
|
597
764
|
providerId: provider.id,
|
|
598
|
-
request: {
|
|
765
|
+
request: {
|
|
766
|
+
headers: {},
|
|
767
|
+
connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
|
|
768
|
+
},
|
|
599
769
|
state,
|
|
600
770
|
}),
|
|
601
771
|
};
|
|
@@ -607,6 +777,9 @@ export function createCaptureContext(
|
|
|
607
777
|
return {
|
|
608
778
|
ctx,
|
|
609
779
|
resolverTelemetry,
|
|
780
|
+
throwIfRefused: () => {
|
|
781
|
+
if (refusal) throw refusal;
|
|
782
|
+
},
|
|
610
783
|
getCapturedRaw: async () => {
|
|
611
784
|
if (streamCaptures.length === 0) {
|
|
612
785
|
if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
|
|
@@ -727,6 +900,8 @@ function snapshotRequestOptions<T extends SensitiveRequestOptions>(options: T):
|
|
|
727
900
|
}
|
|
728
901
|
|
|
729
902
|
type HttpCaptureCallbacks = {
|
|
903
|
+
/** Throws before the request when the recorder cannot resolve `url`. */
|
|
904
|
+
assertResolvableUrl(url: string): void;
|
|
730
905
|
reserveOrder(): number;
|
|
731
906
|
reserveStreamOrdinal(): number;
|
|
732
907
|
onSensitiveParams(url: string, options?: RequestOptions): void;
|
|
@@ -751,6 +926,7 @@ function captureHttpClient(client: HttpClient, callbacks: HttpCaptureCallbacks):
|
|
|
751
926
|
return response;
|
|
752
927
|
};
|
|
753
928
|
const captureRequestOptions = (method: PropertyKey, args: unknown[]) => {
|
|
929
|
+
callbacks.assertResolvableUrl(String(args[0]));
|
|
754
930
|
const invocation = parseHttpRequestInvocation(method, args);
|
|
755
931
|
const options = invocation ? requestOptionsFromHttpInvocation(invocation) : undefined;
|
|
756
932
|
if (!invocation || !options) return;
|