@usearazzo/runner 1.0.1-alpha.11 → 1.0.1-alpha.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usearazzo/runner",
3
- "version": "1.0.1-alpha.11",
3
+ "version": "1.0.1-alpha.13",
4
4
  "description": "Runner for Arazzo Workflows.",
5
5
  "keywords": [
6
6
  "arazzo",
@@ -69,11 +69,12 @@
69
69
  "@speclynx/apidom-traverse": "^6.1.0",
70
70
  "@swaggerexpert/arazzo-criterion": "^1.0.1",
71
71
  "@swaggerexpert/arazzo-runtime-expression": "^3.3.0",
72
+ "@swaggerexpert/cookie": "^2.0.2",
72
73
  "@swaggerexpert/json-pointer": "^3.0.2",
73
74
  "@swaggerexpert/jsonpath": "^4.0.5",
74
75
  "@swaggerexpert/media-types": "^1.0.1",
75
- "@usearazzo/parser": "1.0.1-alpha.11",
76
- "@usearazzo/resolver": "1.0.1-alpha.11",
76
+ "@usearazzo/parser": "1.0.1-alpha.13",
77
+ "@usearazzo/resolver": "1.0.1-alpha.13",
77
78
  "@xmldom/xmldom": "^0.9.12",
78
79
  "fontoxpath": "^3.34.0",
79
80
  "ramda": "^0.32.0",
@@ -93,5 +94,5 @@
93
94
  "devDependencies": {
94
95
  "swagger-client": "^3.38.4"
95
96
  },
96
- "gitHead": "3cfffaf0045eff162d73df30f840c35cc593af77"
97
+ "gitHead": "41c018418a867e1469de0b2393929f7ec129c447"
97
98
  }
@@ -17,6 +17,14 @@ exports.default = void 0;
17
17
 
18
18
  /**
19
19
  * Response from an OpenAPI operation execution.
20
+ *
21
+ * `body` is the payload parsed by its content type, what `$response.body`
22
+ * reads. The body as received is read through `text()`, `blob()` and
23
+ * `bytes()`, named after the WHATWG `Response` methods but synchronous, and
24
+ * empty when the response carried no body (e.g. `204`, `304`). `bytes()` and
25
+ * `blob()` always return the bytes received. A body that is valid UTF-8 is held
26
+ * as its decoded string, which a plain-text or XML `body` shares rather than
27
+ * duplicates, and is encoded back on read; any other body is held as bytes.
20
28
  * @public
21
29
  */
