@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.4
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 +14 -0
- package/CHANGELOG.md +8 -0
- package/bin/apifuse-check.ts +18 -58
- package/bin/apifuse-create.ts +1 -1
- package/bin/apifuse-dev.ts +27 -52
- package/bin/apifuse-pack-smoke.ts +22 -81
- package/bin/apifuse-pack-types.ts +266 -0
- package/bin/apifuse-perf.ts +45 -127
- package/bin/apifuse-record.ts +23 -61
- package/bin/apifuse-submit-check.ts +73 -30
- package/bin/apifuse.ts +1 -1
- package/bin/submit-check-xml-semantics.ts +204 -0
- package/bin/submit-check-xml.ts +134 -0
- package/dist/auth-turn/index.d.ts +1 -1
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +9 -18
- package/dist/ceremonies/index.d.ts +1 -1
- package/dist/ceremonies/index.js +8 -18
- package/dist/config/loader.d.ts +2 -2
- package/dist/config/loader.js +19 -42
- package/dist/contract-serialization.d.ts +2 -2
- package/dist/contract-serialization.js +3 -6
- package/dist/contract-types.d.ts +2 -2
- package/dist/contract.d.ts +3 -3
- package/dist/contract.js +4 -6
- package/dist/define.d.ts +8 -1
- package/dist/define.js +57 -115
- package/dist/dev.d.ts +1 -1
- package/dist/dev.js +1 -1
- package/dist/errors.d.ts +4 -1
- package/dist/errors.js +48 -0
- package/dist/i18n/catalog.d.ts +2 -2
- package/dist/i18n/catalog.js +4 -10
- package/dist/i18n/index.d.ts +2 -2
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/keys.d.ts +2 -2
- package/dist/index.d.ts +43 -42
- package/dist/index.js +38 -37
- package/dist/lint.js +8 -15
- package/dist/provider.d.ts +11 -11
- package/dist/provider.js +9 -9
- package/dist/public-schema-field-lint.d.ts +1 -1
- package/dist/recipes/gov-api.js +1 -1
- package/dist/runtime/auth-flow.d.ts +1 -1
- package/dist/runtime/auth-flow.js +3 -3
- package/dist/runtime/browser.d.ts +1 -1
- package/dist/runtime/browser.js +15 -29
- package/dist/runtime/cache.d.ts +1 -1
- package/dist/runtime/cache.js +4 -8
- package/dist/runtime/choice.d.ts +1 -1
- package/dist/runtime/choice.js +12 -34
- package/dist/runtime/credential.d.ts +1 -1
- package/dist/runtime/credential.js +1 -1
- package/dist/runtime/env.d.ts +1 -1
- package/dist/runtime/executor.d.ts +1 -1
- package/dist/runtime/executor.js +8 -3
- package/dist/runtime/http.d.ts +2 -2
- package/dist/runtime/http.js +5 -5
- package/dist/runtime/insights.d.ts +1 -1
- package/dist/runtime/insights.js +6 -13
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +5 -15
- package/dist/runtime/keyring.js +1 -1
- package/dist/runtime/namespace.js +1 -1
- package/dist/runtime/otlp.d.ts +1 -1
- package/dist/runtime/perf.d.ts +1 -1
- package/dist/runtime/provider.d.ts +1 -1
- package/dist/runtime/provider.js +1 -2
- package/dist/runtime/proxy-errors.d.ts +1 -1
- package/dist/runtime/proxy-errors.js +3 -5
- package/dist/runtime/proxy-retry-policy.d.ts +2 -2
- package/dist/runtime/proxy-retry-policy.js +2 -2
- package/dist/runtime/proxy-telemetry.d.ts +1 -1
- package/dist/runtime/proxy-telemetry.js +16 -48
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +2 -4
- package/dist/runtime/request-options.d.ts +1 -1
- package/dist/runtime/state.d.ts +2 -2
- package/dist/runtime/state.js +3 -3
- package/dist/runtime/stealth.d.ts +2 -2
- package/dist/runtime/stealth.js +6 -6
- package/dist/runtime/stt.d.ts +1 -1
- package/dist/runtime/stt.js +11 -15
- package/dist/runtime/trace.d.ts +2 -2
- package/dist/runtime/trace.js +2 -4
- package/dist/runtime/waterfall.d.ts +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +7 -15
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +6 -6
- package/dist/server/self-test-redaction.d.ts +1 -1
- package/dist/server/self-test-redaction.js +1 -1
- package/dist/server/self-test.d.ts +3 -3
- package/dist/server/self-test.js +3 -3
- package/dist/server/serve.d.ts +3 -3
- package/dist/server/serve.js +49 -57
- package/dist/server/types.js +2 -7
- package/dist/stealth/profiles.d.ts +1 -1
- package/dist/stealth/profiles.js +5 -14
- package/dist/stream.d.ts +1 -1
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/dist/testing/run.d.ts +1 -1
- package/dist/testing/run.js +7 -14
- package/dist/types.d.ts +57 -0
- package/dist/user-input.d.ts +30 -0
- package/dist/user-input.js +66 -0
- package/package.json +7 -4
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +28 -86
- package/src/ceremonies/index.ts +24 -78
- package/src/config/loader.ts +58 -176
- package/src/contract-serialization.ts +5 -11
- package/src/contract-types.ts +2 -2
- package/src/contract.ts +12 -28
- package/src/define.ts +174 -495
- package/src/dev.ts +4 -9
- package/src/errors.ts +58 -5
- package/src/i18n/catalog.ts +10 -32
- package/src/i18n/index.ts +2 -2
- package/src/i18n/keys.ts +5 -11
- package/src/index.ts +44 -42
- package/src/lint.ts +89 -159
- package/src/provider.ts +15 -11
- package/src/public-schema-field-lint.ts +7 -33
- package/src/recipes/gov-api.ts +2 -5
- package/src/runtime/auth-flow.ts +5 -7
- package/src/runtime/browser.ts +64 -187
- package/src/runtime/cache.ts +20 -67
- package/src/runtime/choice.ts +55 -129
- package/src/runtime/credential.ts +2 -2
- package/src/runtime/env.ts +1 -1
- package/src/runtime/executor.ts +15 -20
- package/src/runtime/http.ts +7 -7
- package/src/runtime/insights.ts +15 -53
- package/src/runtime/instrumentation.ts +19 -58
- package/src/runtime/keyring.ts +7 -19
- package/src/runtime/namespace.ts +2 -7
- package/src/runtime/otlp.ts +12 -23
- package/src/runtime/perf.ts +1 -1
- package/src/runtime/provider.ts +4 -9
- package/src/runtime/proxy-errors.ts +17 -38
- package/src/runtime/proxy-retry-policy.ts +3 -3
- package/src/runtime/proxy-telemetry.ts +23 -72
- package/src/runtime/redis.ts +4 -12
- package/src/runtime/request-options.ts +4 -13
- package/src/runtime/state.ts +30 -109
- package/src/runtime/stealth.ts +8 -8
- package/src/runtime/stt.ts +38 -94
- package/src/runtime/trace.ts +14 -44
- package/src/runtime/waterfall.ts +5 -18
- package/src/schema.ts +23 -84
- package/src/serve.ts +1 -1
- package/src/server/index.ts +7 -7
- package/src/server/self-test-redaction.ts +2 -2
- package/src/server/self-test.ts +5 -5
- package/src/server/serve.ts +103 -252
- package/src/server/types.ts +6 -19
- package/src/stealth/profiles.ts +10 -26
- package/src/stream.ts +8 -19
- package/src/testing/index.ts +2 -2
- package/src/testing/run.ts +19 -67
- package/src/types.ts +58 -0
- package/src/user-input.ts +118 -0
package/bin/apifuse-record.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
type StealthClient,
|
|
21
21
|
TransportError,
|
|
22
22
|
ValidationError,
|
|
23
|
-
} from "../src";
|
|
24
|
-
import { createMemoryProviderRuntimeState } from "../src/runtime/state";
|
|
23
|
+
} from "../src/index.js";
|
|
24
|
+
import { createMemoryProviderRuntimeState } from "../src/runtime/state.js";
|
|
25
25
|
|
|
26
26
|
type CliArgs = {
|
|
27
27
|
append: boolean;
|
|
@@ -64,31 +64,18 @@ export async function main() {
|
|
|
64
64
|
resolveOperationBaseUrl(provider, operationName),
|
|
65
65
|
);
|
|
66
66
|
|
|
67
|
-
console.log(
|
|
68
|
-
`[apifuse record] Calling ${operationName} on ${provider.id}...`,
|
|
69
|
-
);
|
|
67
|
+
console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
|
|
70
68
|
|
|
71
|
-
const result = await executeOperation(
|
|
72
|
-
provider,
|
|
73
|
-
operationName,
|
|
74
|
-
capture.ctx,
|
|
75
|
-
parsedParams,
|
|
76
|
-
);
|
|
69
|
+
const result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
|
|
77
70
|
const captured = capture.getCapturedRaw();
|
|
78
71
|
|
|
79
72
|
if (captured === undefined) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`No upstream response was captured for ${provider.id}.${operationName}.`,
|
|
82
|
-
);
|
|
73
|
+
throw new Error(`No upstream response was captured for ${provider.id}.${operationName}.`);
|
|
83
74
|
}
|
|
84
75
|
|
|
85
76
|
const rawPayload = args.sanitize ? sanitizeFixture(captured) : captured;
|
|
86
77
|
const fixturePath = resolve(location.rootDir, "__fixtures__", "raw.json");
|
|
87
|
-
const nextPayload = await prepareFixturePayload(
|
|
88
|
-
fixturePath,
|
|
89
|
-
rawPayload,
|
|
90
|
-
args.append,
|
|
91
|
-
);
|
|
78
|
+
const nextPayload = await prepareFixturePayload(fixturePath, rawPayload, args.append);
|
|
92
79
|
|
|
93
80
|
await mkdir(dirname(fixturePath), { recursive: true });
|
|
94
81
|
await writeFile(fixturePath, `${JSON.stringify(nextPayload, null, 2)}\n`);
|
|
@@ -98,9 +85,7 @@ export async function main() {
|
|
|
98
85
|
Buffer.byteLength(JSON.stringify(rawPayload)),
|
|
99
86
|
)})`,
|
|
100
87
|
);
|
|
101
|
-
console.log(
|
|
102
|
-
`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`,
|
|
103
|
-
);
|
|
88
|
+
console.log(`[apifuse record] Saved to ${relative(process.cwd(), fixturePath)}`);
|
|
104
89
|
|
|
105
90
|
void result;
|
|
106
91
|
} catch (error) {
|
|
@@ -210,11 +195,7 @@ function formatCliError(error: unknown): string {
|
|
|
210
195
|
}
|
|
211
196
|
|
|
212
197
|
if (error instanceof ProviderError || error instanceof ValidationError) {
|
|
213
|
-
return [
|
|
214
|
-
error.message,
|
|
215
|
-
error.code ? `code=${error.code}` : undefined,
|
|
216
|
-
error.fix,
|
|
217
|
-
]
|
|
198
|
+
return [error.message, error.code ? `code=${error.code}` : undefined, error.fix]
|
|
218
199
|
.filter(Boolean)
|
|
219
200
|
.join(" ");
|
|
220
201
|
}
|
|
@@ -269,8 +250,7 @@ function findProviderRoot(startDirectory: string): string | undefined {
|
|
|
269
250
|
|
|
270
251
|
function looksLikeProviderRoot(directory: string): boolean {
|
|
271
252
|
return (
|
|
272
|
-
existsSync(resolve(directory, "index.ts")) &&
|
|
273
|
-
existsSync(resolve(directory, "package.json"))
|
|
253
|
+
existsSync(resolve(directory, "index.ts")) && existsSync(resolve(directory, "package.json"))
|
|
274
254
|
);
|
|
275
255
|
}
|
|
276
256
|
|
|
@@ -287,15 +267,10 @@ async function loadProvider(rootDir: string): Promise<ProviderRuntime> {
|
|
|
287
267
|
return module.default;
|
|
288
268
|
}
|
|
289
269
|
|
|
290
|
-
function resolveOperationName(
|
|
291
|
-
provider: ProviderRuntime,
|
|
292
|
-
operationName?: string,
|
|
293
|
-
): string {
|
|
270
|
+
function resolveOperationName(provider: ProviderRuntime, operationName?: string): string {
|
|
294
271
|
if (operationName) {
|
|
295
272
|
if (!(operationName in provider.operations)) {
|
|
296
|
-
throw new Error(
|
|
297
|
-
`Unknown operation "${operationName}" for provider "${provider.id}".`,
|
|
298
|
-
);
|
|
273
|
+
throw new Error(`Unknown operation "${operationName}" for provider "${provider.id}".`);
|
|
299
274
|
}
|
|
300
275
|
|
|
301
276
|
return operationName;
|
|
@@ -309,10 +284,7 @@ function resolveOperationName(
|
|
|
309
284
|
return firstOperation;
|
|
310
285
|
}
|
|
311
286
|
|
|
312
|
-
function parseParams(
|
|
313
|
-
operation: ProviderRuntime["operations"][string],
|
|
314
|
-
value: string,
|
|
315
|
-
): unknown {
|
|
287
|
+
function parseParams(operation: ProviderRuntime["operations"][string], value: string): unknown {
|
|
316
288
|
let parsed: unknown;
|
|
317
289
|
|
|
318
290
|
try {
|
|
@@ -326,10 +298,7 @@ function parseParams(
|
|
|
326
298
|
return operation.input ? operation.input.parse(parsed) : parsed;
|
|
327
299
|
}
|
|
328
300
|
|
|
329
|
-
function resolveOperationBaseUrl(
|
|
330
|
-
provider: ProviderRuntime,
|
|
331
|
-
operationName: string,
|
|
332
|
-
): string {
|
|
301
|
+
function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
|
|
333
302
|
const baseUrl = provider.operations[operationName]?.upstream?.baseUrl;
|
|
334
303
|
if (!baseUrl) {
|
|
335
304
|
throw new Error(
|
|
@@ -346,12 +315,9 @@ function createCaptureContext(provider: ProviderRuntime, baseUrl: string) {
|
|
|
346
315
|
const http = proxyHttpClient(createHttpClient(baseUrl), (response) => {
|
|
347
316
|
capturedRaw = response.data;
|
|
348
317
|
});
|
|
349
|
-
const stealth = proxyStealthClient(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
capturedRaw = normalizeCapturedStealthResponse(response);
|
|
353
|
-
},
|
|
354
|
-
);
|
|
318
|
+
const stealth = proxyStealthClient(createStealthClient(baseUrl), (response) => {
|
|
319
|
+
capturedRaw = normalizeCapturedStealthResponse(response);
|
|
320
|
+
});
|
|
355
321
|
|
|
356
322
|
const env = {
|
|
357
323
|
get: (key: string) => process.env[key],
|
|
@@ -464,9 +430,7 @@ function proxyStealthSession(
|
|
|
464
430
|
};
|
|
465
431
|
}
|
|
466
432
|
|
|
467
|
-
function normalizeCapturedStealthResponse(
|
|
468
|
-
response: Awaited<ReturnType<StealthClient["fetch"]>>,
|
|
469
|
-
) {
|
|
433
|
+
function normalizeCapturedStealthResponse(response: Awaited<ReturnType<StealthClient["fetch"]>>) {
|
|
470
434
|
try {
|
|
471
435
|
return JSON.parse(response.body);
|
|
472
436
|
} catch {
|
|
@@ -483,15 +447,13 @@ function sanitizeFixture(value: unknown): unknown {
|
|
|
483
447
|
return value;
|
|
484
448
|
}
|
|
485
449
|
|
|
486
|
-
const entries = Object.entries(value as MutableRecord).map(
|
|
487
|
-
(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
450
|
+
const entries = Object.entries(value as MutableRecord).map(([key, entryValue]) => {
|
|
451
|
+
if (isSensitiveKey(key)) {
|
|
452
|
+
return [key, "[REDACTED]"] as const;
|
|
453
|
+
}
|
|
491
454
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
);
|
|
455
|
+
return [key, sanitizeFixture(entryValue)] as const;
|
|
456
|
+
});
|
|
495
457
|
|
|
496
458
|
return Object.fromEntries(entries);
|
|
497
459
|
}
|
|
@@ -11,15 +11,16 @@ import * as acorn from "acorn";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
13
|
import packageJson from "../package.json";
|
|
14
|
-
import type { ProviderDefinition } from "../src";
|
|
14
|
+
import type { ProviderDefinition } from "../src/index.js";
|
|
15
15
|
import {
|
|
16
16
|
loadProviderLocaleCatalogs,
|
|
17
17
|
type ProviderLocale,
|
|
18
18
|
validateProviderLocaleCatalogs,
|
|
19
|
-
} from "../src/i18n";
|
|
20
|
-
import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
|
|
21
|
-
import { safeParseSchemaSync } from "../src/schema";
|
|
22
|
-
import { type CheckResult, runChecks } from "./apifuse-check";
|
|
19
|
+
} from "../src/i18n/index.js";
|
|
20
|
+
import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
|
|
21
|
+
import { safeParseSchemaSync } from "../src/schema.js";
|
|
22
|
+
import { type CheckResult, runChecks } from "./apifuse-check.js";
|
|
23
|
+
import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
|
|
23
24
|
|
|
24
25
|
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
25
26
|
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
@@ -1979,10 +1980,7 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
1979
1980
|
|
|
1980
1981
|
const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
|
|
1981
1982
|
|
|
1982
|
-
function scoreFixtureProvenance(
|
|
1983
|
-
providerRoot: string,
|
|
1984
|
-
provider: ProviderDefinition,
|
|
1985
|
-
): SubmitCheck {
|
|
1983
|
+
function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1986
1984
|
const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
|
|
1987
1985
|
let hasRecordedEvidence = false;
|
|
1988
1986
|
if (existsSync(rawPath)) {
|
|
@@ -2066,8 +2064,14 @@ function recordedFixtureStats(
|
|
|
2066
2064
|
leafValues,
|
|
2067
2065
|
};
|
|
2068
2066
|
}
|
|
2069
|
-
if (typeof value === "string"
|
|
2070
|
-
|
|
2067
|
+
if (typeof value === "string") {
|
|
2068
|
+
if (value.length === 0) {
|
|
2069
|
+
return { hasNestedSubstance: false, leafValues: 0 };
|
|
2070
|
+
}
|
|
2071
|
+
// A recorded operation value may be a raw XML success payload; treat a
|
|
2072
|
+
// substantive, well-formed one as nested evidence while still counting the
|
|
2073
|
+
// string as a leaf so existing JSON provenance heuristics are unchanged.
|
|
2074
|
+
return { hasNestedSubstance: hasSubstantiveXmlStructure(value), leafValues: 1 };
|
|
2071
2075
|
}
|
|
2072
2076
|
return { hasNestedSubstance: false, leafValues: 1 };
|
|
2073
2077
|
}
|
|
@@ -2083,12 +2087,17 @@ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): b
|
|
|
2083
2087
|
}
|
|
2084
2088
|
|
|
2085
2089
|
function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
|
|
2086
|
-
return escapeHatchResult(
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2090
|
+
return escapeHatchResult(
|
|
2091
|
+
providerRoot,
|
|
2092
|
+
"vendor-key-leak",
|
|
2093
|
+
findVendorKeyLeakFindings(providerRoot),
|
|
2094
|
+
{
|
|
2095
|
+
blockerMessage: "Public schema keys leak raw vendor field names.",
|
|
2096
|
+
remediation:
|
|
2097
|
+
"Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
|
|
2098
|
+
passMessage: "No vendor field-name leaks detected in public schemas.",
|
|
2099
|
+
},
|
|
2100
|
+
);
|
|
2092
2101
|
}
|
|
2093
2102
|
|
|
2094
2103
|
function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
|
|
@@ -2191,26 +2200,57 @@ function vendorKeyFindingsForObject(
|
|
|
2191
2200
|
const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
|
|
2192
2201
|
const digitFamilies = new Map<string, Set<string>>();
|
|
2193
2202
|
for (const key of keys) {
|
|
2194
|
-
const
|
|
2195
|
-
if (!
|
|
2203
|
+
const member = numberedFamilyMember(key.name);
|
|
2204
|
+
if (!member) {
|
|
2196
2205
|
continue;
|
|
2197
2206
|
}
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
digitFamilies.set(
|
|
2207
|
+
const positions = digitFamilies.get(member.base) ?? new Set<string>();
|
|
2208
|
+
positions.add(member.position);
|
|
2209
|
+
digitFamilies.set(member.base, positions);
|
|
2201
2210
|
}
|
|
2202
2211
|
|
|
2203
2212
|
return keys
|
|
2204
2213
|
.filter((key) => {
|
|
2205
|
-
if (
|
|
2214
|
+
if (!isAllowedPublicOutputKeyName(key.name)) {
|
|
2206
2215
|
return true;
|
|
2207
2216
|
}
|
|
2208
|
-
const
|
|
2209
|
-
return
|
|
2217
|
+
const member = numberedFamilyMember(key.name);
|
|
2218
|
+
return member !== null && (digitFamilies.get(member.base)?.size ?? 0) >= 3;
|
|
2210
2219
|
})
|
|
2211
2220
|
.map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
|
|
2212
2221
|
}
|
|
2213
2222
|
|
|
2223
|
+
// A numbered vendor family is a base name plus a numeric position and an
|
|
2224
|
+
// optional trailing letter suffix, in either compact/camel form (sensor1,
|
|
2225
|
+
// duty1s) or semantic snake_case form (sensor_1, duty_time_1s). Both styles
|
|
2226
|
+
// normalize to the same { base, position } so a family of >=3 distinct
|
|
2227
|
+
// positions is caught regardless of which naming style the vendor leaked
|
|
2228
|
+
// through. Returns null for names that carry no numeric position.
|
|
2229
|
+
function numberedFamilyMember(name: string): { base: string; position: string } | null {
|
|
2230
|
+
const camelMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(name);
|
|
2231
|
+
if (camelMatch?.[1] && camelMatch[2]) {
|
|
2232
|
+
return { base: camelMatch[1], position: camelMatch[2] };
|
|
2233
|
+
}
|
|
2234
|
+
const snakeMatch = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*?)_(\d+)[a-z]*$/.exec(name);
|
|
2235
|
+
if (snakeMatch?.[1] && snakeMatch[2]) {
|
|
2236
|
+
return { base: snakeMatch[1], position: snakeMatch[2] };
|
|
2237
|
+
}
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// Public output keys may use APIFuse lowerCamelCase (isOpen24h, latitude) or
|
|
2242
|
+
// semantic snake_case (pharmacy_id, weekly_hours, total_count, scan_exhausted).
|
|
2243
|
+
// Both are normalized, human-authored names. Raw vendor keys leak through mixed
|
|
2244
|
+
// case or uppercase acronyms (MKioskTy) and match neither, so they stay flagged.
|
|
2245
|
+
// Numbered vendor families still pass this name gate in either style
|
|
2246
|
+
// (sensor1/2/3 or sensor_1/sensor_2/sensor_3), so they are caught separately by
|
|
2247
|
+
// the >=3-member numberedFamilyMember check in vendorKeyFindingsForObject.
|
|
2248
|
+
function isAllowedPublicOutputKeyName(name: string): boolean {
|
|
2249
|
+
const isLowerCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(name);
|
|
2250
|
+
const isSemanticSnakeCase = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
|
|
2251
|
+
return isLowerCamelCase || isSemanticSnakeCase;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2214
2254
|
function collectTopLevelObjectKeys(
|
|
2215
2255
|
source: string,
|
|
2216
2256
|
objectStart: number,
|
|
@@ -2244,9 +2284,7 @@ function collectTopLevelObjectKeys(
|
|
|
2244
2284
|
if (computedQuote === '"' || computedQuote === "'") {
|
|
2245
2285
|
const literalEnd = findStringEnd(source, literalStart);
|
|
2246
2286
|
const afterLiteral =
|
|
2247
|
-
literalEnd === -1
|
|
2248
|
-
? -1
|
|
2249
|
-
: skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
|
|
2287
|
+
literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
|
|
2250
2288
|
if (literalEnd !== -1 && afterLiteral === computedEnd) {
|
|
2251
2289
|
key = source.slice(literalStart + 1, literalEnd);
|
|
2252
2290
|
}
|
|
@@ -2364,8 +2402,13 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
|
|
|
2364
2402
|
return ranges;
|
|
2365
2403
|
}
|
|
2366
2404
|
|
|
2367
|
-
function findConstValueRangeContaining(
|
|
2368
|
-
|
|
2405
|
+
function findConstValueRangeContaining(
|
|
2406
|
+
source: string,
|
|
2407
|
+
offset: number,
|
|
2408
|
+
): NamedObjectRange | undefined {
|
|
2409
|
+
return findNamedConstValueRanges(source).find(
|
|
2410
|
+
(range) => offset >= range.start && offset <= range.end,
|
|
2411
|
+
);
|
|
2369
2412
|
}
|
|
2370
2413
|
|
|
2371
2414
|
function findStringLiteralsInRange(
|
package/bin/apifuse.ts
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { XmlElement } from "@rgrove/parse-xml";
|
|
2
|
+
|
|
3
|
+
const FAILURE_TEXT_PATTERN =
|
|
4
|
+
/\b(?:access denied|denied|error|exception|failed|failure|fault|forbidden|invalid|maintenance|not authorized|temporarily unavailable|unauthorized|unavailable)\b/i;
|
|
5
|
+
const KOREAN_FAILURE_TEXT_PATTERN =
|
|
6
|
+
/(?:오류|에러|실패|장애|점검|서비스\s*(?:중단|불가)|(?:일시적(?:으로)?\s*)?(?:이용|사용)\s*(?:이|가)?\s*(?:불가|어렵|할\s*수\s*없))/u;
|
|
7
|
+
const SUCCESS_CODE_PATTERN = /^(?:0+|2\d\d|2xx|ok|success|successful|normalservice)$/;
|
|
8
|
+
const SUCCESS_VALUE_PATTERN = /^(?:1|true|y|yes|ok|success|successful)$/;
|
|
9
|
+
const SUCCESS_TEXT_PATTERN = /^(?:normalserviceresponse|successfulresponse)$/;
|
|
10
|
+
const LOCALIZED_SUCCESS_TEXT_PATTERN =
|
|
11
|
+
/^(?:成功|正常|処理完了|正常終了|処理が完了しました|성공|정상|처리완료|처리가완료되었습니다|处理完成|處理完成|操作成功)$/u;
|
|
12
|
+
const CODE_SHAPED_VALUE_PATTERN = /^(?:\d+|[1-5]xx)$/;
|
|
13
|
+
const CODE_CONTROL_FIELDS: ReadonlySet<string> = new Set([
|
|
14
|
+
"httpstatus",
|
|
15
|
+
"resultcode",
|
|
16
|
+
"returnreasoncode",
|
|
17
|
+
"statuscode",
|
|
18
|
+
]);
|
|
19
|
+
const TEXT_CONTROL_FIELDS: ReadonlySet<string> = new Set([
|
|
20
|
+
"message",
|
|
21
|
+
"msg",
|
|
22
|
+
"reason",
|
|
23
|
+
"resultmessage",
|
|
24
|
+
"resultmsg",
|
|
25
|
+
"state",
|
|
26
|
+
"status",
|
|
27
|
+
"statustext",
|
|
28
|
+
]);
|
|
29
|
+
const SUCCESS_CONTROL_FIELDS: ReadonlySet<string> = new Set([
|
|
30
|
+
"issuccess",
|
|
31
|
+
"ok",
|
|
32
|
+
"success",
|
|
33
|
+
"successful",
|
|
34
|
+
]);
|
|
35
|
+
const ERROR_CODE_FIELD_PATTERN = /^(?:(?:error|exception|fault)(?:code|status)s?|errcode)$/;
|
|
36
|
+
const ERROR_TEXT_FIELD_PATTERN =
|
|
37
|
+
/^(?:(?:error|exception|fault)(?:description|detail|details|info|message|reason|string|type)?s?|errmsg|returnauthmsg)$/;
|
|
38
|
+
const STRONG_CONTROL_CONTEXT_NAMES: ReadonlySet<string> = new Set([
|
|
39
|
+
"cmmmsgheader",
|
|
40
|
+
"control",
|
|
41
|
+
"error",
|
|
42
|
+
"exception",
|
|
43
|
+
"fault",
|
|
44
|
+
"header",
|
|
45
|
+
"meta",
|
|
46
|
+
"result",
|
|
47
|
+
"status",
|
|
48
|
+
]);
|
|
49
|
+
const ORDINARY_ENVELOPE_NAMES: ReadonlySet<string> = new Set(["body", "envelope", "response"]);
|
|
50
|
+
const ERROR_ROOT_NAMES: ReadonlySet<string> = new Set([
|
|
51
|
+
"error",
|
|
52
|
+
"errorresponse",
|
|
53
|
+
"exception",
|
|
54
|
+
"exceptionresponse",
|
|
55
|
+
"fault",
|
|
56
|
+
"faultresponse",
|
|
57
|
+
]);
|
|
58
|
+
const DOMAIN_BOUNDARY_NAMES: ReadonlySet<string> = new Set([
|
|
59
|
+
"entry",
|
|
60
|
+
"item",
|
|
61
|
+
"measurement",
|
|
62
|
+
"record",
|
|
63
|
+
"row",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
export type XmlSemanticBranch = "control" | "domain" | "envelope" | "error" | "neutral";
|
|
67
|
+
|
|
68
|
+
// A control failure is only meaningful in a control/error/envelope context.
|
|
69
|
+
// Inside a domain boundary (item/record/row/…) the same field names are ordinary
|
|
70
|
+
// data — e.g. `faultCode` describing a charger's fault is not a service failure.
|
|
71
|
+
export function hasSemanticXmlFailure(element: XmlElement, branch: XmlSemanticBranch): boolean {
|
|
72
|
+
if (branch === "domain") return false;
|
|
73
|
+
const insideError = branch === "error";
|
|
74
|
+
const strongControl = insideError || branch === "control";
|
|
75
|
+
const insideControl = strongControl || branch === "envelope";
|
|
76
|
+
const fieldName = normalizedXmlName(element.name);
|
|
77
|
+
const value = element.text.trim();
|
|
78
|
+
if (
|
|
79
|
+
hasControlValueFailure({
|
|
80
|
+
fieldName,
|
|
81
|
+
value,
|
|
82
|
+
insideControl: insideControl || isSemanticControlField(fieldName),
|
|
83
|
+
strongControl,
|
|
84
|
+
})
|
|
85
|
+
) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return Object.entries(element.attributes).some(([name, attributeValue]) =>
|
|
89
|
+
hasControlValueFailure({
|
|
90
|
+
fieldName: normalizedXmlName(name),
|
|
91
|
+
value: attributeValue.trim(),
|
|
92
|
+
insideControl: true,
|
|
93
|
+
strongControl: true,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function rootXmlContext(name: string): XmlSemanticBranch {
|
|
99
|
+
if (DOMAIN_BOUNDARY_NAMES.has(name)) return "domain";
|
|
100
|
+
if (isXmlErrorWrapperName(name)) return "error";
|
|
101
|
+
if (isStrongControlContextName(name)) return "control";
|
|
102
|
+
return ORDINARY_ENVELOPE_NAMES.has(name) ? "envelope" : "neutral";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function childXmlContext(parent: XmlSemanticBranch, name: string): XmlSemanticBranch {
|
|
106
|
+
if (parent === "control" || parent === "domain" || parent === "error") return parent;
|
|
107
|
+
if (isXmlErrorWrapperName(name)) return "error";
|
|
108
|
+
if (isStrongControlContextName(name)) return "control";
|
|
109
|
+
if (DOMAIN_BOUNDARY_NAMES.has(name)) return "domain";
|
|
110
|
+
return parent === "envelope" || ORDINARY_ENVELOPE_NAMES.has(name) ? "envelope" : "neutral";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isXmlErrorRootName(name: string): boolean {
|
|
114
|
+
return ERROR_ROOT_NAMES.has(name) || isXmlErrorWrapperName(name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function normalizedXmlName(name: string): string {
|
|
118
|
+
const compatibleName = name.normalize("NFKC");
|
|
119
|
+
const localName = compatibleName.slice(compatibleName.lastIndexOf(":") + 1);
|
|
120
|
+
return localName.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasControlValueFailure(input: {
|
|
124
|
+
readonly fieldName: string;
|
|
125
|
+
readonly value: string;
|
|
126
|
+
readonly insideControl: boolean;
|
|
127
|
+
readonly strongControl: boolean;
|
|
128
|
+
}): boolean {
|
|
129
|
+
const { fieldName, value, insideControl, strongControl } = input;
|
|
130
|
+
const normalizedValue = normalizedXmlValue(value);
|
|
131
|
+
if (ERROR_CODE_FIELD_PATTERN.test(fieldName)) {
|
|
132
|
+
return !SUCCESS_CODE_PATTERN.test(normalizedValue);
|
|
133
|
+
}
|
|
134
|
+
if (ERROR_TEXT_FIELD_PATTERN.test(fieldName)) {
|
|
135
|
+
return normalizedValue.length > 0 && !SUCCESS_CODE_PATTERN.test(normalizedValue);
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
strongControl &&
|
|
139
|
+
TEXT_CONTROL_FIELDS.has(fieldName) &&
|
|
140
|
+
normalizedValue.length > 0 &&
|
|
141
|
+
!isExplicitSuccess(normalizedValue)
|
|
142
|
+
) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const isCodeControl =
|
|
146
|
+
CODE_CONTROL_FIELDS.has(fieldName) ||
|
|
147
|
+
(fieldName === "code" && insideControl) ||
|
|
148
|
+
(fieldName === "status" && insideControl && CODE_SHAPED_VALUE_PATTERN.test(normalizedValue));
|
|
149
|
+
if (isCodeControl && !SUCCESS_CODE_PATTERN.test(normalizedValue)) return true;
|
|
150
|
+
if (
|
|
151
|
+
insideControl &&
|
|
152
|
+
SUCCESS_CONTROL_FIELDS.has(fieldName) &&
|
|
153
|
+
!SUCCESS_VALUE_PATTERN.test(normalizedValue)
|
|
154
|
+
) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return insideControl && TEXT_CONTROL_FIELDS.has(fieldName) && hasFailureText(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isSemanticControlField(fieldName: string): boolean {
|
|
161
|
+
return (
|
|
162
|
+
CODE_CONTROL_FIELDS.has(fieldName) ||
|
|
163
|
+
TEXT_CONTROL_FIELDS.has(fieldName) ||
|
|
164
|
+
SUCCESS_CONTROL_FIELDS.has(fieldName) ||
|
|
165
|
+
ERROR_CODE_FIELD_PATTERN.test(fieldName) ||
|
|
166
|
+
ERROR_TEXT_FIELD_PATTERN.test(fieldName) ||
|
|
167
|
+
fieldName === "code"
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isExplicitSuccess(value: string): boolean {
|
|
172
|
+
return (
|
|
173
|
+
SUCCESS_CODE_PATTERN.test(value) ||
|
|
174
|
+
SUCCESS_VALUE_PATTERN.test(value) ||
|
|
175
|
+
SUCCESS_TEXT_PATTERN.test(value) ||
|
|
176
|
+
LOCALIZED_SUCCESS_TEXT_PATTERN.test(value)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isStrongControlContextName(name: string): boolean {
|
|
181
|
+
return STRONG_CONTROL_CONTEXT_NAMES.has(name) || name.endsWith("control");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isXmlErrorWrapperName(name: string): boolean {
|
|
185
|
+
return /(?:error|exception|fault)(?:response)?$/.test(name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hasFailureText(value: string): boolean {
|
|
189
|
+
const normalized = value.normalize("NFKC");
|
|
190
|
+
return [normalized, normalized.replace(/\p{Cf}/gu, "")].some((candidate) => {
|
|
191
|
+
if (KOREAN_FAILURE_TEXT_PATTERN.test(candidate)) return true;
|
|
192
|
+
const tokenized = candidate
|
|
193
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
194
|
+
.replace(/[^A-Za-z0-9]+/g, " ");
|
|
195
|
+
return FAILURE_TEXT_PATTERN.test(tokenized);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizedXmlValue(value: string): string {
|
|
200
|
+
return value
|
|
201
|
+
.normalize("NFKC")
|
|
202
|
+
.replace(/[^\p{L}\p{N}]/gu, "")
|
|
203
|
+
.toLowerCase();
|
|
204
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseXml,
|
|
3
|
+
XmlDocumentType,
|
|
4
|
+
XmlElement,
|
|
5
|
+
XmlError,
|
|
6
|
+
XmlProcessingInstruction,
|
|
7
|
+
} from "@rgrove/parse-xml";
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
childXmlContext,
|
|
12
|
+
hasSemanticXmlFailure,
|
|
13
|
+
isXmlErrorRootName,
|
|
14
|
+
normalizedXmlName,
|
|
15
|
+
rootXmlContext,
|
|
16
|
+
type XmlSemanticBranch,
|
|
17
|
+
} from "./submit-check-xml-semantics.js";
|
|
18
|
+
|
|
19
|
+
const MIN_RECORDED_XML_LENGTH = 128;
|
|
20
|
+
// Recorded fixtures must remain reviewable; this pre-allocation cap also bounds the parser tree.
|
|
21
|
+
export const MAX_RECORDED_XML_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const MAX_RECORDED_XML_DEPTH = 64;
|
|
23
|
+
const MAX_RECORDED_XML_ELEMENTS = 50_000;
|
|
24
|
+
const XML_DOCTYPE_PATTERN = /<!DOCTYPE\b/i;
|
|
25
|
+
const REJECTED_RECORDED_XML_ROOT_NAMES: ReadonlySet<string> = new Set(["body", "html", "head"]);
|
|
26
|
+
|
|
27
|
+
// Recognizes a recorded operation value that is a substantive, well-formed XML
|
|
28
|
+
// success payload — the shape captured by `apifuse record` against upstreams
|
|
29
|
+
// that return XML (e.g. Korean public-data APIs). Fails closed on malformed XML,
|
|
30
|
+
// HTML, DTD/processing-instruction payloads, oversized/deep/wide trees, error
|
|
31
|
+
// roots, and failure/control-only envelopes. Uses a maintained parser rather
|
|
32
|
+
// than regex so entity/namespace/CDATA handling is correct.
|
|
33
|
+
export function hasSubstantiveXmlStructure(
|
|
34
|
+
value: string,
|
|
35
|
+
parser: typeof parseXml = parseXml,
|
|
36
|
+
): boolean {
|
|
37
|
+
if (Buffer.byteLength(value, "utf8") > MAX_RECORDED_XML_BYTES) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const xml = value.trim();
|
|
41
|
+
if (xml.length < MIN_RECORDED_XML_LENGTH || XML_DOCTYPE_PATTERN.test(xml)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let document: ReturnType<typeof parseXml>;
|
|
46
|
+
try {
|
|
47
|
+
document = parser(xml, { preserveDocumentType: true });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof XmlError || error instanceof RangeError) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
document.children.some(
|
|
56
|
+
(child) => child instanceof XmlDocumentType || child instanceof XmlProcessingInstruction,
|
|
57
|
+
)
|
|
58
|
+
) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const root = document.root;
|
|
63
|
+
if (root === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const rootName = normalizedXmlName(root.name);
|
|
67
|
+
if (REJECTED_RECORDED_XML_ROOT_NAMES.has(rootName) || isXmlErrorRootName(rootName)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pending: Array<{
|
|
72
|
+
readonly branch: XmlSemanticBranch;
|
|
73
|
+
readonly element: XmlElement;
|
|
74
|
+
readonly depth: number;
|
|
75
|
+
}> = [
|
|
76
|
+
{
|
|
77
|
+
element: root,
|
|
78
|
+
depth: 1,
|
|
79
|
+
branch: rootXmlContext(rootName),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
const leafNames = new Set<string>();
|
|
83
|
+
let leafTextLength = 0;
|
|
84
|
+
let elementCount = 0;
|
|
85
|
+
while (pending.length > 0) {
|
|
86
|
+
const current = pending.pop();
|
|
87
|
+
if (current === undefined) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
elementCount += 1;
|
|
91
|
+
if (
|
|
92
|
+
elementCount > MAX_RECORDED_XML_ELEMENTS ||
|
|
93
|
+
current.depth > MAX_RECORDED_XML_DEPTH ||
|
|
94
|
+
hasSemanticXmlFailure(current.element, current.branch)
|
|
95
|
+
) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const childElements: XmlElement[] = [];
|
|
100
|
+
for (const child of current.element.children) {
|
|
101
|
+
if (child instanceof XmlProcessingInstruction) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (child instanceof XmlElement) {
|
|
105
|
+
childElements.push(child);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (childElements.length === 0) {
|
|
109
|
+
const leafText = current.element.text.trim();
|
|
110
|
+
// Only substantive *domain* leaves count as evidence. Control/error
|
|
111
|
+
// leaves (resultCode, resultMsg, header status, …) are not payload data,
|
|
112
|
+
// so a control-only success envelope with no real records is rejected.
|
|
113
|
+
if (
|
|
114
|
+
leafText.length > 0 &&
|
|
115
|
+
current.depth >= 3 &&
|
|
116
|
+
current.branch !== "control" &&
|
|
117
|
+
current.branch !== "error"
|
|
118
|
+
) {
|
|
119
|
+
leafNames.add(normalizedXmlName(current.element.name));
|
|
120
|
+
leafTextLength += leafText.length;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
for (const child of childElements) {
|
|
125
|
+
const childName = normalizedXmlName(child.name);
|
|
126
|
+
pending.push({
|
|
127
|
+
element: child,
|
|
128
|
+
depth: current.depth + 1,
|
|
129
|
+
branch: childXmlContext(current.branch, childName),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return leafNames.size >= 2 && leafTextLength >= 16;
|
|
134
|
+
}
|