@volter/twin-standard 1.0.158 → 1.0.159
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/README.md +3 -2
- package/client-sdks/package.json +1 -0
- package/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/client-sdks/package.json +1 -0
- package/dist/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/src/assessment.d.ts +13 -3
- package/dist/src/assessment.js +10 -6
- package/dist/src/browser-assessment.js +16 -13
- package/dist/src/clients/harness.d.ts +25 -4
- package/dist/src/clients/harness.js +184 -22
- package/dist/src/clients/jira.js +11 -2
- package/dist/src/clients/run.js +19 -14
- package/dist/src/conformance.d.ts +2 -0
- package/dist/src/conformance.js +392 -184
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +3 -0
- package/dist/src/gate.js +4 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/protocol-3.js +8 -3
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/score-pack.js +6 -5
- package/dist/src/tools/vendor-examples.js +91 -80
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +182 -133
- package/dist/src/walk/journey-kit.d.ts +3 -2
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/replay.d.ts +23 -0
- package/dist/src/walk/replay.js +118 -0
- package/dist/src/walk/serve-pack.d.ts +2 -2
- package/dist/src/walk/serve-pack.js +38 -2
- package/dist/src/walk/shape.d.ts +25 -4
- package/dist/src/walk/shape.js +166 -56
- package/dist/src/walk/vendor-fetch.d.ts +7 -2
- package/dist/src/walk/vendor-fetch.js +13 -2
- package/dist/src/walk/walk-database.d.ts +9 -1
- package/dist/src/walk/walk-database.js +35 -13
- package/dist/src/walk/xml-view.d.ts +3 -2
- package/dist/src/walk/xml-view.js +5 -4
- package/package.json +3 -3
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/run.ts +17 -15
- package/src/conformance.ts +313 -128
- package/src/consumer.ts +83 -21
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +3 -1
- package/src/gate.ts +3 -0
- package/src/lanes.ts +7 -0
- package/src/protocol-3.ts +7 -3
- package/src/spec-documents.ts +92 -3
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/score-pack.ts +7 -6
- package/src/tools/vendor-examples.ts +88 -75
- package/src/walk/behavior-journey.ts +145 -109
- package/src/walk/journey-kit.ts +7 -5
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +25 -5
- package/src/walk/shape.ts +156 -54
- package/src/walk/vendor-fetch.ts +16 -4
- package/src/walk/walk-database.ts +41 -15
- package/src/walk/xml-view.ts +5 -4
|
@@ -48,7 +48,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
48
48
|
import { gunzipSync } from 'node:zlib';
|
|
49
49
|
import { inMemoryWorld, walk, type Failure, usePacks } from '../walk/behavior-journey.ts';
|
|
50
50
|
import { vendorFetch, vendorStream } from '../walk/vendor-fetch.ts';
|
|
51
|
-
import { at as selectorAt, captureAt, expandFills, isResp, isGit,
|
|
51
|
+
import { at as selectorAt, captureAt, expandFills, isResp, isGit, isSocket, isWait, type Journey, type JourneyStep, type LineStep } from '../walk/journey-kit.ts';
|
|
52
52
|
import { lanesOf, shapeChecker } from '../walk/shape.ts';
|
|
53
53
|
import { applySpecPatch, discoveryToOpenAPI, mergeDocuments, readSpecDocuments, slug, type SpecPatch } from '../spec.ts';
|
|
54
54
|
import { packsRoot } from './packs-root.ts';
|
|
@@ -63,7 +63,7 @@ usePacks(PACKS);
|
|
|
63
63
|
* still be the sample's type. `schema` is the served spec's schema of the answer, which says where null is allowed. */
|
|
64
64
|
/** `placeholder`: strings of the published input matching it stand for ids the World mints (openai's `vs_abc123`), so the
|
|
65
65
|
* step sends a string there, not that one. */
|
|
66
|
-
type Example = { names?: string[]; wire?: 'client'; patchRemoved?: boolean; id: string; operation: string; lane?: string; status?: number; output: unknown; input?: unknown; partial?: boolean; placeholder?: RegExp; target?: string; restXml?:
|
|
66
|
+
type Example = { names?: string[]; wire?: 'client'; patchRemoved?: boolean; id: string; operation: string; lane?: string; status?: number; output: unknown; input?: unknown; partial?: boolean; placeholder?: RegExp; target?: string; restXml?: RestXml; lines?: string[]; sample?: boolean; schema?: SpecSchema; request?: { method: string; url: string; headers?: Record<string, string> }; authorizationAbsent?: boolean };
|
|
67
67
|
/** A served spec's schema, its references resolved lazily (`resolveSchema`). */
|
|
68
68
|
type SpecSchema = { $ref?: string; nullable?: boolean; properties?: Record<string, SpecSchema>; items?: SpecSchema; allOf?: SpecSchema[]; anyOf?: SpecSchema[]; oneOf?: SpecSchema[]; [k: string]: unknown };
|
|
69
69
|
let specSchemas: Record<string, SpecSchema> = {};
|
|
@@ -71,6 +71,7 @@ let specSchemas: Record<string, SpecSchema> = {};
|
|
|
71
71
|
const schemasByOperation: Record<string, Record<string, SpecSchema>> = {};
|
|
72
72
|
/** Each operation's request-body schema and its JSON answers' schemas by status, as the served spec gives them. */
|
|
73
73
|
const operationSchemas: Record<string, { request?: SpecSchema; responses: Record<string, SpecSchema> }> = {};
|
|
74
|
+
const operationKey = (operation: string, lane: string | undefined): string => lane ? `${lane}:${operation}` : operation;
|
|
74
75
|
|
|
75
76
|
/** A page's placeholder id in an answer-map key is the id sent at the corresponding published request position.
|
|
76
77
|
* Only declared placeholders bind, and an ambiguous repeated placeholder cannot stand for a guessed map key. */
|
|
@@ -107,7 +108,9 @@ function served<T extends object>(pkg: string, doc: T): T {
|
|
|
107
108
|
|
|
108
109
|
// ── a restXml service's examples (S3's): the input where each member's binding puts it, the answer read by its shape ──
|
|
109
110
|
type Json = any;
|
|
110
|
-
|
|
111
|
+
type SmithyModel = { shapes: Record<string, Json> };
|
|
112
|
+
type RestXml = { id: string; model: SmithyModel };
|
|
113
|
+
const restXmlService = (model: SmithyModel): boolean => Object.values(model.shapes).some((shape) => shape.type === 'service' && shape.traits?.['aws.protocols#restXml'] !== undefined);
|
|
111
114
|
/** A translated placeholder-key map is a complete map even when its surrounding documented request is partial. */
|
|
112
115
|
function requestMapKeysHold(published: unknown, translated: unknown, sent: unknown, placeholder?: RegExp): boolean {
|
|
113
116
|
if (!placeholder || !published || typeof published !== 'object') return true;
|
|
@@ -122,10 +125,10 @@ function requestMapKeysHold(published: unknown, translated: unknown, sent: unkno
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
const PRELUDE: Record<string, string> = { Boolean: 'boolean', PrimitiveBoolean: 'boolean', Integer: 'integer', PrimitiveInteger: 'integer', Long: 'long', PrimitiveLong: 'long', Float: 'float', Double: 'double' };
|
|
125
|
-
const scalarType = (target: string): string =>
|
|
126
|
-
const scalarOf = (target: string, v: unknown): unknown => {
|
|
128
|
+
const scalarType = (model: SmithyModel, target: string): string => model.shapes[target]?.type ?? PRELUDE[target.split('#')[1] ?? ''] ?? 'string';
|
|
129
|
+
const scalarOf = (model: SmithyModel, target: string, v: unknown): unknown => {
|
|
127
130
|
if (typeof v !== 'string') return v;
|
|
128
|
-
const t = scalarType(target);
|
|
131
|
+
const t = scalarType(model, target);
|
|
129
132
|
if (t === 'boolean') return v === 'true';
|
|
130
133
|
if (['integer', 'long', 'short', 'byte'].includes(t)) return /^[+-]?\d+$/.test(v) ? exactInteger(v) : v;
|
|
131
134
|
return ['float', 'double'].includes(t) && v !== '' && !Number.isNaN(Number(v)) ? Number(v) : v;
|
|
@@ -134,39 +137,39 @@ const arrayOf = (v: unknown): unknown[] => (v === undefined || v === '' ? [] : A
|
|
|
134
137
|
|
|
135
138
|
/** An answer's XML members (xml-view) read by the model's shape: each member under its xmlName, a flattened list the
|
|
136
139
|
* repeated element, a wrapped list its container's member elements, scalars as their shape's type. */
|
|
137
|
-
function typedXml(target: string, v: unknown): unknown {
|
|
138
|
-
const shape =
|
|
139
|
-
if (!shape || shape.type !== 'structure') return scalarOf(target, v);
|
|
140
|
+
function typedXml(model: SmithyModel, target: string, v: unknown): unknown {
|
|
141
|
+
const shape = model.shapes[target];
|
|
142
|
+
if (!shape || shape.type !== 'structure') return scalarOf(model, target, v);
|
|
140
143
|
if (!v || typeof v !== 'object') return v === '' ? {} : v;
|
|
141
144
|
const obj = v as Record<string, unknown>;
|
|
142
145
|
const out: Record<string, unknown> = {};
|
|
143
146
|
for (const [name, m] of Object.entries((shape.members ?? {}) as Record<string, Json>)) {
|
|
144
147
|
if (Object.keys(m.traits ?? {}).some((t) => /^smithy\.api#http(Header|PrefixHeaders|ResponseCode)$/.test(t))) continue;
|
|
145
148
|
const el = String(m.traits?.['smithy.api#xmlName'] ?? name);
|
|
146
|
-
const ts =
|
|
149
|
+
const ts = model.shapes[m.target];
|
|
147
150
|
const raw = obj[el];
|
|
148
151
|
if (ts?.type === 'list') {
|
|
149
|
-
if (m.traits?.['smithy.api#xmlFlattened'] !== undefined) { if (raw !== undefined) out[name] = arrayOf(raw).map((i) => typedXml(ts.member.target, i)); continue; }
|
|
152
|
+
if (m.traits?.['smithy.api#xmlFlattened'] !== undefined) { if (raw !== undefined) out[name] = arrayOf(raw).map((i) => typedXml(model, ts.member.target, i)); continue; }
|
|
150
153
|
if (raw === undefined) continue;
|
|
151
154
|
const memberEl = String(ts.member.traits?.['smithy.api#xmlName'] ?? 'member');
|
|
152
|
-
out[name] = arrayOf(raw === '' ? undefined : (raw as Record<string, unknown>)[memberEl]).map((i) => typedXml(ts.member.target, i));
|
|
153
|
-
} else if (raw !== undefined) out[name] = typedXml(m.target, raw);
|
|
155
|
+
out[name] = arrayOf(raw === '' ? undefined : (raw as Record<string, unknown>)[memberEl]).map((i) => typedXml(model, ts.member.target, i));
|
|
156
|
+
} else if (raw !== undefined) out[name] = typedXml(model, m.target, raw);
|
|
154
157
|
}
|
|
155
158
|
return out;
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
/** A restXml operation's success answer as its output structure: the body's members by shape (or its payload
|
|
159
162
|
* member's), and the members its headers carry. */
|
|
160
|
-
function restXmlAnswer(opId:
|
|
161
|
-
const out =
|
|
163
|
+
function restXmlAnswer({ id: opId, model }: RestXml, body: unknown, headers: Record<string, string> = {}): unknown {
|
|
164
|
+
const out = model.shapes[model.shapes[opId]?.output?.target ?? ''];
|
|
162
165
|
if (!out) return body;
|
|
163
166
|
const members = Object.entries((out.members ?? {}) as Record<string, Json>);
|
|
164
167
|
const payload = members.find(([, m]) => m.traits?.['smithy.api#httpPayload'] !== undefined);
|
|
165
|
-
const answer = (payload ? { [payload[0]]: typedXml(payload[1].target, body) } : typedXml(
|
|
168
|
+
const answer = (payload ? { [payload[0]]: typedXml(model, payload[1].target, body) } : typedXml(model, model.shapes[opId].output.target, body)) as Record<string, unknown>;
|
|
166
169
|
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
167
170
|
for (const [name, m] of members) {
|
|
168
171
|
const h = m.traits?.['smithy.api#httpHeader'];
|
|
169
|
-
if (h !== undefined && lower[String(h).toLowerCase()] !== undefined) answer[name] = scalarOf(m.target, lower[String(h).toLowerCase()]);
|
|
172
|
+
if (h !== undefined && lower[String(h).toLowerCase()] !== undefined) answer[name] = scalarOf(model, m.target, lower[String(h).toLowerCase()]);
|
|
170
173
|
// a map bound to a header prefix (S3's Metadata, `x-amz-meta-*`): every header under it, by the rest of its name
|
|
171
174
|
const prefix = m.traits?.['smithy.api#httpPrefixHeaders'];
|
|
172
175
|
if (prefix !== undefined) answer[name] = Object.fromEntries(Object.entries(lower).filter(([k]) => k.startsWith(String(prefix).toLowerCase())).map(([k, v]) => [k.slice(String(prefix).length), v]));
|
|
@@ -176,8 +179,8 @@ function restXmlAnswer(opId: string, body: unknown, headers: Record<string, stri
|
|
|
176
179
|
|
|
177
180
|
/** Where a restXml example's input is not what the step sends: each member the example gives, at its binding (a path
|
|
178
181
|
* label, a query key, a header; a payload or body member is the step's body and is not compared). */
|
|
179
|
-
function restXmlInputProblems(opId:
|
|
180
|
-
const shape =
|
|
182
|
+
function restXmlInputProblems({ id: opId, model }: RestXml, input: Record<string, unknown>, path: string, headers: Record<string, string>, placeholder: RegExp, rawPath?: string): string[] {
|
|
183
|
+
const shape = model.shapes[model.shapes[opId]?.input?.target ?? ''];
|
|
181
184
|
const url = new URL(path, 'http://twin.local');
|
|
182
185
|
// the step as written, its captures unfilled: a value it takes from a capture is the World's own id (an S3 upload's
|
|
183
186
|
// UploadId), which stands for the example's literal as a spec's ids stand for the World's (architecture, "Published examples")
|
|
@@ -215,11 +218,13 @@ function lenientJson(text: string): string {
|
|
|
215
218
|
* response's `example`/`examples` (a `$ref` to `components.examples` followed), a Swagger 2 response's `examples`, Stripe's
|
|
216
219
|
* fixtures (one sample per resource, beside its spec), and OpenAI's `x-oaiMeta.examples` (its `response` is JSON text; one that is not JSON, a stream or an abridged answer, is
|
|
217
220
|
* kept as text and compared with the published framing and payload). */
|
|
218
|
-
function published(pkg: string): Example[] {
|
|
221
|
+
function published(pkg: string, lane: string | undefined): Example[] {
|
|
219
222
|
// Both the vendor API and its lanes publish examples; a root spec must not hide a lane. A lane's example names its
|
|
220
223
|
// lane, the operation being the lane's own (its owner is `<lane>:<id>` in the vendor's owners)
|
|
221
|
-
const
|
|
222
|
-
|
|
224
|
+
const smithy = ['smithy.json.gz', 'smithy.json'].map((name) => join(pkg, 'spec', name)).find(existsSync);
|
|
225
|
+
const model = smithy ? parseExactJson((smithy.endsWith('.gz') ? gunzipSync(readFileSync(smithy)) : readFileSync(smithy)).toString('utf8')) as SmithyModel : undefined;
|
|
226
|
+
const own = [...(hasOwnSurface(pkg) ? fromSpec(pkg, lane, model) : []), ...fromDocs(pkg, lane, model)].map((e) => ({ ...e, lane }));
|
|
227
|
+
return [...own, ...lanesOf(pkg).flatMap((dir) => published(dir, lane ? `${lane}:${basename(dir)}` : basename(dir)))];
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
/** The examples a vendor's documentation pages publish beside its spec (a schedule page's expressions, each with what
|
|
@@ -231,7 +236,7 @@ function published(pkg: string): Example[] {
|
|
|
231
236
|
* reference writes every property, and every scalar as a string, `"verified": "true"` beside a schema saying boolean)
|
|
232
237
|
* shows what the answer can hold, not one request's answer: its example is a sample against the served schema, and a
|
|
233
238
|
* string where the schema says number, integer or boolean is read as that type, in the answer and the request body. */
|
|
234
|
-
function fromDocs(pkg: string): Example[] {
|
|
239
|
+
function fromDocs(pkg: string, lane: string | undefined, model: SmithyModel | undefined): Example[] {
|
|
235
240
|
const file = join(pkg, 'spec', 'doc-examples.json');
|
|
236
241
|
if (!existsSync(file)) return [];
|
|
237
242
|
const docs = parseExactJson(readFileSync(file, 'utf8')) as Array<{ wire?: 'client'; title: string; source: string; quote?: string; operation: string; input?: unknown; output?: unknown; means: string; status?: number; rendered?: boolean; placeholders?: { names: string[]; quote: string }; request?: { method: string; url: string; headers?: Record<string, string> } }>;
|
|
@@ -261,11 +266,10 @@ function fromDocs(pkg: string): Example[] {
|
|
|
261
266
|
// a page that names its own variables for the reader to fill ("Make sure to fill in the … BRANCH_NAME … variables")
|
|
262
267
|
// lists them with that sentence quoted: each stands for any value, and the published text stays as written
|
|
263
268
|
for (const d of docs) if (d.placeholders && (!d.placeholders.names?.length || !d.placeholders.quote?.trim())) throw new Error(`${file}: "${d.title}" names placeholders without the page's words for them (placeholders.quote)`);
|
|
264
|
-
const smithy = ['smithy.json.gz', 'smithy.json'].some((f) => existsSync(join(pkg, 'spec', f)));
|
|
265
269
|
// a page's example is a fragment (an expression, not the request that carries it): the body holds it
|
|
266
270
|
return docs.map((d) => {
|
|
267
271
|
if (!d.rendered) return fromPage(d, d.output, d.input);
|
|
268
|
-
const schemas = operationSchemas[d.operation];
|
|
272
|
+
const schemas = operationSchemas[operationKey(d.operation, lane)];
|
|
269
273
|
const schema = schemas?.responses[String(d.status ?? 200)];
|
|
270
274
|
if (!schema) throw new Error(`${file}: "${d.title}" is rendered from a schema the served spec does not give (${d.operation}, ${d.status ?? 200})`);
|
|
271
275
|
// the renderer writes any string it cannot fill as the word "string": in a rendered input it stands for any string
|
|
@@ -276,7 +280,13 @@ function fromDocs(pkg: string): Example[] {
|
|
|
276
280
|
function fromPage(d: (typeof docs)[number], output: unknown, input: unknown): Example {
|
|
277
281
|
const named = d.placeholders?.names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
278
282
|
const placeholder = named ? new RegExp(`${placeholderIds.source}|^(?:${named})$`, placeholderIds.flags) : placeholderIds;
|
|
279
|
-
|
|
283
|
+
let restXml: RestXml | undefined;
|
|
284
|
+
if (model && restXmlService(model)) {
|
|
285
|
+
const id = Object.keys(model.shapes).find((key) => key.endsWith(`#${d.operation}`));
|
|
286
|
+
if (!id) throw new Error(`${file}: "${d.title}" names no operation in its XML service (${d.operation})`);
|
|
287
|
+
restXml = { id, model };
|
|
288
|
+
}
|
|
289
|
+
return { names: d.placeholders?.names, ...(d.wire ? { wire: d.wire } : {}), id: `${d.operation}#doc ${d.title}`, operation: d.operation, output, ...(d.status ? { status: d.status } : {}), ...(input !== undefined ? { input, partial: true, placeholder } : {}), ...(model ? (restXml ? { restXml } : { target: d.operation }) : {}), ...(d.request ? { request: d.request } : {}), ...(authorizationAbsent.has(d) ? { authorizationAbsent: true } : {}) };
|
|
280
290
|
}
|
|
281
291
|
}
|
|
282
292
|
|
|
@@ -351,11 +361,23 @@ function decodedExample(media: string, value: unknown): unknown {
|
|
|
351
361
|
try { return parseExactJson(value); } catch { return value; }
|
|
352
362
|
}
|
|
353
363
|
|
|
354
|
-
function fromSpec(pkg: string): Example[] {
|
|
364
|
+
function fromSpec(pkg: string, lane: string | undefined, model: SmithyModel | undefined): Example[] {
|
|
355
365
|
const rfc = existsSync(join(pkg, 'spec')) ? readdirSync(join(pkg, 'spec')).find((f) => /^rfc\d+\.txt$/.test(f)) : undefined;
|
|
356
366
|
if (rfc) return scenarios(readFileSync(join(pkg, 'spec', rfc), 'utf8'));
|
|
367
|
+
const out: Example[] = [];
|
|
368
|
+
if (model) {
|
|
369
|
+
const restXml = restXmlService(model);
|
|
370
|
+
for (const [id, shape] of Object.entries(model.shapes)) {
|
|
371
|
+
const examples = shape.traits?.['smithy.api#examples'] as Array<{ title: string; input?: unknown; output?: unknown; error?: { content?: unknown } }> | undefined;
|
|
372
|
+
const operation = id.split('#')[1];
|
|
373
|
+
// an awsJson operation is named by its x-amz-target header, `<service>.<operation>`; its body is the input. A
|
|
374
|
+
// restXml operation (S3's) is named by its method and path, and its input is where each member's binding puts it
|
|
375
|
+
for (const e of examples ?? []) out.push({ id: `${operation}#${e.title}`, operation, output: e.error ? e.error.content : e.output, input: e.input ?? {}, ...(restXml ? { restXml: { id, model } } : { target: operation }) });
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
357
379
|
// a spec vendored as published or gzipped, as derive-pack reads it
|
|
358
|
-
const spec = ['
|
|
380
|
+
const spec = ['openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml', 'discovery.json.gz', 'discovery.json'].map((f) => join(pkg, 'spec', f)).find((p) => existsSync(p));
|
|
359
381
|
// a Protocol Buffers service publishes no examples in its .proto files: its examples are its documentation's alone
|
|
360
382
|
if (!spec && existsSync(join(pkg, 'spec', 'proto'))) return [];
|
|
361
383
|
// a command table (Redis's src/commands/*.json) publishes no examples either: its examples are its documentation's
|
|
@@ -366,20 +388,6 @@ function fromSpec(pkg: string): Example[] {
|
|
|
366
388
|
const documents = spec ? undefined : readSpecDocuments(join(pkg, 'spec'));
|
|
367
389
|
if (!spec && !documents) throw new Error(`${pkg}: no published-example reader for this spec yet`);
|
|
368
390
|
const text = spec ? (spec.endsWith('.gz') ? gunzipSync(readFileSync(spec)) : readFileSync(spec)).toString('utf8') : '';
|
|
369
|
-
const out: Example[] = [];
|
|
370
|
-
if (spec && /smithy\.json(\.gz)?$/.test(spec)) {
|
|
371
|
-
const model = parseExactJson(text) as { shapes: Record<string, { type?: string; traits?: Record<string, unknown> }> };
|
|
372
|
-
const restXml = Object.values(model.shapes).some((sh) => sh.type === 'service' && sh.traits?.['aws.protocols#restXml'] !== undefined);
|
|
373
|
-
if (restXml) xmlModel = model as { shapes: Record<string, Json> };
|
|
374
|
-
for (const [id, shape] of Object.entries(model.shapes)) {
|
|
375
|
-
const examples = shape.traits?.['smithy.api#examples'] as Array<{ title: string; input?: unknown; output?: unknown; error?: { content?: unknown } }> | undefined;
|
|
376
|
-
const operation = id.split('#')[1];
|
|
377
|
-
// an awsJson operation is named by its x-amz-target header, `<service>.<operation>`; its body is the input. A
|
|
378
|
-
// restXml operation (S3's) is named by its method and path, and its input is where each member's binding puts it
|
|
379
|
-
for (const e of examples ?? []) out.push({ id: `${operation}#${e.title}`, operation, output: e.error ? e.error.content : e.output, input: e.input ?? {}, ...(restXml ? { restXml: id } : { target: operation }) });
|
|
380
|
-
}
|
|
381
|
-
return out;
|
|
382
|
-
}
|
|
383
391
|
type Op = { operationId?: string; responses?: Record<string, Record<string, unknown>>; 'x-oaiMeta'?: { examples?: unknown } };
|
|
384
392
|
type Doc = { paths: Record<string, Record<string, Op>>; components?: { examples?: Record<string, { value?: unknown }>; schemas?: Record<string, SpecSchema> }; definitions?: Record<string, SpecSchema> };
|
|
385
393
|
// a Google API Discovery document publishes no examples; it is read as the OpenAPI document derive-pack reads it as,
|
|
@@ -399,8 +407,9 @@ function fromSpec(pkg: string): Example[] {
|
|
|
399
407
|
if (!op || typeof op !== 'object' || !('responses' in op)) continue;
|
|
400
408
|
// Swagger 2: the request is the `in: body` parameter's schema, an answer its response's `schema`
|
|
401
409
|
const bodyParam = ((op as { parameters?: Array<{ in?: string; schema?: SpecSchema }> }).parameters ?? []).find((x) => x.in === 'body');
|
|
402
|
-
|
|
403
|
-
|
|
410
|
+
const key = operationKey(idOf(op, method, path), lane);
|
|
411
|
+
schemasByOperation[key] = specSchemas;
|
|
412
|
+
operationSchemas[key] = {
|
|
404
413
|
request: json((op as { requestBody?: { content?: unknown } }).requestBody?.content) ?? bodyParam?.schema,
|
|
405
414
|
responses: Object.fromEntries(Object.entries(op.responses ?? {}).flatMap(([code, r]) => { const sch = json(r.content) ?? (r as { schema?: SpecSchema }).schema; return sch ? [[code, sch]] : []; })),
|
|
406
415
|
};
|
|
@@ -829,17 +838,17 @@ function outputSchemaMismatches(schema: SpecSchema | undefined, value: unknown,
|
|
|
829
838
|
* recorded with the per-example classification. Request echoes and generated output are absent. */
|
|
830
839
|
const storedCollectionFields: Record<string, Record<string, string[]>> = {
|
|
831
840
|
'ai-gateway': { listModels: ['data'] },
|
|
832
|
-
aws: { ListBuckets: ['Buckets'], ListMultipartUploads: ['Uploads'], ListObjects: ['Contents', 'CommonPrefixes'], ListObjectsV2: ['Contents', 'CommonPrefixes'] },
|
|
833
|
-
cloudflare: { ListBuckets: ['Buckets'], ListMultipartUploads: ['Uploads'], ListObjects: ['Contents', 'CommonPrefixes'], ListObjectsV2: ['Contents', 'CommonPrefixes'] },
|
|
841
|
+
aws: { 's3:ListBuckets': ['Buckets'], 's3:ListMultipartUploads': ['Uploads'], 's3:ListObjects': ['Contents', 'CommonPrefixes'], 's3:ListObjectsV2': ['Contents', 'CommonPrefixes'] },
|
|
842
|
+
cloudflare: { 'r2:ListBuckets': ['Buckets'], 'r2:ListMultipartUploads': ['Uploads'], 'r2:ListObjects': ['Contents', 'CommonPrefixes'], 'r2:ListObjectsV2': ['Contents', 'CommonPrefixes'] },
|
|
834
843
|
discord: { oauth2_token: ['guild.roles'], get_my_oauth2_authorization: ['scopes'] },
|
|
835
844
|
github: { 'repos/get-branch': ['commit.parents'], 'repos/get-commit': ['parents', 'files'], 'repos/get-content': ['', 'entries'], 'repos/compare-commits': ['commits', 'files'], 'git/get-tree': ['tree'], 'git/list-matching-refs': [''], 'repos/get-repo-rulesets': [''] },
|
|
836
|
-
hackernews: { getItem: ['kids'], getUser: ['submitted'], getUpdates: ['items', 'profiles'], getTopStories: [''], getNewStories: [''], getBestStories: [''], getAskStories: [''], getShowStories: [''], getJobStories: [''] },
|
|
845
|
+
hackernews: { 'api:getItem': ['kids'], 'api:getUser': ['submitted'], 'api:getUpdates': ['items', 'profiles'], 'api:getTopStories': [''], 'api:getNewStories': [''], 'api:getBestStories': [''], 'api:getAskStories': [''], 'api:getShowStories': [''], 'api:getJobStories': [''] },
|
|
837
846
|
linkedin: { findOrganizationAclsByRoleAssignee: ['elements'] },
|
|
838
847
|
'npm-registry': { 'libnpmsearch.searchStream': ['objects', 'objects.[].package.maintainers'] },
|
|
839
848
|
openrouter: { getCurrentKey: ['data.allowed_data_regions'], getModels: ['data'] },
|
|
840
849
|
redis: { SCAN: ['[].1'] },
|
|
841
850
|
resend: { 'domains/get': ['records'], 'emails/list-receiving': ['data', 'data.[].attachments'] },
|
|
842
|
-
sentry: { listOrganizations: ['', '[].features'] },
|
|
851
|
+
sentry: { 'api:listOrganizations': ['', '[].features'] },
|
|
843
852
|
slack: { conversations_rename: ['channel.previous_names'], conversations_invite: ['channel.previous_names'], conversations_members: ['members'], conversations_list: ['channels', 'channels.[].previous_names'], users_conversations: ['channels', 'channels.[].previous_names'], conversations_info: ['channel.previous_names'], conversations_replies: ['messages'], conversations_history: ['messages'], usergroups_list: ['usergroups'] },
|
|
844
853
|
tinybird: { datasources: ['datasources'], jobs: ['jobs'], pipes: ['pipes'], tokens: ['tokens'], token_get: ['scopes'], token_refresh: ['scopes'], pipe_set_endpoint: ['nodes'] },
|
|
845
854
|
};
|
|
@@ -931,7 +940,7 @@ if (!vendor || vendor.startsWith('-')) { console.error('usage: twin-standard exa
|
|
|
931
940
|
const PKG = join(PACKS, vendor);
|
|
932
941
|
const file = join(PKG, 'journeys', 'vendor-examples.json');
|
|
933
942
|
await loadPlaceholders(PKG);
|
|
934
|
-
const examples = published(PKG);
|
|
943
|
+
const examples = published(PKG, undefined);
|
|
935
944
|
const byId = new Map(examples.map((e) => [e.id, e]));
|
|
936
945
|
|
|
937
946
|
if (byId.size !== examples.length) throw new Error(`${vendor}: ${examples.length - byId.size} published examples share an id`);
|
|
@@ -948,9 +957,11 @@ const shape = await shapeChecker(PKG);
|
|
|
948
957
|
// (a page of an operation the spec lacks can match another's route)
|
|
949
958
|
for (const e of examples) {
|
|
950
959
|
if (e.patchRemoved || !e.request || !shape) continue;
|
|
951
|
-
const
|
|
960
|
+
const hit = shape.operationOf(e.request.method, new URL(e.request.url, `http://${journey.host ?? 'twin.local'}`).href, e.request.headers);
|
|
961
|
+
const routed = hit ? operationKey(hit.op.id, hit.lane) : undefined;
|
|
952
962
|
if (e.operation === 'gap' && routed === undefined) continue;
|
|
953
|
-
|
|
963
|
+
const expected = operationKey(e.operation, e.lane);
|
|
964
|
+
if (routed !== expected) throw new Error(`spec/doc-examples.json: "${e.id}" sends ${e.request.method} ${e.request.url}, which the spec routes to ${routed ?? 'no operation'}, not ${expected}`);
|
|
954
965
|
}
|
|
955
966
|
const sweeping = process.argv.includes('--sweep');
|
|
956
967
|
const sweepHits: string[] = [];
|
|
@@ -1065,17 +1076,19 @@ await walk(journey, world.root(), failures, {
|
|
|
1065
1076
|
},
|
|
1066
1077
|
after: (step, _path, answer, stepFailures) => {
|
|
1067
1078
|
if (isGit(step) || isWait(step) || isSocket(step)) return;
|
|
1068
|
-
|
|
1079
|
+
const request = answer.request;
|
|
1080
|
+
const hit = request ? shape?.operationOf(request.method, request.url, request.headers) : undefined;
|
|
1081
|
+
const operation = hit?.op;
|
|
1082
|
+
if (request) {
|
|
1069
1083
|
const st = step as JourneyStep;
|
|
1070
|
-
const method =
|
|
1084
|
+
const method = request.method;
|
|
1071
1085
|
// a read by what the operation does (an RPC wire POSTs its reads), else by its verb
|
|
1072
|
-
const
|
|
1073
|
-
const opClass = op?.class;
|
|
1086
|
+
const opClass = operation?.class;
|
|
1074
1087
|
const isRead = opClass ? ['retrieve', 'list', 'computed'].includes(opClass) : method === 'GET';
|
|
1075
|
-
if (isRead) reads.push({ path: bare(
|
|
1088
|
+
if (isRead) reads.push({ path: bare(request.url), body: JSON.stringify(answer.body ?? '') });
|
|
1076
1089
|
else if (st.example && answer.status < 300 && answer.body && typeof answer.body === 'object' && !Array.isArray(answer.body) && Object.keys(answer.body).length <= 3) {
|
|
1077
1090
|
const body = answer.body as Record<string, unknown>;
|
|
1078
|
-
const segs = bare(
|
|
1091
|
+
const segs = bare(request.url).split('/');
|
|
1079
1092
|
// an action on a subject (`/emails/{id}/cancel`) touches the subject; a create touches the new id under the collection;
|
|
1080
1093
|
// a write whose subject is neither minted nor in its path (an RPC call naming it in its body) is left to the judge
|
|
1081
1094
|
const minted = typeof body.id === 'string' ? body.id : '';
|
|
@@ -1083,8 +1096,7 @@ await walk(journey, world.root(), failures, {
|
|
|
1083
1096
|
if (minted || idInPath) {
|
|
1084
1097
|
const base = minted && !segs.includes(minted) ? `${segs.join('/')}/${minted}` : /^[a-z_-]+$/.test(segs.at(-1) ?? '') && segs.length > 2 ? segs.slice(0, -1).join('/') : segs.join('/');
|
|
1085
1098
|
// only an effect the vendor gives a door to read: a GET of the subject or of its collection (a moderation has neither)
|
|
1086
|
-
const
|
|
1087
|
-
const gets = (at: string): boolean => shape?.operationOf('GET', at, hdrs)?.op.method.toUpperCase() === 'GET';
|
|
1099
|
+
const gets = (at: string): boolean => shape?.operationOf('GET', new URL(at, request.url).href, request.headers)?.op.method.toUpperCase() === 'GET';
|
|
1088
1100
|
const door = !shape || gets(base) || gets(base.split('/').slice(0, -1).join('/'));
|
|
1089
1101
|
if (door) thinWrites.push({ step: st.name, base, id: base.split('/').at(-1) ?? '', from: reads.length, deletes: method === 'DELETE' || opClass === 'delete' });
|
|
1090
1102
|
}
|
|
@@ -1104,38 +1116,40 @@ await walk(journey, world.root(), failures, {
|
|
|
1104
1116
|
if (!s.example) return;
|
|
1105
1117
|
const ex = byId.get(s.example);
|
|
1106
1118
|
if (!ex) { unknown.push(s.example); return; }
|
|
1107
|
-
|
|
1119
|
+
const key = operationKey(ex.operation, ex.lane);
|
|
1120
|
+
specSchemas = schemasByOperation[key] ?? {};
|
|
1108
1121
|
replayed.add(s.example);
|
|
1109
|
-
if (ex.operation === 'gap' && ex.request) {
|
|
1110
|
-
|
|
1111
|
-
if (s.method !== ex.request.method || sent.href !== ex.request.url) failures.push({ step: s.name, beat: 'EXAMPLE', detail: 'the unknown request differs from its recorded method or URL' });
|
|
1122
|
+
if (ex.operation === 'gap' && ex.request && request) {
|
|
1123
|
+
if (request.method !== ex.request.method || request.url !== ex.request.url) failures.push({ step: s.name, beat: 'EXAMPLE', detail: 'the unknown request differs from its recorded method or URL' });
|
|
1112
1124
|
}
|
|
1113
|
-
if (ex.authorizationAbsent &&
|
|
1125
|
+
if (ex.authorizationAbsent && request && new Headers(request.headers).has('authorization')) failures.push({ step: s.name, beat: 'EXAMPLE', detail: 'the recording has no Authorization header, but the replay sends one' });
|
|
1114
1126
|
// the step calls the example's operation (the spec's, as the walk's SHAPE check reads the request)
|
|
1115
|
-
if (
|
|
1116
|
-
const called =
|
|
1117
|
-
if (called && called !==
|
|
1127
|
+
if (request && shape) {
|
|
1128
|
+
const called = hit ? operationKey(hit.op.id, hit.lane) : undefined;
|
|
1129
|
+
if (called && called !== key) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `calls ${called}, not the example's operation ${key}` });
|
|
1118
1130
|
}
|
|
1119
1131
|
// the example is sent as published: its input as the body, to its operation
|
|
1120
1132
|
// a published field the step leaves out (`unsent`: a rendered value naming an object the World does not hold) must
|
|
1121
1133
|
// be one the operation's request schema does not require
|
|
1122
1134
|
for (const [path, why] of Object.entries(s.unsent ?? {})) {
|
|
1123
1135
|
if (!why) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `unsent ${path} gives no reason` });
|
|
1124
|
-
if (requiredAt(operationSchemas[
|
|
1136
|
+
if (requiredAt(operationSchemas[key]?.request, path)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `unsent ${path} is required by the operation's request schema` });
|
|
1125
1137
|
}
|
|
1126
1138
|
const publishedBody = exampleBody({ ...s, body: ex.input, form: undefined, bodyBase64: undefined }, ex.input);
|
|
1127
1139
|
const inputType = Object.entries(ex.request?.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1]
|
|
1128
|
-
??
|
|
1140
|
+
?? (request ? new Headers(request.headers).get('content-type') ?? undefined : undefined);
|
|
1129
1141
|
const publishedInput = s.unsent && publishedBody !== undefined ? withoutPaths(publishedBody, Object.keys(s.unsent)) : publishedBody;
|
|
1130
1142
|
// the body the step sends, every check below reading it alike: no body (a GET) is the empty input a page publishes for it
|
|
1131
1143
|
const body = isResp(s) ? s.resp : exampleBody(s, publishedInput) ?? {};
|
|
1132
1144
|
// A placeholder used as a request-map key is bound by its scalar request position, just as an answer key is.
|
|
1133
1145
|
const input = answerKeys(publishedInput, publishedInput, body, ex.placeholder, true);
|
|
1134
|
-
if (ex.restXml)
|
|
1146
|
+
if (ex.restXml) {
|
|
1147
|
+
if (request) for (const p of restXmlInputProblems(ex.restXml, (input ?? {}) as Record<string, unknown>, shape?.pathOf(request.method, request.url, request.headers) ?? request.url, request.headers, placeholderIds, (s as { path?: string }).path)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `the request is not the example's published input: ${p}` });
|
|
1148
|
+
}
|
|
1135
1149
|
else if (input !== undefined && (!requestMapKeysHold(publishedInput, input, body, ex.placeholder) || (ex.partial ? !holds(body, input, ex.placeholder, undefined, ex.names, false, inputType) : !same(body, input, ex.placeholder ?? /(?!)/, undefined, ex.names, false, inputType)))) failures.push({ step: s.name, beat: 'EXAMPLE', detail: typeof input === 'string' ? comparedText(body, input, ex.placeholder ?? /(?!)/, undefined, ex.names, inputType)?.problem ?? "the body is not the example's published input" : "the body is not the example's published input" });
|
|
1136
1150
|
// a scenario's turn is sent as the RFC writes it: its client lines (none for the greeting, a connect)
|
|
1137
1151
|
if (ex.lines && JSON.stringify([s.send ?? []].flat()) !== JSON.stringify(ex.lines)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `sends ${JSON.stringify(s.send ?? null)}, not the scenario's ${JSON.stringify(ex.lines)}` });
|
|
1138
|
-
const target =
|
|
1152
|
+
const target = request ? new Headers(request.headers).get('x-amz-target') : undefined;
|
|
1139
1153
|
if (ex.target && !target?.endsWith(`.${ex.target}`)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `x-amz-target is ${target}, not the example's operation ${ex.target}` });
|
|
1140
1154
|
if (ex.status && answer.status !== ex.status && !s.exampleDiffers?.status) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `answered ${answer.status}, the example ${ex.status}` });
|
|
1141
1155
|
if (stepFailures.length) return;
|
|
@@ -1166,9 +1180,8 @@ await walk(journey, world.root(), failures, {
|
|
|
1166
1180
|
const published = isResp(s) && publishedValue !== undefined ? (Array.isArray(s.resp?.[0]) ? publishedValue : [publishedValue]) : publishedValue;
|
|
1167
1181
|
const responseType = answer.headers?.['content-type'];
|
|
1168
1182
|
const literal = isResp(s) || (published !== null && typeof published !== 'object');
|
|
1169
|
-
const valueSchema = ex.schema ?? operationSchemas[
|
|
1170
|
-
comparisonCollections = [...(storedCollectionFields[vendor]?.[
|
|
1171
|
-
const operation = shape?.operationOf((s.method ?? 'GET').toUpperCase(), _path, { ...(journey.headers ?? {}), ...(s.headers ?? {}) })?.op;
|
|
1183
|
+
const valueSchema = ex.schema ?? operationSchemas[key]?.responses[String(answer.status)];
|
|
1184
|
+
comparisonCollections = [...(storedCollectionFields[vendor]?.[key] ?? [])];
|
|
1172
1185
|
// A derived resource list names its holder; never treat every array under a read as a list.
|
|
1173
1186
|
if (operation?.class === 'list' && operation.answers?.list) {
|
|
1174
1187
|
const holder = operation.answers.key ? (published as Record<string, unknown>)?.[operation.answers.key] : published;
|
|
@@ -1209,7 +1222,7 @@ await walk(journey, world.root(), failures, {
|
|
|
1209
1222
|
// an example of an operation the twin does not serve is breadth's gap (score-pack), counted here and not replayed
|
|
1210
1223
|
type Owned = { owners?: () => Record<string, string> } | undefined;
|
|
1211
1224
|
const owners = ((await vendorFetch(PACKS, vendor))?.(world.root()) as Owned)?.owners?.() ?? ((await vendorStream(PACKS, vendor))?.(world.root()) as Owned)?.owners?.() ?? {};
|
|
1212
|
-
const ownerOf = (e: Example): string | undefined => e.wire === 'client' ? 'gap' : owners[e.
|
|
1225
|
+
const ownerOf = (e: Example): string | undefined => e.wire === 'client' ? 'gap' : owners[operationKey(e.operation, e.lane)];
|
|
1213
1226
|
const patchRemoved = examples.filter((e) => e.patchRemoved);
|
|
1214
1227
|
const unserved = examples.filter((e) => !e.patchRemoved && ownerOf(e) === 'gap');
|
|
1215
1228
|
const recorded = Object.keys(journey.notReplayed ?? {});
|