22
30
  class OpenAPIOperationResponse {
@@ -25,18 +33,45 @@ class OpenAPIOperationResponse {
25
33
  status;
26
34
  statusText;
27
35
  headers;
28
- text;
29
36
  body;
30
37
  request;
38
+ #content;
31
39
  constructor(raw) {
32
40
  this.ok = raw.ok;
33
41
  this.url = raw.url;
34
42
  this.status = raw.status;
35
43
  this.statusText = raw.statusText;
36
44
  this.headers = raw.headers;
37
- this.text = raw.text;
38
45
  this.body = raw.body;
46
+ this.#content = raw.content;
39
47
  this.request = raw.request;
40
48
  }
49
+
50
+ /**
51
+ * The body as text, like `Response.text()`; `''` when there is none.
52
+ */
53
+ text() {
54
+ return typeof this.#content === 'string' ? this.#content : new TextDecoder().decode(this.#content);
55
+ }
56
+
57
+ /**
58
+ * The body as a `Blob` typed by the response's `Content-Type`, like
59
+ * `Response.blob()`.
60
+ */
61
+ blob() {
62
+ // the lib types only take ArrayBuffer-backed views; the normalizer never
63
+ // produces a shared one.
64
+ return new Blob([this.#content], {
65
+ type: this.headers['content-type'] ?? ''
66
+ });
67
+ }
68
+
69
+ /**
70
+ * The body as bytes, like `Response.bytes()`; a fresh copy on every call, so
71
+ * the response stays immutable.
72
+ */
73
+ bytes() {
74
+ return typeof this.#content === 'string' ? new TextEncoder().encode(this.#content) : this.#content.slice();
75
+ }
41
76
  }
42
77
  var _default = exports.default = OpenAPIOperationResponse;
@@ -11,6 +11,14 @@
11
11
 
12
12
  /**
13
13
  * Response from an OpenAPI operation execution.
14
+ *
15
+ * `body` is the payload parsed by its content type, what `$response.body`
16
+ * reads. The body as received is read through `text()`, `blob()` and
17
+ * `bytes()`, named after the WHATWG `Response` methods but synchronous, and
18
+ * empty when the response carried no body (e.g. `204`, `304`). `bytes()` and
19
+ * `blob()` always return the bytes received. A body that is valid UTF-8 is held
20
+ * as its decoded string, which a plain-text or XML `body` shares rather than
21
+ * duplicates, and is encoded back on read; any other body is held as bytes.
14
22
  * @public
15
23
  */
16
24
  class OpenAPIOperationResponse {
@@ -19,18 +27,45 @@ class OpenAPIOperationResponse {
19
27
  status;
20
28
  statusText;
21
29
  headers;
22
- text;
23
30
  body;
24
31
  request;
32
+ #content;
25
33
  constructor(raw) {
26
34
  this.ok = raw.ok;
27
35
  this.url = raw.url;
28
36
  this.status = raw.status;
29
37
  this.statusText = raw.statusText;
30
38
  this.headers = raw.headers;
31
- this.text = raw.text;
32
39
  this.body = raw.body;
40
+ this.#content = raw.content;
33
41
  this.request = raw.request;
34
42
  }
43
+
44
+ /**
45
+ * The body as text, like `Response.text()`; `''` when there is none.
46
+ */
47
+ text() {
48
+ return typeof this.#content === 'string' ? this.#content : new TextDecoder().decode(this.#content);
49
+ }
50
+
51
+ /**
52
+ * The body as a `Blob` typed by the response's `Content-Type`, like
53
+ * `Response.blob()`.
54
+ */
55
+ blob() {
56
+ // the lib types only take ArrayBuffer-backed views; the normalizer never
57
+ // produces a shared one.
58
+ return new Blob([this.#content], {
59
+ type: this.headers['content-type'] ?? ''
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The body as bytes, like `Response.bytes()`; a fresh copy on every call, so
65
+ * the response stays immutable.
66
+ */
67
+ bytes() {
68
+ return typeof this.#content === 'string' ? new TextEncoder().encode(this.#content) : this.#content.slice();
69
+ }
35
70
  }
36
71
  export default OpenAPIOperationResponse;
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  value: true
6
6
  });
7
7
  exports.default = void 0;
8
+ var _mediaTypes = require("@swaggerexpert/media-types");
8
9
  var _swaggerClient = require("../vendor/swagger-client.cjs");
9
10
  var _OpenAPIOperationResponse = _interopRequireDefault(require("./OpenAPIOperationResponse.cjs"));
10
11
  // @ts-expect-error vendored swagger-client bundle has no type declarations
@@ -20,7 +21,11 @@ var _OpenAPIOperationResponse = _interopRequireDefault(require("./OpenAPIOperati
20
21
  * Headers are taken from the `Response` directly rather than from the
21
22
  * deserializer, which splits every value containing `", "` into an array:
22
23
  * values stay strings as sent, with repeated fields (`Set-Cookie` included)
23
- * combined by `Headers.get`.
24
+ * combined by `Headers.get`. The body is read once, as bytes, and handed to the
25
+ * deserializer from memory. A body that decodes as UTF-8 without loss is kept
26
+ * as that one string, the same one the deserializer parses, so a plain-text or
27
+ * XML `body` shares it; any other body (binary, another charset, a leading
28
+ * byte-order mark) is kept as the bytes, so they are never re-encoded lossily.
24
29
  * @public
25
30
  */
26
31
  class OpenAPIOperationResponseNormalizer {
@@ -31,7 +36,30 @@ class OpenAPIOperationResponseNormalizer {
31
36
  * carries none (e.g. a stubbed transport).
32
37
  */
33
38
  async normalize(response, request) {
34
- const deserialized = await (0, _swaggerClient.deserializeResponse)(response, request?.url);
39
+ const bytes = new Uint8Array(await response.arrayBuffer());
40
+ const text = this.#decodeLosslessly(bytes);
41
+ // the deserializer matches the raw Content-Type case-sensitively and against
42
+ // its parameters too, so it is handed only the parsed essence; the response's
43
+ // own `headers` below stay as sent.
44
+ const deserializerHeaders = new Headers(response.headers);
45
+ const contentType = response.headers.get('content-type');
46
+ const mediaType = contentType === null ? undefined : (0, _mediaTypes.parse)(contentType, {
47
+ type: 'media-type'
48
+ }).tree;
49
+ if (mediaType !== undefined) {
50
+ deserializerHeaders.set('content-type', `${mediaType.type}/${mediaType.subtype}`);
51
+ }
52
+ // the deserializer only reads the body through text() or blob(), by content
53
+ // type; answering from memory spares a second copy of the stream.
54
+ const deserialized = await (0, _swaggerClient.deserializeResponse)({
55
+ ok: response.ok,
56
+ url: response.url,
57
+ status: response.status,
58
+ statusText: response.statusText,
59
+ headers: deserializerHeaders,
60
+ text: async () => text ?? new TextDecoder().decode(bytes),
61
+ blob: async () => new Blob([bytes])
62
+ }, request?.url);
35
63
  const headers = Object.fromEntries(Array.from(response.headers.keys(), name => [name, response.headers.get(name)]));
36
64
  return new _OpenAPIOperationResponse.default({
37
65
  ok: deserialized.ok,
@@ -39,10 +67,26 @@ class OpenAPIOperationResponseNormalizer {
39
67
  status: deserialized.status,
40
68
  statusText: deserialized.statusText,
41
69
  headers,
42
- text: deserialized.text,
43
70
  body: deserialized.body,
71
+ content: text ?? bytes,
44
72
  request
45
73
  });
46
74
  }
75
+
76
+ /**
77
+ * The bytes as a string that encodes back to exactly them, or `undefined`
78
+ * when there is none: invalid UTF-8, or a leading byte-order mark, which
79
+ * decoding strips.
80
+ */
81
+ #decodeLosslessly(bytes) {
82
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return undefined;
83
+ try {
84
+ return new TextDecoder('utf-8', {
85
+ fatal: true
86
+ }).decode(bytes);
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
47
91
  }
48
92
  var _default = exports.default = OpenAPIOperationResponseNormalizer;
@@ -1,3 +1,5 @@
1
+ import { parse } from '@swaggerexpert/media-types';
2
+
1
3
  // @ts-expect-error vendored swagger-client bundle has no type declarations
2
4
  import { deserializeResponse } from '../vendor/swagger-client.mjs';
3
5
  import OpenAPIOperationResponse from "./OpenAPIOperationResponse.mjs";
@@ -12,7 +14,11 @@ import OpenAPIOperationResponse from "./OpenAPIOperationResponse.mjs";
12
14
  * Headers are taken from the `Response` directly rather than from the
13
15
  * deserializer, which splits every value containing `", "` into an array:
14
16
  * values stay strings as sent, with repeated fields (`Set-Cookie` included)
15
- * combined by `Headers.get`.
17
+ * combined by `Headers.get`. The body is read once, as bytes, and handed to the
18
+ * deserializer from memory. A body that decodes as UTF-8 without loss is kept
19
+ * as that one string, the same one the deserializer parses, so a plain-text or
20
+ * XML `body` shares it; any other body (binary, another charset, a leading
21
+ * byte-order mark) is kept as the bytes, so they are never re-encoded lossily.
16
22
  * @public
17
23
  */
18
24
  class OpenAPIOperationResponseNormalizer {
@@ -23,7 +29,30 @@ class OpenAPIOperationResponseNormalizer {
23
29
  * carries none (e.g. a stubbed transport).
24
30
  */
25
31
  async normalize(response, request) {
26
- const deserialized = await deserializeResponse(response, request?.url);
32
+ const bytes = new Uint8Array(await response.arrayBuffer());
33
+ const text = this.#decodeLosslessly(bytes);
34
+ // the deserializer matches the raw Content-Type case-sensitively and against
35
+ // its parameters too, so it is handed only the parsed essence; the response's
36
+ // own `headers` below stay as sent.
37
+ const deserializerHeaders = new Headers(response.headers);
38
+ const contentType = response.headers.get('content-type');
39
+ const mediaType = contentType === null ? undefined : parse(contentType, {
40
+ type: 'media-type'
41
+ }).tree;
42
+ if (mediaType !== undefined) {
43
+ deserializerHeaders.set('content-type', `${mediaType.type}/${mediaType.subtype}`);
44
+ }
45
+ // the deserializer only reads the body through text() or blob(), by content
46
+ // type; answering from memory spares a second copy of the stream.
47
+ const deserialized = await deserializeResponse({
48
+ ok: response.ok,
49
+ url: response.url,
50
+ status: response.status,
51
+ statusText: response.statusText,
52
+ headers: deserializerHeaders,
53
+ text: async () => text ?? new TextDecoder().decode(bytes),
54
+ blob: async () => new Blob([bytes])
55
+ }, request?.url);
27
56
  const headers = Object.fromEntries(Array.from(response.headers.keys(), name => [name, response.headers.get(name)]));
28
57
  return new OpenAPIOperationResponse({
29
58
  ok: deserialized.ok,
@@ -31,10 +60,26 @@ class OpenAPIOperationResponseNormalizer {
31
60
  status: deserialized.status,
32
61
  statusText: deserialized.statusText,
33
62
  headers,
34
- text: deserialized.text,
35
63
  body: deserialized.body,
64
+ content: text ?? bytes,
36
65
  request
37
66
  });
38
67
  }
68
+
69
+ /**
70
+ * The bytes as a string that encodes back to exactly them, or `undefined`
71
+ * when there is none: invalid UTF-8, or a leading byte-order mark, which
72
+ * decoding strips.
73
+ */
74
+ #decodeLosslessly(bytes) {
75
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return undefined;
76
+ try {
77
+ return new TextDecoder('utf-8', {
78
+ fatal: true
79
+ }).decode(bytes);
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
39
84
  }
40
85
  export default OpenAPIOperationResponseNormalizer;
@@ -9,6 +9,7 @@ var _apidomCore = require("@speclynx/apidom-core");
9
9
  var _apidomDatamodel = require("@speclynx/apidom-datamodel");
10
10
  var _empty = require("@speclynx/apidom-reference/configuration/empty");
11
11
  var _apidomNsOpenapi = require("@speclynx/apidom-ns-openapi-2");
12
+ var _cookie = require("@swaggerexpert/cookie");
12
13
  var _mediaTypes = require("@swaggerexpert/media-types");
13
14
  var _ramdaAdjunct = require("ramda-adjunct");
14
15
  var _swaggerClient = require("../vendor/swagger-client.cjs");
@@ -30,6 +31,21 @@ var _ClientError = _interopRequireDefault(require("../errors/ClientError.cjs"));
30
31
  * @public
31
32
  */
32
33
 
34
+ /**
35
+ * The plain-JSON shape of the assembled document, as far as declaring a
36
+ * parameter on its one operation needs it.
37
+ */
38
+
39
+ /**
40
+ * A caller-supplied parameter the operation does not declare, in a location
41
+ * it can still be delivered to.
42
+ */
43
+
44
+ const UNDECLARED_LOCATIONS = new Set(['header', 'query', 'cookie', 'path']);
45
+
46
+ // the header parameters OpenAPI 3 says SHALL be ignored, which buildRequest drops
47
+ const IGNORED_HEADERS = new Set(['accept', 'authorization', 'content-type']);
48
+
33
49
  /**
34
50
  * Options for executing an OpenAPI operation.
35
51
  *
@@ -148,9 +164,11 @@ class OpenAPIOperationExecutor {
148
164
  const withServer = this.#applyServerOptions(rest, normalizedOperation, locator.document);
149
165
  const withBody = this.#adaptRequestBody(withServer, normalizedOperation, locator.document, locator.jsonPointer);
150
166
  const buildOptions = this.#adaptRequestContentType(withBody, normalizedOperation, locator.document, locator.jsonPointer);
167
+ const undeclared = this.#undeclaredParameters(buildOptions.parameters, normalizedOperation);
168
+ const spec = assembled.toJSON();
169
+ this.#declareQueryAndPathParameters(spec, undeclared, (0, _apidomCore.toValue)(operation.meta.get('path')), (0, _apidomCore.toValue)(operation.meta.get('http-method')), locator);
151
170
  let request;
152
171
  try {
153
- const spec = assembled.toJSON();
154
172
  request = (0, _swaggerClient.buildRequest)({
155
173
  ...buildOptions,
156
174
  spec,
@@ -170,6 +188,7 @@ class OpenAPIOperationExecutor {
170
188
  if (typeof authoredContentType === 'string' && buildOptions.requestContentType !== authoredContentType) {
171
189
  request.headers['Content-Type'] = authoredContentType;
172
190
  }
191
+ this.#deliverHeadersAndCookies(request, undeclared);
173
192
  request = await this.#finalizeRequest(request, requestInterceptor);
174
193
 
175
194
  // capture the request as sent, so `$url` / `$method` / `$request.*` can be
@@ -195,6 +214,130 @@ class OpenAPIOperationExecutor {
195
214
  return this.#responseNormalizer.normalize(rawResponse, requestInfo);
196
215
  }
197
216
 
217
+ /**
218
+ * The `{in}.{name}`-keyed parameters (see {@link ParameterDelivery}) in a
219
+ * header, query, cookie or path location that the operation does not
220
+ * declare. `buildRequest` delivers only what the operation declares, yet a
221
+ * step's parameters are what its author asked to send — an `Authorization`
222
+ * header carrying a token from an earlier step most of all, which OpenAPI 3
223
+ * cannot even declare. A declared `Accept`, `Authorization` or
224
+ * `Content-Type` header parameter counts as undeclared too: `buildRequest`
225
+ * drops it for OpenAPI 3, as that specification requires, and for 2.0
226
+ * delivering it here sends the same value. Bare keys are left to
227
+ * `buildRequest`: they name no location to deliver to.
228
+ */
229
+ #undeclaredParameters(parameters, operation) {
230
+ if (!(0, _ramdaAdjunct.isPlainObject)(parameters)) return [];
231
+ const declared = new Set(((0, _apidomDatamodel.isArrayElement)(operation.parameters) ? [...operation.parameters] : []).filter(_apidomDatamodel.isObjectElement).map(parameter => `${(0, _apidomCore.toValue)(parameter.get('in'))}.${(0, _apidomCore.toValue)(parameter.get('name'))}`));
232
+ return Object.entries(parameters).flatMap(([key, value]) => {
233
+ const separator = key.indexOf('.');
234
+ const location = key.slice(0, separator);
235
+ const name = key.slice(separator + 1);
236
+ // an unresolved expression or a null delivers nothing, as buildRequest
237
+ // does for a declared parameter
238
+ if (separator === -1 || !UNDECLARED_LOCATIONS.has(location) || value === undefined || value === null) {
239
+ return [];
240
+ }
241
+ const ignored = location === 'header' && IGNORED_HEADERS.has(name.toLowerCase());
242
+ return declared.has(key) && !ignored ? [] : [{
243
+ location,
244
+ name,
245
+ value
246
+ }];
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Declares the undeclared query and path parameters on the assembled
252
+ * operation, so `buildRequest` serializes and encodes them as it does
253
+ * declared ones. A path parameter is declared only when the path has its
254
+ * `{name}` template expression; otherwise there is nowhere to put it, and
255
+ * that authoring error throws {@link ClientError} rather than sending a
256
+ * request without it.
257
+ */
258
+ #declareQueryAndPathParameters(spec, undeclared, path, method, locator) {
259
+ const operation = spec.paths[path][method];
260
+ const isSwagger = (0, _apidomNsOpenapi.isSwaggerElement)(locator.document.parseResult.api);
261
+ for (const {
262
+ location,
263
+ name
264
+ } of undeclared) {
265
+ if (location !== 'query' && location !== 'path') continue;
266
+ if (location === 'path' && !path.includes(`{${name}}`)) {
267
+ throw new _ClientError.default(`Path parameter "${name}" is not declared by the operation and its path "${path}" ` + `has no "{${name}}" to substitute it into`, {
268
+ operationPath: locator.jsonPointer
269
+ });
270
+ }
271
+ const required = location === 'path';
272
+ (operation.parameters ??= []).push(isSwagger ? {
273
+ name,
274
+ in: location,
275
+ required,
276
+ type: 'string'
277
+ } : {
278
+ name,
279
+ in: location,
280
+ required,
281
+ schema: {}
282
+ });
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Sets the undeclared header parameters on the built request and appends the
288
+ * undeclared cookie parameters to its `Cookie` header. A header replaces any
289
+ * value the build gave it under any spelling — the default `Accept`, say —
290
+ * since the step naming it is the more specific intent. OpenAPI 2.0 has no
291
+ * cookie location, so its cookies always go this way.
292
+ */
293
+ #deliverHeadersAndCookies(request, undeclared) {
294
+ const present = name => Object.keys(request.headers).find(header => header.toLowerCase() === name.toLowerCase());
295
+ for (const {
296
+ location,
297
+ name,
298
+ value
299
+ } of undeclared) {
300
+ if (location !== 'header') continue;
301
+ const built = present(name);
302
+ if (built !== undefined) delete request.headers[built];
303
+ request.headers[name] = this.#stringify(value);
304
+ }
305
+ const cookies = undeclared.filter(({
306
+ location
307
+ }) => location === 'cookie').map(({
308
+ name,
309
+ value
310
+ }) => [name, this.#stringify(value)]);
311
+ if (cookies.length === 0) return;
312
+ // serialized as buildRequest serializes a declared cookie, so a `;` in the
313
+ // value is percent-encoded rather than splitting it into further cookies
314
+ const serialized = (0, _cookie.serializeCookie)(cookies, {
315
+ encoders: {
316
+ value: _cookie.cookieValueStrictPercentEncoder
317
+ },
318
+ validators: {
319
+ name: _cookie.cookieNameLenientValidator
320
+ }
321
+ });
322
+ const cookieHeader = present('Cookie') ?? 'Cookie';
323
+ const existing = request.headers[cookieHeader];
324
+ request.headers[cookieHeader] = existing ? `${existing}; ${serialized}` : serialized;
325
+ }
326
+
327
+ /**
328
+ * A header or cookie value with no schema to serialize it by, the way
329
+ * OpenAPI's default for those locations (`simple` and `form`, not exploded)
330
+ * serializes a declared one: an array as `a,b`, an object as `k,v,k2,v2`,
331
+ * anything else via `String()`.
332
+ */
333
+ #stringify(value) {
334
+ if (Array.isArray(value)) return value.map(String).join(',');
335
+ if (typeof value === 'object' && value !== null) {
336
+ return Object.entries(value).flat().map(String).join(',');
337
+ }
338
+ return String(value);
339
+ }
340
+
198
341
  /**
199
342
  * Derives where the request is sent, returning the options to build with.
200
343
  *