| @@ -5,9 +5,11 @@ import type { StorageFailure } from "@executor/sdk"; · 4 unmodified lines |
| 5 | | 5 | |
| 6 | import { OpenApiInvocationError } from "./errors"; | 6 | import { OpenApiInvocationError } from "./errors"; |
| 7 | import { | 7 | import { |
| | 8 | type EncodingObject, |
| 8 | type HeaderValue, | 9 | type HeaderValue, |
| 9 | type OperationBinding, | 10 | type OperationBinding, |
| 10 | InvocationResult, | 11 | InvocationResult, |
| | 12 | type MediaBinding, |
| 11 | type OperationParameter, | 13 | type OperationParameter, |
| 12 | } from "./types"; | 14 | } from "./types"; |
| 13 | | 15 | |
| @@ -175,6 +177,330 @@ const isFormUrlEncoded = (ct: string | null | undefined): boolean => · 161 unmodified lines |
| 175 | const isMultipartFormData = (ct: string | null | undefined): boolean => | 177 | const isMultipartFormData = (ct: string | null | undefined): boolean => |
| 176 | normalizeContentType(ct).startsWith("multipart/form-data"); | 178 | normalizeContentType(ct).startsWith("multipart/form-data"); |
| 177 | | 179 | |
| | 180 | const isXmlContentType = (ct: string | null | undefined): boolean => { |
| | 181 | const normalized = normalizeContentType(ct); |
| | 182 | if (!normalized) return false; |
| | 183 | return ( |
| | 184 | normalized === "application/xml" || |
| | 185 | normalized === "text/xml" || |
| | 186 | normalized.endsWith("+xml") |
| | 187 | ); |
| | 188 | }; |
| | 189 | |
| | 190 | const isTextContentType = (ct: string | null | undefined): boolean => |
| | 191 | normalizeContentType(ct).startsWith("text/"); |
| | 192 | |
| | 193 | const isOctetStream = (ct: string | null | undefined): boolean => |
| | 194 | normalizeContentType(ct) === "application/octet-stream"; |
| | 195 | |
| | 196 | const toUint8Array = (value: unknown): Uint8Array | null => { |
| | 197 | if (value instanceof Uint8Array) return value; |
| | 198 | if (value instanceof ArrayBuffer) return new Uint8Array(value); |
| | 199 | if (ArrayBuffer.isView(value)) { |
| | 200 | const view = value as ArrayBufferView; |
| | 201 | return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); |
| | 202 | } |
| | 203 | if (Array.isArray(value) && value.every((v) => typeof v === "number")) { |
| | 204 | return new Uint8Array(value as readonly number[]); |
| | 205 | } |
| | 206 | return null; |
| | 207 | }; |
| | 208 | |
| | 209 | type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1]; |
| | 210 | type FormDataCoercible = FormDataRecord[string]; |
| | 211 | |
| | 212 | // Pull a plain ArrayBuffer out of a Uint8Array — `new Blob([u8])` rejects |
| | 213 | // views whose `.buffer` is `SharedArrayBuffer | ArrayBuffer` under strict |
| | 214 | // lib.dom typings. |
| | 215 | const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { |
| | 216 | const copy = new ArrayBuffer(bytes.byteLength); |
| | 217 | new Uint8Array(copy).set(bytes); |
| | 218 | return copy; |
| | 219 | }; |
| | 220 | |
| | 221 | // --------------------------------------------------------------------------- |
| | 222 | // OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType |
| | 223 | // for multipart/form-data and application/x-www-form-urlencoded bodies. |
| | 224 | // Spec ref: https://spec.openapis.org/oas/v3.1.0#encoding-object |
| | 225 | // --------------------------------------------------------------------------- |
| | 226 | |
| | 227 | type StyleExplode = { |
| | 228 | readonly style: string; |
| | 229 | readonly explode: boolean; |
| | 230 | readonly allowReserved: boolean; |
| | 231 | }; |
| | 232 | |
| | 233 | const DEFAULT_FORM_STYLE: StyleExplode = { |
| | 234 | style: "form", |
| | 235 | explode: true, |
| | 236 | allowReserved: false, |
| | 237 | }; |
| | 238 | |
| | 239 | const resolveStyleExplode = (e: EncodingObject | undefined): StyleExplode => { |
| | 240 | if (!e) return DEFAULT_FORM_STYLE; |
| | 241 | return { |
| | 242 | style: Option.getOrElse(e.style, () => DEFAULT_FORM_STYLE.style), |
| | 243 | explode: Option.getOrElse(e.explode, () => DEFAULT_FORM_STYLE.explode), |
| | 244 | allowReserved: Option.getOrElse(e.allowReserved, () => DEFAULT_FORM_STYLE.allowReserved), |
| | 245 | }; |
| | 246 | }; |
| | 247 | |
| | 248 | // RFC 3986 §2.2 reserved chars. `allowReserved: true` leaves these |
| | 249 | // unencoded; default OAS behavior encodes everything non-unreserved. |
| | 250 | const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/; |
| | 251 | |
| | 252 | const encodeFormValue = (v: unknown, allowReserved: boolean): string => { |
| | 253 | const raw = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v); |
| | 254 | if (!allowReserved) return encodeURIComponent(raw); |
| | 255 | // Walk char-by-char so the reserved set passes through as-is. |
| | 256 | let out = ""; |
| | 257 | for (const ch of raw) { |
| | 258 | out += RESERVED_UNENCODED_RE.test(ch) ? ch : encodeURIComponent(ch); |
| | 259 | } |
| | 260 | return out; |
| | 261 | }; |
| | 262 | |
| | 263 | /** |
| | 264 | * Serialize a record to application/x-www-form-urlencoded with OAS3 style |
| | 265 | * rules honored per-field. Supports `form` (default), `deepObject`, |
| | 266 | * `pipeDelimited`, `spaceDelimited` styles with `explode` true / false. |
| | 267 | */ |
| | 268 | const serializeFormUrlEncoded = ( |
| | 269 | value: Record<string, unknown>, |
| | 270 | encoding: Record<string, EncodingObject> | undefined, |
| | 271 | ): string => { |
| | 272 | const parts: string[] = []; |
| | 273 | for (const [key, raw] of Object.entries(value)) { |
| | 274 | if (raw === undefined || raw === null) continue; |
| | 275 | const { style, explode, allowReserved } = resolveStyleExplode(encoding?.[key]); |
| | 276 | const encKey = encodeURIComponent(key); |
| | 277 | |
| | 278 | if (Array.isArray(raw)) { |
| | 279 | if (explode) { |
| | 280 | for (const v of raw) { |
| | 281 | parts.push(`${encKey}=${encodeFormValue(v, allowReserved)}`); |
| | 282 | } |
| | 283 | } else { |
| | 284 | const sep = |
| | 285 | style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; |
| | 286 | parts.push( |
| | 287 | `${encKey}=${encodeFormValue( |
| | 288 | raw.map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(sep), |
| | 289 | allowReserved, |
| | 290 | )}`, |
| | 291 | ); |
| | 292 | } |
| | 293 | continue; |
| | 294 | } |
| | 295 | |
| | 296 | if (typeof raw === "object") { |
| | 297 | const entries = Object.entries(raw as Record<string, unknown>).filter( |
| | 298 | ([, v]) => v !== undefined && v !== null, |
| | 299 | ); |
| | 300 | if (style === "deepObject") { |
| | 301 | for (const [subkey, subval] of entries) { |
| | 302 | // Encode the whole `key[subkey]` fragment so `[` / `]` become |
| | 303 | // `%5B` / `%5D`. Matches swagger-client's behaviour and remains |
| | 304 | // accepted by common server-side parsers (qs, Rails, etc.). |
| | 305 | parts.push( |
| | 306 | `${encodeURIComponent(`${key}[${subkey}]`)}=${encodeFormValue( |
| | 307 | subval, |
| | 308 | allowReserved, |
| | 309 | )}`, |
| | 310 | ); |
| | 311 | } |
| | 312 | } else if (explode) { |
| | 313 | // form + explode=true on object: sub-keys become top-level fields. |
| | 314 | for (const [subkey, subval] of entries) { |
| | 315 | parts.push( |
| | 316 | `${encodeURIComponent(subkey)}=${encodeFormValue(subval, allowReserved)}`, |
| | 317 | ); |
| | 318 | } |
| | 319 | } else { |
| | 320 | // form + explode=false on object: flatten to csv key,val,key,val. |
| | 321 | const flat = entries.flatMap(([k, v]) => [ |
| | 322 | k, |
| | 323 | typeof v === "object" ? JSON.stringify(v) : String(v), |
| | 324 | ]); |
| | 325 | parts.push(`${encKey}=${encodeFormValue(flat.join(","), allowReserved)}`); |
| | 326 | } |
| | 327 | continue; |
| | 328 | } |
| | 329 | |
| | 330 | parts.push(`${encKey}=${encodeFormValue(raw, allowReserved)}`); |
| | 331 | } |
| | 332 | return parts.join("&"); |
| | 333 | }; |
| | 334 | |
| | 335 | /** |
| | 336 | * Best-effort build of a multipart FormData entry record. |
| | 337 | * |
| | 338 | * If `encoding[key].contentType` is declared (OAS3 §4.8.15), wrap the value |
| | 339 | * in a `Blob` with that type so the runtime multipart framer emits the |
| | 340 | * per-part `Content-Type` header (e.g. `application/json` for a metadata |
| | 341 | * part whose server expects parsed JSON). |
| | 342 | * |
| | 343 | * Otherwise: primitives pass through, arrays handle their item types, byte |
| | 344 | * shapes wrap as Blob, nested objects JSON-stringify (never `[object Object]`). |
| | 345 | */ |
| | 346 | const coerceFormDataRecord = ( |
| | 347 | value: Record<string, unknown>, |
| | 348 | encoding: Record<string, EncodingObject> | undefined, |
| | 349 | ): FormDataRecord => { |
| | 350 | const out: Record<string, FormDataCoercible> = {}; |
| | 351 | for (const [key, raw] of Object.entries(value)) { |
| | 352 | if (raw === undefined || raw === null) continue; |
| | 353 | |
| | 354 | const partType = encoding?.[key] |
| | 355 | ? Option.getOrUndefined(encoding[key]!.contentType) |
| | 356 | : undefined; |
| | 357 | |
| | 358 | // Explicit per-part content type: wrap in a typed Blob so the framer |
| | 359 | // emits `Content-Type: <partType>` on this part. JSON types get the |
| | 360 | // value JSON-stringified first so the blob body is valid JSON. |
| | 361 | if (partType) { |
| | 362 | const isJson = |
| | 363 | partType.startsWith("application/json") || partType.includes("+json"); |
| | 364 | const serialized = |
| | 365 | typeof raw === "string" |
| | 366 | ? raw |
| | 367 | : isJson |
| | 368 | ? JSON.stringify(raw) |
| | 369 | : typeof raw === "object" |
| | 370 | ? JSON.stringify(raw) |
| | 371 | : String(raw); |
| | 372 | out[key] = new Blob([serialized], { type: partType }); |
| | 373 | continue; |
| | 374 | } |
| | 375 | |
| | 376 | if ( |
| | 377 | typeof raw === "string" || |
| | 378 | typeof raw === "number" || |
| | 379 | typeof raw === "boolean" || |
| | 380 | raw instanceof Blob || |
| | 381 | (typeof File !== "undefined" && raw instanceof File) |
| | 382 | ) { |
| | 383 | out[key] = raw as FormDataCoercible; |
| | 384 | continue; |
| | 385 | } |
| | 386 | if (Array.isArray(raw)) { |
| | 387 | out[key] = raw.map((v) => |
| | 388 | typeof v === "string" || |
| | 389 | typeof v === "number" || |
| | 390 | typeof v === "boolean" || |
| | 391 | v instanceof Blob || |
| | 392 | (typeof File !== "undefined" && v instanceof File) |
| | 393 | ? (v as FormDataCoercible) |
| | 394 | : JSON.stringify(v), |
| | 395 | ) as FormDataCoercible; |
| | 396 | continue; |
| | 397 | } |
| | 398 | const bytes = toUint8Array(raw); |
| | 399 | if (bytes) { |
| | 400 | out[key] = new Blob([toArrayBuffer(bytes)]); |
| | 401 | continue; |
| | 402 | } |
| | 403 | out[key] = JSON.stringify(raw); |
| | 404 | } |
| | 405 | return out; |
| | 406 | }; |
| | 407 | |
| | 408 | // --------------------------------------------------------------------------- |
| | 409 | // Request body dispatch |
| | 410 | // |
| | 411 | // Dispatch is driven by the spec-declared content type first, JS type of |
| | 412 | // the provided body second. Servers that advertise a specific content type |
| | 413 | // almost always reject anything else (e.g. a multipart endpoint will hang |
| | 414 | // waiting for valid framing if it receives `application/json`), so the |
| | 415 | // content type wins. |
| | 416 | // |
| | 417 | // Within each content type we accept both pre-serialized strings (user |
| | 418 | // already produced the wire format) and structured JS values we can |
| | 419 | // serialize ourselves. The last-resort fallback is `JSON.stringify(body)` |
| | 420 | // — never `String(body)` (which produces the useless `[object Object]`). |
| | 421 | // --------------------------------------------------------------------------- |
| | 422 | |
| | 423 | const applyRequestBody = ( |
| | 424 | request: HttpClientRequest.HttpClientRequest, |
| | 425 | contentType: string, |
| | 426 | bodyValue: unknown, |
| | 427 | encoding: Record<string, EncodingObject> | undefined, |
| | 428 | ): HttpClientRequest.HttpClientRequest => { |
| | 429 | if (isJsonContentType(contentType)) { |
| | 430 | // Pre-serialized JSON strings pass through with the declared media |
| | 431 | // type preserved (important for `application/vnd.foo+json` etc.). |
| | 432 | if (typeof bodyValue === "string") { |
| | 433 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| | 434 | } |
| | 435 | return HttpClientRequest.bodyUnsafeJson(request, bodyValue); |
| | 436 | } |
| | 437 | |
| | 438 | if (isFormUrlEncoded(contentType)) { |
| | 439 | if (typeof bodyValue === "string") { |
| | 440 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| | 441 | } |
| | 442 | if (typeof bodyValue === "object" && bodyValue !== null && !Array.isArray(bodyValue)) { |
| | 443 | // Serialize ourselves so OAS3 encoding (style/explode/deepObject) |
| | 444 | // is honored. bodyUrlParams doesn't know about per-field style. |
| | 445 | const serialized = serializeFormUrlEncoded( |
| | 446 | bodyValue as Record<string, unknown>, |
| | 447 | encoding, |
| | 448 | ); |
| | 449 | return HttpClientRequest.bodyText(request, serialized, contentType); |
| | 450 | } |
| | 451 | // Non-object body — fall back to platform helper (handles URLSearchParams). |
| | 452 | return HttpClientRequest.bodyUrlParams( |
| | 453 | request, |
| | 454 | bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1], |
| | 455 | ); |
| | 456 | } |
| | 457 | |
| | 458 | if (isMultipartFormData(contentType)) { |
| | 459 | if (bodyValue instanceof FormData) { |
| | 460 | return HttpClientRequest.bodyFormData(request, bodyValue); |
| | 461 | } |
| | 462 | if (typeof bodyValue === "object" && bodyValue !== null) { |
| | 463 | return HttpClientRequest.bodyFormDataRecord( |
| | 464 | request, |
| | 465 | coerceFormDataRecord(bodyValue as Record<string, unknown>, encoding), |
| | 466 | ); |
| | 467 | } |
| | 468 | // String / primitive under multipart is almost certainly wrong on the |
| | 469 | // caller's end — send it as text with their declared content type and |
| | 470 | // let the server produce a useful error. |
| | 471 | return HttpClientRequest.bodyText(request, String(bodyValue), contentType); |
| | 472 | } |
| | 473 | |
| | 474 | if (isOctetStream(contentType)) { |
| | 475 | const bytes = toUint8Array(bodyValue); |
| | 476 | if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); |
| | 477 | if (typeof bodyValue === "string") { |
| | 478 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| | 479 | } |
| | 480 | // Unknown shape — serialize as JSON so at least the payload is visible. |
| | 481 | return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); |
| | 482 | } |
| | 483 | |
| | 484 | if (isXmlContentType(contentType) || isTextContentType(contentType)) { |
| | 485 | if (typeof bodyValue === "string") { |
| | 486 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| | 487 | } |
| | 488 | const bytes = toUint8Array(bodyValue); |
| | 489 | if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); |
| | 490 | // Object body under text/xml is unusual — stringify so the caller sees |
| | 491 | // their own payload instead of `[object Object]`. |
| | 492 | return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); |
| | 493 | } |
| | 494 | |
| | 495 | // Unknown content type: respect what the caller supplied. |
| | 496 | if (typeof bodyValue === "string") { |
| | 497 | return HttpClientRequest.bodyText(request, bodyValue, contentType); |
| | 498 | } |
| | 499 | const bytes = toUint8Array(bodyValue); |
| | 500 | if (bytes) return HttpClientRequest.bodyUint8Array(request, bytes, contentType); |
| | 501 | return HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), contentType); |
| | 502 | }; |
| | 503 | |
| 178 | // --------------------------------------------------------------------------- | 504 | // --------------------------------------------------------------------------- |
| 179 | // Public API — invoke a single operation | 505 | // Public API — invoke a single operation |
| 180 | // --------------------------------------------------------------------------- | 506 | // --------------------------------------------------------------------------- |
| @@ -218,23 +544,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 37 unmodified lines |
| 218 | const rb = operation.requestBody.value; | 544 | const rb = operation.requestBody.value; |
| 219 | const bodyValue = args.body ?? args.input; | 545 | const bodyValue = args.body ?? args.input; |
| 220 | if (bodyValue !== undefined) { | 546 | if (bodyValue !== undefined) { |
| 221 | if (isJsonContentType(rb.contentType)) { | 547 | // Resolve which declared media type to use. When the spec declares |
| 222 | request = HttpClientRequest.bodyUnsafeJson(request, bodyValue); | 548 | // multiple, the caller can override via `args.contentType`; otherwise |
| 223 | } else if (typeof bodyValue === "string") { | 549 | // we use the first-declared (spec author's preferred ordering). |
| 224 | request = HttpClientRequest.bodyText(request, bodyValue, rb.contentType); | 550 | const contentsOpt = Option.getOrUndefined(rb.contents); |
| 225 | } else if (isFormUrlEncoded(rb.contentType)) { | 551 | const requestedCt = |
| 226 | request = HttpClientRequest.bodyUrlParams( | 552 | typeof args.contentType === "string" ? args.contentType : undefined; |
| 227 | request, | 553 | const selected: MediaBinding | undefined = |
| 228 | bodyValue as Parameters<typeof HttpClientRequest.bodyUrlParams>[1], | 554 | contentsOpt && requestedCt |
| 229 | ); | 555 | ? contentsOpt.find((c) => c.contentType === requestedCt) |
| 230 | } else if (isMultipartFormData(rb.contentType)) { | 556 | : undefined; |
| 231 | request = HttpClientRequest.bodyFormDataRecord( | 557 | const chosenCt = selected?.contentType ?? rb.contentType; |
| 232 | request, | 558 | const chosenEncoding = selected |
| 233 | bodyValue as Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1], | 559 | ? Option.getOrUndefined(selected.encoding) |
| 234 | ); | 560 | : contentsOpt && contentsOpt[0] |
| 235 | } else { | 561 | ? Option.getOrUndefined(contentsOpt[0].encoding) |
| 236 | request = HttpClientRequest.bodyText(request, JSON.stringify(bodyValue), rb.contentType); | 562 | : undefined; |
| 237 | } | 563 | request = applyRequestBody(request, chosenCt, bodyValue, chosenEncoding); |
| 238 | } | 564 | } |
| 239 | } | 565 | } |
| 240 | | 566 | |