@glubean/sdk 0.1.39 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract-core.d.ts +96 -0
- package/dist/contract-core.d.ts.map +1 -0
- package/dist/contract-core.js +747 -0
- package/dist/contract-core.js.map +1 -0
- package/dist/contract-http/adapter.d.ts +52 -0
- package/dist/contract-http/adapter.d.ts.map +1 -0
- package/dist/contract-http/adapter.js +650 -0
- package/dist/contract-http/adapter.js.map +1 -0
- package/dist/contract-http/factory.d.ts +32 -0
- package/dist/contract-http/factory.d.ts.map +1 -0
- package/dist/contract-http/factory.js +83 -0
- package/dist/contract-http/factory.js.map +1 -0
- package/dist/contract-http/flow-helpers.d.ts +12 -0
- package/dist/contract-http/flow-helpers.d.ts.map +1 -0
- package/dist/contract-http/flow-helpers.js +34 -0
- package/dist/contract-http/flow-helpers.js.map +1 -0
- package/dist/contract-http/index.d.ts +15 -0
- package/dist/contract-http/index.d.ts.map +1 -0
- package/dist/contract-http/index.js +14 -0
- package/dist/contract-http/index.js.map +1 -0
- package/dist/contract-http/markdown.d.ts +10 -0
- package/dist/contract-http/markdown.d.ts.map +1 -0
- package/dist/contract-http/markdown.js +21 -0
- package/dist/contract-http/markdown.js.map +1 -0
- package/dist/contract-http/openapi.d.ts +15 -0
- package/dist/contract-http/openapi.d.ts.map +1 -0
- package/dist/contract-http/openapi.js +38 -0
- package/dist/contract-http/openapi.js.map +1 -0
- package/dist/contract-http/types.d.ts +252 -0
- package/dist/contract-http/types.d.ts.map +1 -0
- package/dist/contract-http/types.js +13 -0
- package/dist/contract-http/types.js.map +1 -0
- package/dist/contract-types.d.ts +420 -467
- package/dist/contract-types.d.ts.map +1 -1
- package/dist/contract-types.js +16 -4
- package/dist/contract-types.js.map +1 -1
- package/dist/index.d.ts +21 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +22 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/contract.d.ts +0 -64
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -793
- package/dist/contract.js.map +0 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP adapter — built-in implementation of ContractProtocolAdapter for HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Shipped inside @glubean/sdk (zero-config UX for the common case) but uses
|
|
5
|
+
* the same adapter interface as future plugin protocols (gRPC / GraphQL /
|
|
6
|
+
* Kafka). Registered at SDK load time from `../index.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - execute: run a case's request + expect + verify lifecycle
|
|
10
|
+
* - project: produce Runtime ContractProjection<HttpPayloadSchemas>
|
|
11
|
+
* - normalize: convert Runtime → ExtractedContractProjection<HttpSafeSchemas>
|
|
12
|
+
* - executeCaseInFlow: deep-merge lens resolvedInputs over case spec,
|
|
13
|
+
* run case with Rule 1 try/finally teardown
|
|
14
|
+
* - classifyFailure: map HTTP status → FailureKind
|
|
15
|
+
* - describePayload: summary for index views
|
|
16
|
+
* - toOpenApi / toMarkdown: delegate to ./openapi.ts / ./markdown.ts
|
|
17
|
+
*
|
|
18
|
+
* .case() fail-fast: rejects cases with function-valued input fields
|
|
19
|
+
* (body/params/query/headers) because function fields reference case-local
|
|
20
|
+
* setup state which is not available in flow mode.
|
|
21
|
+
*/
|
|
22
|
+
import { mergeSlot } from "./flow-helpers.js";
|
|
23
|
+
import { buildOpenApiForHttp } from "./openapi.js";
|
|
24
|
+
import { renderMarkdownForHttp } from "./markdown.js";
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Helpers — endpoint, params, request body, response headers
|
|
27
|
+
// =============================================================================
|
|
28
|
+
export function parseEndpoint(endpoint) {
|
|
29
|
+
const spaceIdx = endpoint.indexOf(" ");
|
|
30
|
+
if (spaceIdx === -1)
|
|
31
|
+
return { method: "GET", path: endpoint };
|
|
32
|
+
return {
|
|
33
|
+
method: endpoint.slice(0, spaceIdx).toUpperCase(),
|
|
34
|
+
path: endpoint.slice(spaceIdx + 1),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function extractParamValue(v) {
|
|
38
|
+
if (typeof v === "string")
|
|
39
|
+
return v;
|
|
40
|
+
if (v && typeof v === "object" && "value" in v) {
|
|
41
|
+
return String(v.value);
|
|
42
|
+
}
|
|
43
|
+
return String(v);
|
|
44
|
+
}
|
|
45
|
+
export function flattenParamValues(params) {
|
|
46
|
+
if (!params)
|
|
47
|
+
return undefined;
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const [key, value] of Object.entries(params)) {
|
|
50
|
+
result[key] = extractParamValue(value);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
function resolveParams(path, params) {
|
|
55
|
+
if (!params)
|
|
56
|
+
return path;
|
|
57
|
+
let resolved = path;
|
|
58
|
+
for (const [key, value] of Object.entries(params)) {
|
|
59
|
+
resolved = resolved.replace(`:${key}`, encodeURIComponent(value));
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
const STRUCTURED_REQUEST_FIELDS = [
|
|
64
|
+
"body",
|
|
65
|
+
"contentType",
|
|
66
|
+
"headers",
|
|
67
|
+
"example",
|
|
68
|
+
"examples",
|
|
69
|
+
];
|
|
70
|
+
export function normalizeRequest(req) {
|
|
71
|
+
if (!req || typeof req !== "object")
|
|
72
|
+
return undefined;
|
|
73
|
+
const hasStructuredField = STRUCTURED_REQUEST_FIELDS.some((f) => f in req);
|
|
74
|
+
if (hasStructuredField)
|
|
75
|
+
return req;
|
|
76
|
+
return { body: req };
|
|
77
|
+
}
|
|
78
|
+
function buildRequestBodyOptions(body, contentType) {
|
|
79
|
+
if (body === undefined)
|
|
80
|
+
return {};
|
|
81
|
+
const ct = (contentType ?? "application/json").toLowerCase();
|
|
82
|
+
if (ct.startsWith("application/json")) {
|
|
83
|
+
return { json: body };
|
|
84
|
+
}
|
|
85
|
+
if (ct.startsWith("multipart/form-data")) {
|
|
86
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
87
|
+
return { body };
|
|
88
|
+
}
|
|
89
|
+
if (body && typeof body === "object") {
|
|
90
|
+
const fd = new FormData();
|
|
91
|
+
for (const [k, v] of Object.entries(body)) {
|
|
92
|
+
if (v instanceof Blob || v instanceof File)
|
|
93
|
+
fd.append(k, v);
|
|
94
|
+
else
|
|
95
|
+
fd.append(k, String(v));
|
|
96
|
+
}
|
|
97
|
+
return { body: fd };
|
|
98
|
+
}
|
|
99
|
+
return { body };
|
|
100
|
+
}
|
|
101
|
+
if (ct.startsWith("application/x-www-form-urlencoded")) {
|
|
102
|
+
if (body instanceof URLSearchParams)
|
|
103
|
+
return { body };
|
|
104
|
+
if (body && typeof body === "object") {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
for (const [k, v] of Object.entries(body)) {
|
|
107
|
+
params.append(k, String(v));
|
|
108
|
+
}
|
|
109
|
+
return { body: params };
|
|
110
|
+
}
|
|
111
|
+
return { body };
|
|
112
|
+
}
|
|
113
|
+
return { body };
|
|
114
|
+
}
|
|
115
|
+
function normalizeResponseHeaders(headers) {
|
|
116
|
+
const result = {};
|
|
117
|
+
if (!headers)
|
|
118
|
+
return result;
|
|
119
|
+
if (typeof headers.forEach === "function" &&
|
|
120
|
+
typeof headers.get === "function") {
|
|
121
|
+
headers.forEach((value, key) => {
|
|
122
|
+
const lowerKey = key.toLowerCase();
|
|
123
|
+
if (lowerKey === "set-cookie") {
|
|
124
|
+
const existing = result[lowerKey];
|
|
125
|
+
const newValue = typeof existing === "string"
|
|
126
|
+
? [existing, value]
|
|
127
|
+
: [...(existing ?? []), value];
|
|
128
|
+
result[lowerKey] = newValue;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
result[lowerKey] = value;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
if (typeof headers === "object") {
|
|
137
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
138
|
+
const lowerKey = key.toLowerCase();
|
|
139
|
+
if (Array.isArray(value))
|
|
140
|
+
result[lowerKey] = value.map(String);
|
|
141
|
+
else if (value != null)
|
|
142
|
+
result[lowerKey] = String(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Schema → JSON Schema (normalize helper)
|
|
149
|
+
// =============================================================================
|
|
150
|
+
/**
|
|
151
|
+
* Try to convert a SchemaLike (Zod v4, Valibot, etc.) to JSON Schema.
|
|
152
|
+
* Uses the schema's own `toJSONSchema` method if present. Falls back to
|
|
153
|
+
* passing through as-is if already plain or unrecognized.
|
|
154
|
+
*/
|
|
155
|
+
export function schemaToJsonSchema(schema) {
|
|
156
|
+
if (schema == null)
|
|
157
|
+
return null;
|
|
158
|
+
if (typeof schema !== "object")
|
|
159
|
+
return schema;
|
|
160
|
+
// Already plain JSON Schema-ish
|
|
161
|
+
if ("type" in schema || "$ref" in schema) {
|
|
162
|
+
return schema;
|
|
163
|
+
}
|
|
164
|
+
// Zod v4 instance method
|
|
165
|
+
const toJSONSchema = schema.toJSONSchema;
|
|
166
|
+
if (typeof toJSONSchema === "function") {
|
|
167
|
+
try {
|
|
168
|
+
return toJSONSchema.call(schema);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
// =============================================================================
|
|
177
|
+
// Extract per-param metadata from case spec (for OpenAPI)
|
|
178
|
+
// =============================================================================
|
|
179
|
+
function extractParamMetaSchemas(params) {
|
|
180
|
+
if (!params || typeof params === "function")
|
|
181
|
+
return undefined;
|
|
182
|
+
const result = {};
|
|
183
|
+
let hasAny = false;
|
|
184
|
+
for (const [key, val] of Object.entries(params)) {
|
|
185
|
+
if (val && typeof val === "object" && !Array.isArray(val) && "value" in val) {
|
|
186
|
+
const pv = val;
|
|
187
|
+
if (pv.schema !== undefined ||
|
|
188
|
+
pv.description !== undefined ||
|
|
189
|
+
pv.required !== undefined ||
|
|
190
|
+
pv.deprecated !== undefined) {
|
|
191
|
+
result[key] = {
|
|
192
|
+
schema: pv.schema,
|
|
193
|
+
description: pv.description,
|
|
194
|
+
required: pv.required,
|
|
195
|
+
deprecated: pv.deprecated,
|
|
196
|
+
};
|
|
197
|
+
hasAny = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return hasAny ? result : undefined;
|
|
202
|
+
}
|
|
203
|
+
// =============================================================================
|
|
204
|
+
// Case execution (standalone mode)
|
|
205
|
+
// =============================================================================
|
|
206
|
+
/**
|
|
207
|
+
* Run a single case. Called by the dispatcher in contract-core.ts (via
|
|
208
|
+
* adapter.execute). Full lifecycle: setup → request → assert → verify →
|
|
209
|
+
* teardown (finally).
|
|
210
|
+
*/
|
|
211
|
+
async function executeCase(ctx, caseSpec, spec) {
|
|
212
|
+
const { method, path } = parseEndpoint(spec.endpoint);
|
|
213
|
+
// Setup
|
|
214
|
+
const state = caseSpec.setup
|
|
215
|
+
? await caseSpec.setup(ctx)
|
|
216
|
+
: undefined;
|
|
217
|
+
try {
|
|
218
|
+
const client = (caseSpec.client ?? spec.client);
|
|
219
|
+
if (!client) {
|
|
220
|
+
throw new Error(`No HTTP client provided for case. Set "client" on the case or contract spec.`);
|
|
221
|
+
}
|
|
222
|
+
// Resolve params/query/body/headers
|
|
223
|
+
const rawParams = typeof caseSpec.params === "function"
|
|
224
|
+
? caseSpec.params(state)
|
|
225
|
+
: caseSpec.params;
|
|
226
|
+
const params = flattenParamValues(rawParams);
|
|
227
|
+
const resolvedPath = resolveParams(path, params);
|
|
228
|
+
const requestOptions = {};
|
|
229
|
+
const body = typeof caseSpec.body === "function"
|
|
230
|
+
? caseSpec.body(state)
|
|
231
|
+
: caseSpec.body;
|
|
232
|
+
const normalizedReq = normalizeRequest(spec.request);
|
|
233
|
+
const effectiveContentType = caseSpec.contentType ?? normalizedReq?.contentType ?? "application/json";
|
|
234
|
+
if (body !== undefined) {
|
|
235
|
+
Object.assign(requestOptions, buildRequestBodyOptions(body, effectiveContentType));
|
|
236
|
+
}
|
|
237
|
+
const headers = typeof caseSpec.headers === "function"
|
|
238
|
+
? caseSpec.headers(state)
|
|
239
|
+
: caseSpec.headers;
|
|
240
|
+
if (headers)
|
|
241
|
+
requestOptions.headers = headers;
|
|
242
|
+
if (caseSpec.query) {
|
|
243
|
+
const rawQuery = typeof caseSpec.query === "function"
|
|
244
|
+
? caseSpec.query(state)
|
|
245
|
+
: caseSpec.query;
|
|
246
|
+
requestOptions.searchParams = flattenParamValues(rawQuery);
|
|
247
|
+
}
|
|
248
|
+
requestOptions.throwHttpErrors = false;
|
|
249
|
+
const methodLower = method.toLowerCase();
|
|
250
|
+
let res;
|
|
251
|
+
try {
|
|
252
|
+
res = await client[methodLower](resolvedPath, requestOptions);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
256
|
+
const timeoutMs = client._configuredTimeout ?? 10000;
|
|
257
|
+
throw new Error(`${err.message} (timeout: ${timeoutMs}ms)`);
|
|
258
|
+
}
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
// Assertions
|
|
262
|
+
ctx.expect(res).toHaveStatus(caseSpec.expect.status);
|
|
263
|
+
if (caseSpec.expect.headers) {
|
|
264
|
+
const normalizedHeaders = normalizeResponseHeaders(res.headers);
|
|
265
|
+
ctx.validate(normalizedHeaders, caseSpec.expect.headers, `response headers`);
|
|
266
|
+
}
|
|
267
|
+
let parsed;
|
|
268
|
+
if (caseSpec.expect.schema) {
|
|
269
|
+
const jsonBody = await res.json();
|
|
270
|
+
const validated = ctx.validate(jsonBody, caseSpec.expect.schema, `response body`);
|
|
271
|
+
parsed = (validated !== undefined ? validated : jsonBody);
|
|
272
|
+
}
|
|
273
|
+
else if (caseSpec.verify) {
|
|
274
|
+
parsed = (await res.json());
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
parsed = undefined;
|
|
278
|
+
}
|
|
279
|
+
if (caseSpec.verify) {
|
|
280
|
+
await caseSpec.verify(ctx, parsed);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
if (caseSpec.teardown) {
|
|
285
|
+
await caseSpec.teardown(ctx, state);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// =============================================================================
|
|
290
|
+
// project(): HttpContractSpec → ContractProjection<HttpPayloadSchemas>
|
|
291
|
+
// =============================================================================
|
|
292
|
+
function projectHttp(spec) {
|
|
293
|
+
const { method, path } = parseEndpoint(spec.endpoint);
|
|
294
|
+
const normalizedReq = normalizeRequest(spec.request);
|
|
295
|
+
return {
|
|
296
|
+
protocol: "http",
|
|
297
|
+
target: spec.endpoint,
|
|
298
|
+
description: spec.description,
|
|
299
|
+
feature: spec.feature,
|
|
300
|
+
tags: spec.tags,
|
|
301
|
+
extensions: spec.extensions,
|
|
302
|
+
deprecated: spec.deprecated,
|
|
303
|
+
meta: { method, path },
|
|
304
|
+
schemas: {
|
|
305
|
+
request: normalizedReq
|
|
306
|
+
? {
|
|
307
|
+
body: normalizedReq.body,
|
|
308
|
+
contentType: normalizedReq.contentType,
|
|
309
|
+
headers: normalizedReq.headers,
|
|
310
|
+
example: normalizedReq.example,
|
|
311
|
+
examples: normalizedReq.examples,
|
|
312
|
+
}
|
|
313
|
+
: undefined,
|
|
314
|
+
},
|
|
315
|
+
cases: Object.entries(spec.cases).map(([key, c]) => {
|
|
316
|
+
const effectiveDeprecated = c.deprecated ?? spec.deprecated;
|
|
317
|
+
const lifecycle = effectiveDeprecated
|
|
318
|
+
? "deprecated"
|
|
319
|
+
: c.deferred
|
|
320
|
+
? "deferred"
|
|
321
|
+
: "active";
|
|
322
|
+
const paramSchemas = extractParamMetaSchemas(c.params);
|
|
323
|
+
const querySchemas = extractParamMetaSchemas(c.query);
|
|
324
|
+
return {
|
|
325
|
+
key,
|
|
326
|
+
description: c.description,
|
|
327
|
+
lifecycle,
|
|
328
|
+
severity: c.severity ?? "warning",
|
|
329
|
+
deferredReason: c.deferred,
|
|
330
|
+
deprecatedReason: effectiveDeprecated,
|
|
331
|
+
requires: c.requires,
|
|
332
|
+
defaultRun: c.defaultRun,
|
|
333
|
+
tags: c.tags,
|
|
334
|
+
extensions: c.extensions,
|
|
335
|
+
schemas: {
|
|
336
|
+
request: undefined,
|
|
337
|
+
response: {
|
|
338
|
+
status: c.expect.status,
|
|
339
|
+
body: c.expect.schema,
|
|
340
|
+
contentType: c.expect.contentType,
|
|
341
|
+
headers: c.expect.headers,
|
|
342
|
+
example: c.expect.example,
|
|
343
|
+
examples: c.expect.examples,
|
|
344
|
+
},
|
|
345
|
+
params: paramSchemas,
|
|
346
|
+
query: querySchemas,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// =============================================================================
|
|
353
|
+
// normalize(): Runtime → Safe (Zod → JSON Schema)
|
|
354
|
+
// =============================================================================
|
|
355
|
+
function normalizeHttp(projection) {
|
|
356
|
+
const safeContractSchemas = projection.schemas
|
|
357
|
+
? {
|
|
358
|
+
request: projection.schemas.request
|
|
359
|
+
? {
|
|
360
|
+
body: schemaToJsonSchema(projection.schemas.request.body) ?? undefined,
|
|
361
|
+
contentType: projection.schemas.request.contentType,
|
|
362
|
+
headers: schemaToJsonSchema(projection.schemas.request.headers) ?? undefined,
|
|
363
|
+
example: projection.schemas.request.example,
|
|
364
|
+
examples: projection.schemas.request.examples,
|
|
365
|
+
}
|
|
366
|
+
: undefined,
|
|
367
|
+
// Contract-level security (set by the scoped factory via
|
|
368
|
+
// `contract.http.with("name", { security })`). Must survive
|
|
369
|
+
// normalize so downstream tools (toOpenApi, Cloud views) see it.
|
|
370
|
+
security: projection.schemas.security,
|
|
371
|
+
}
|
|
372
|
+
: undefined;
|
|
373
|
+
return {
|
|
374
|
+
id: projection.id,
|
|
375
|
+
protocol: projection.protocol,
|
|
376
|
+
target: projection.target,
|
|
377
|
+
description: projection.description,
|
|
378
|
+
feature: projection.feature,
|
|
379
|
+
instanceName: projection.instanceName,
|
|
380
|
+
tags: projection.tags,
|
|
381
|
+
extensions: projection.extensions,
|
|
382
|
+
deprecated: projection.deprecated,
|
|
383
|
+
schemas: safeContractSchemas,
|
|
384
|
+
meta: projection.meta,
|
|
385
|
+
cases: projection.cases.map((c) => ({
|
|
386
|
+
key: c.key,
|
|
387
|
+
description: c.description,
|
|
388
|
+
lifecycle: c.lifecycle,
|
|
389
|
+
severity: c.severity,
|
|
390
|
+
deferredReason: c.deferredReason,
|
|
391
|
+
deprecatedReason: c.deprecatedReason,
|
|
392
|
+
requires: c.requires,
|
|
393
|
+
defaultRun: c.defaultRun,
|
|
394
|
+
tags: c.tags,
|
|
395
|
+
extensions: c.extensions,
|
|
396
|
+
meta: c.meta,
|
|
397
|
+
schemas: c.schemas
|
|
398
|
+
? {
|
|
399
|
+
response: c.schemas.response
|
|
400
|
+
? {
|
|
401
|
+
status: c.schemas.response.status,
|
|
402
|
+
body: schemaToJsonSchema(c.schemas.response.body) ?? undefined,
|
|
403
|
+
contentType: c.schemas.response.contentType,
|
|
404
|
+
headers: schemaToJsonSchema(c.schemas.response.headers) ?? undefined,
|
|
405
|
+
example: c.schemas.response.example,
|
|
406
|
+
examples: c.schemas.response.examples,
|
|
407
|
+
}
|
|
408
|
+
: undefined,
|
|
409
|
+
params: c.schemas.params
|
|
410
|
+
? normalizeParamMetaRecord(c.schemas.params)
|
|
411
|
+
: undefined,
|
|
412
|
+
query: c.schemas.query
|
|
413
|
+
? normalizeParamMetaRecord(c.schemas.query)
|
|
414
|
+
: undefined,
|
|
415
|
+
security: c.schemas.security,
|
|
416
|
+
}
|
|
417
|
+
: undefined,
|
|
418
|
+
})),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function normalizeParamMetaRecord(raw) {
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const [key, meta] of Object.entries(raw)) {
|
|
424
|
+
out[key] = {
|
|
425
|
+
schema: schemaToJsonSchema(meta.schema) ?? undefined,
|
|
426
|
+
description: meta.description,
|
|
427
|
+
required: meta.required,
|
|
428
|
+
deprecated: meta.deprecated,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
// =============================================================================
|
|
434
|
+
// executeCaseInFlow(): deep-merge lens inputs + Rule 1 teardown
|
|
435
|
+
// =============================================================================
|
|
436
|
+
async function executeCaseInFlowHttp(input) {
|
|
437
|
+
const { ctx, contract, caseKey, resolvedInputs } = input;
|
|
438
|
+
const spec = contract._spec;
|
|
439
|
+
const caseSpec = spec.cases[caseKey];
|
|
440
|
+
if (!caseSpec) {
|
|
441
|
+
throw new Error(`case "${caseKey}" not in contract "${contract._projection.id}"`);
|
|
442
|
+
}
|
|
443
|
+
// Note: function-valued fields are rejected at .case() time (§5.1.1).
|
|
444
|
+
// In flow mode we assume all input slots are static (possibly undefined).
|
|
445
|
+
// Compute effective body/params/query/headers via deep-merge
|
|
446
|
+
const patch = (resolvedInputs ?? {});
|
|
447
|
+
const effectiveBody = mergeSlot(caseSpec.body, patch.body);
|
|
448
|
+
const effectiveParams = mergeSlot(caseSpec.params, patch.params);
|
|
449
|
+
const effectiveQuery = mergeSlot(caseSpec.query, patch.query);
|
|
450
|
+
const effectiveHeaders = mergeSlot(caseSpec.headers, patch.headers);
|
|
451
|
+
// Rule 1: case setup throw → teardown does NOT run. Track whether setup
|
|
452
|
+
// SUCCEEDED via a separate flag, not via state-is-undefined — a setup may
|
|
453
|
+
// legitimately return `undefined` and we still owe it a teardown call.
|
|
454
|
+
let setupRan = false;
|
|
455
|
+
let caseState = undefined;
|
|
456
|
+
if (caseSpec.setup) {
|
|
457
|
+
caseState = await caseSpec.setup(ctx);
|
|
458
|
+
setupRan = true;
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const { method, path } = parseEndpoint(spec.endpoint);
|
|
462
|
+
const client = (caseSpec.client ?? spec.client);
|
|
463
|
+
if (!client) {
|
|
464
|
+
throw new Error(`No HTTP client provided for case "${caseKey}" in contract "${contract._projection.id}". ` +
|
|
465
|
+
`Set "client" on the case or contract spec.`);
|
|
466
|
+
}
|
|
467
|
+
const resolvedPath = resolveParams(path, flattenParamValues(effectiveParams));
|
|
468
|
+
const normalizedReq = normalizeRequest(spec.request);
|
|
469
|
+
const effectiveContentType = caseSpec.contentType ?? normalizedReq?.contentType ?? "application/json";
|
|
470
|
+
const requestOptions = { throwHttpErrors: false };
|
|
471
|
+
if (effectiveBody !== undefined) {
|
|
472
|
+
Object.assign(requestOptions, buildRequestBodyOptions(effectiveBody, effectiveContentType));
|
|
473
|
+
}
|
|
474
|
+
if (effectiveHeaders)
|
|
475
|
+
requestOptions.headers = effectiveHeaders;
|
|
476
|
+
if (effectiveQuery) {
|
|
477
|
+
requestOptions.searchParams = flattenParamValues(effectiveQuery);
|
|
478
|
+
}
|
|
479
|
+
const methodLower = method.toLowerCase();
|
|
480
|
+
let res;
|
|
481
|
+
try {
|
|
482
|
+
res = await client[methodLower](resolvedPath, requestOptions);
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
486
|
+
const timeoutMs = client._configuredTimeout ?? 10000;
|
|
487
|
+
throw new Error(`${err.message} (timeout: ${timeoutMs}ms)`);
|
|
488
|
+
}
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
ctx.expect(res).toHaveStatus(caseSpec.expect.status);
|
|
492
|
+
const responseHeaders = normalizeResponseHeaders(res.headers);
|
|
493
|
+
if (caseSpec.expect.headers) {
|
|
494
|
+
ctx.validate(responseHeaders, caseSpec.expect.headers, `response headers`);
|
|
495
|
+
}
|
|
496
|
+
let body;
|
|
497
|
+
if (caseSpec.expect.schema) {
|
|
498
|
+
const jsonBody = await res.json();
|
|
499
|
+
const validated = ctx.validate(jsonBody, caseSpec.expect.schema, `response body`);
|
|
500
|
+
body = validated !== undefined ? validated : jsonBody;
|
|
501
|
+
}
|
|
502
|
+
else if (caseSpec.verify) {
|
|
503
|
+
body = await res.json();
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
// Try to pull body anyway for downstream lens
|
|
507
|
+
try {
|
|
508
|
+
body = await res.json();
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
body = undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (caseSpec.verify) {
|
|
515
|
+
await caseSpec.verify(ctx, body);
|
|
516
|
+
}
|
|
517
|
+
return { status: res.status, headers: responseHeaders, body };
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
// Rule 1: case.teardown runs in finally whenever setup succeeded,
|
|
521
|
+
// regardless of downstream outcome AND regardless of the state value
|
|
522
|
+
// returned by setup (undefined is a valid return). Teardown errors are
|
|
523
|
+
// logged but MUST NOT mask the primary exception.
|
|
524
|
+
if (caseSpec.teardown && setupRan) {
|
|
525
|
+
try {
|
|
526
|
+
await caseSpec.teardown(ctx, caseState);
|
|
527
|
+
}
|
|
528
|
+
catch (tdErr) {
|
|
529
|
+
ctx.log?.(`case.teardown("${caseKey}") failed: ${String(tdErr)}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// =============================================================================
|
|
535
|
+
// classifyFailure(): HTTP status → FailureKind
|
|
536
|
+
// =============================================================================
|
|
537
|
+
function classifyHttpFailure(input) {
|
|
538
|
+
// Scan recent HTTP trace events for the response status
|
|
539
|
+
for (let i = input.events.length - 1; i >= 0; i--) {
|
|
540
|
+
const ev = input.events[i];
|
|
541
|
+
if (ev.type === "http:response" || ev.type === "http:trace") {
|
|
542
|
+
const status = ev.data.status;
|
|
543
|
+
if (typeof status === "number") {
|
|
544
|
+
return statusToClassification(status);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Fallback: inspect error
|
|
549
|
+
if (input.error instanceof Error) {
|
|
550
|
+
if (input.error.name === "TimeoutError") {
|
|
551
|
+
return { kind: "timeout", source: "trace", retryable: true };
|
|
552
|
+
}
|
|
553
|
+
if (/ECONNREFUSED|ENOTFOUND|fetch failed/.test(input.error.message)) {
|
|
554
|
+
return { kind: "transport", source: "trace", retryable: true };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
function statusToClassification(status) {
|
|
560
|
+
if (status === 401)
|
|
561
|
+
return { kind: "auth", source: "trace" };
|
|
562
|
+
if (status === 403)
|
|
563
|
+
return { kind: "permission", source: "trace" };
|
|
564
|
+
if (status === 404)
|
|
565
|
+
return { kind: "not-found", source: "trace" };
|
|
566
|
+
if (status === 429)
|
|
567
|
+
return { kind: "rate-limit", source: "trace", retryable: true };
|
|
568
|
+
if (status >= 500)
|
|
569
|
+
return { kind: "transport", source: "trace", retryable: true };
|
|
570
|
+
return { kind: "business-rule", source: "trace" };
|
|
571
|
+
}
|
|
572
|
+
// =============================================================================
|
|
573
|
+
// describePayload(): payload overview from HttpSafeSchemas
|
|
574
|
+
// =============================================================================
|
|
575
|
+
function describeHttpPayload(schemas) {
|
|
576
|
+
if (!schemas)
|
|
577
|
+
return undefined;
|
|
578
|
+
return {
|
|
579
|
+
hasRequest: !!schemas.request?.body,
|
|
580
|
+
hasResponse: !!schemas.response?.body,
|
|
581
|
+
responseStatus: schemas.response?.status,
|
|
582
|
+
responseContentType: schemas.response?.contentType,
|
|
583
|
+
requestContentType: schemas.request?.contentType,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// =============================================================================
|
|
587
|
+
// The HTTP adapter
|
|
588
|
+
// =============================================================================
|
|
589
|
+
export const httpAdapter = {
|
|
590
|
+
async execute(ctx, caseSpec, spec) {
|
|
591
|
+
await executeCase(ctx, caseSpec, spec);
|
|
592
|
+
},
|
|
593
|
+
project(spec) {
|
|
594
|
+
return projectHttp(spec);
|
|
595
|
+
},
|
|
596
|
+
normalize(projection) {
|
|
597
|
+
return normalizeHttp(projection);
|
|
598
|
+
},
|
|
599
|
+
executeCaseInFlow(input) {
|
|
600
|
+
return executeCaseInFlowHttp(input);
|
|
601
|
+
},
|
|
602
|
+
classifyFailure(input) {
|
|
603
|
+
return classifyHttpFailure(input);
|
|
604
|
+
},
|
|
605
|
+
describePayload(schemas) {
|
|
606
|
+
return describeHttpPayload(schemas);
|
|
607
|
+
},
|
|
608
|
+
toOpenApi(projection) {
|
|
609
|
+
return buildOpenApiForHttp(projection);
|
|
610
|
+
},
|
|
611
|
+
toMarkdown(projection) {
|
|
612
|
+
return renderMarkdownForHttp(projection);
|
|
613
|
+
},
|
|
614
|
+
renderTarget(target) {
|
|
615
|
+
return target; // HTTP "POST /users" is already human-readable
|
|
616
|
+
},
|
|
617
|
+
validateCaseForFlow(spec, caseKey, contractId) {
|
|
618
|
+
const caseSpec = spec.cases[caseKey];
|
|
619
|
+
if (!caseSpec) {
|
|
620
|
+
throw new Error(`Case "${caseKey}" not in contract "${contractId}"`);
|
|
621
|
+
}
|
|
622
|
+
validateHttpCaseForFlow(contractId, caseKey, caseSpec);
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
// =============================================================================
|
|
626
|
+
// .case(key) fail-fast for function-valued input fields
|
|
627
|
+
// =============================================================================
|
|
628
|
+
/**
|
|
629
|
+
* Validate that an HTTP case can be referenced from a flow. Called from
|
|
630
|
+
* ProtocolContract.case() — throws if the case has function-valued inputs.
|
|
631
|
+
* See contract-flow v9 §5.1.1.
|
|
632
|
+
*/
|
|
633
|
+
export function validateHttpCaseForFlow(contractId, caseKey, caseSpec) {
|
|
634
|
+
const functionFields = [];
|
|
635
|
+
if (typeof caseSpec.body === "function")
|
|
636
|
+
functionFields.push("body");
|
|
637
|
+
if (typeof caseSpec.params === "function")
|
|
638
|
+
functionFields.push("params");
|
|
639
|
+
if (typeof caseSpec.query === "function")
|
|
640
|
+
functionFields.push("query");
|
|
641
|
+
if (typeof caseSpec.headers === "function")
|
|
642
|
+
functionFields.push("headers");
|
|
643
|
+
if (functionFields.length > 0) {
|
|
644
|
+
throw new Error(`Contract "${contractId}" case "${caseKey}" has function-valued field(s): ` +
|
|
645
|
+
`${functionFields.join(", ")}. Function fields reference case-local setup state, ` +
|
|
646
|
+
`which is not available in flow mode. ` +
|
|
647
|
+
`Fix: split into a new case with static values, or convert the field to a static value.`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=adapter.js.map
|