From 66c13d7e13e6bda6cd436a87ab254955dcd45937 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 20 Aug 2026 08:42:42 -0700 Subject: [PATCH] Port OpenAPI improvements from the integration branch (#602) --- crates/yaak-http/src/types.rs | 147 ++- crates/yaak-models/src/queries/folders.rs | 6 +- .../yaak-models/src/queries/grpc_requests.rs | 6 +- .../yaak-models/src/queries/http_requests.rs | 47 +- crates/yaak-models/src/queries/mod.rs | 70 +- .../src/queries/websocket_requests.rs | 10 +- crates/yaak-models/src/queries/workspaces.rs | 5 +- plugins/importer-openapi/src/index.ts | 790 ++++++++++++---- plugins/importer-openapi/tests/index.test.ts | 865 +++++++++++++++++- 9 files changed, 1735 insertions(+), 211 deletions(-) diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index d7931684..8800c06a 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -78,8 +78,19 @@ impl SendableHttpRequest { } pub fn insert_header(&mut self, header: (String, String)) { + if header.0.eq_ignore_ascii_case("cookie") { + if let Some(existing) = + self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie")) + { + existing.1 = format!("{}; {}", existing.1, header.1); + } else { + self.headers.push(header); + } + return; + } + if let Some(existing) = - self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase()) + self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0)) { existing.1 = header.1; } else { @@ -205,16 +216,23 @@ fn append_graphql_query_params(url: &str, body: &BTreeMap Vec<(String, String)> { - r.headers - .iter() - .filter_map(|h| { - if h.enabled && !h.name.is_empty() { - Some((h.name.clone(), h.value.clone())) - } else { - None + // RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into + // the first one + let mut headers: Vec<(String, String)> = Vec::new(); + for h in &r.headers { + if !h.enabled || h.name.is_empty() { + continue; + } + if h.name.eq_ignore_ascii_case("cookie") { + if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie")) + { + existing.1 = format!("{}; {}", existing.1, h.value); + continue; } - }) - .collect() + } + headers.push((h.name.clone(), h.value.clone())); + } + headers } async fn build_body( @@ -494,7 +512,114 @@ mod tests { use bytes::Bytes; use serde_json::json; use std::collections::BTreeMap; - use yaak_models::models::{HttpRequest, HttpUrlParameter}; + use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; + + #[tokio::test] + async fn test_sendable_request_preserves_independent_cookie_enabled_states() { + let request = HttpRequest { + url: "https://example.com/api".to_string(), + headers: vec![ + HttpRequestHeader { + enabled: true, + name: "Cookie".to_string(), + value: "session=abc".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "debug=verbose".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]); + } + + #[tokio::test] + async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() { + let request = HttpRequest { + url: "https://example.com/api".to_string(), + headers: vec![ + HttpRequestHeader { + enabled: true, + name: "Cookie".to_string(), + value: "session=abc".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "debug=verbose".to_string(), + id: None, + }, + HttpRequestHeader { + enabled: true, + name: "cookie".to_string(), + value: "theme=dark".to_string(), + id: None, + }, + ], + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!( + sendable.headers, + vec![("Cookie".to_string(), "session=abc; theme=dark".to_string())], + ); + } + + #[test] + fn test_insert_header_appends_authentication_cookie() { + let mut request = SendableHttpRequest { + headers: vec![ + ("Cookie".to_string(), "session=abc".to_string()), + ("Cookie".to_string(), "theme=dark".to_string()), + ], + ..Default::default() + }; + + request.insert_header(("cookie".to_string(), "api_key=secret".to_string())); + + assert_eq!( + request.headers, + vec![ + ("Cookie".to_string(), "session=abc; api_key=secret".to_string()), + ("Cookie".to_string(), "theme=dark".to_string()), + ], + ); + } + + #[tokio::test] + async fn test_sendable_request_preserves_serialized_path_delimiters() { + let request = HttpRequest { + url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2" + .to_string(), + ..Default::default() + }; + + let sendable = + SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default()) + .await + .unwrap(); + + assert_eq!( + sendable.url, + "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2", + ); + } #[test] fn test_build_url_no_params() { diff --git a/crates/yaak-models/src/queries/folders.rs b/crates/yaak-models/src/queries/folders.rs index a26e3379..bc5bd389 100644 --- a/crates/yaak-models/src/queries/folders.rs +++ b/crates/yaak-models/src/queries/folders.rs @@ -1,4 +1,4 @@ -use super::conflict_free_name; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::connection_or_tx::ConnectionOrTx; use crate::error::Result; @@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut folder.headers.clone()); - - Ok(headers) + Ok(merge_headers(headers, folder.headers.clone())) } pub fn resolve_settings_for_folder( diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index f54635b4..593e128f 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> { metadata.append(&mut workspace_metadata); } - metadata.append(&mut grpc_request.metadata.clone()); - - Ok(dedupe_headers(metadata)) + Ok(merge_headers(metadata, grpc_request.metadata.clone())) } pub fn resolve_settings_for_grpc_request( diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index 6130ec67..d4e0d6d7 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut http_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, http_request.headers.clone())) } pub fn resolve_settings_for_http_request( @@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> { Ok(children) } } + +#[cfg(test)] +mod tests { + use crate::init_in_memory; + use crate::models::{HttpRequest, HttpRequestHeader}; + + #[test] + fn request_resolution_preserves_duplicate_request_headers() { + let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0); + let request = HttpRequest { + workspace_id: workspace.id, + headers: vec![ + HttpRequestHeader { + name: "Cookie".to_string(), + value: "required=1".to_string(), + ..Default::default() + }, + HttpRequestHeader { + enabled: false, + name: "Cookie".to_string(), + value: "optional=1".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve"); + let cookies = resolved + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("cookie")) + .collect::>(); + + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].value, "required=1"); + assert_eq!(cookies[1].value, "optional=1"); + assert!(!cookies[1].enabled); + } +} diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index 2b6bdc43..e2e8dabc 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name; const MAX_HISTORY_ITEMS: usize = 20; use crate::models::HttpRequestHeader; -use std::collections::HashMap; +use std::collections::HashSet; -/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. -/// Preserves the order of first occurrence for each header name. -pub(crate) fn dedupe_headers(headers: Vec) -> Vec { - let mut index_by_name: HashMap = HashMap::new(); - let mut deduped: Vec = Vec::new(); - for header in headers { - let key = header.name.to_lowercase(); - if let Some(&idx) = index_by_name.get(&key) { - deduped[idx] = header; - } else { - index_by_name.insert(key, deduped.len()); - deduped.push(header); - } - } - deduped +/// Merge a more-specific header layer over its parent. Names in the child replace +/// inherited values case-insensitively, while duplicates declared together in +/// either layer remain independent entries. +pub(crate) fn merge_headers( + mut parent: Vec, + child: Vec, +) -> Vec { + let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::>(); + parent.retain(|header| !child_names.contains(&header.name.to_lowercase())); + parent.extend(child); + parent +} + +#[cfg(test)] +mod tests { + use super::merge_headers; + use crate::models::HttpRequestHeader; + + fn header(name: &str, value: &str) -> HttpRequestHeader { + HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() } + } + + #[test] + fn preserves_duplicate_headers_declared_in_one_layer() { + let merged = merge_headers( + vec![header("Cookie", "inherited=1")], + vec![ + header("Cookie", "required=1"), + header("cookie", "optional=1"), + ], + ); + + assert_eq!( + merged.iter().map(|header| header.value.as_str()).collect::>(), + vec!["required=1", "optional=1"], + ); + } + + #[test] + fn child_names_override_parent_names_without_affecting_other_headers() { + let merged = merge_headers( + vec![header("Accept", "*/*"), header("X-Parent", "kept")], + vec![header("accept", "application/json")], + ); + + assert_eq!( + merged + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(), + vec![("X-Parent", "kept"), ("accept", "application/json")], + ); + } } diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index 3ef4c16d..1dcf752f 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -1,4 +1,4 @@ -use super::{conflict_free_name, dedupe_headers}; +use super::{conflict_free_name, merge_headers}; use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{ @@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> { &self, websocket_request: &WebsocketRequest, ) -> Result> { - let workspace = self.get_workspace(&websocket_request.workspace_id)?; - // Resolved headers should be from furthest to closest ancestor, to override logically. let mut headers = Vec::new(); - headers.append(&mut workspace.headers.clone()); - if let Some(folder_id) = websocket_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; @@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> { headers.append(&mut workspace_headers); } - headers.append(&mut websocket_request.headers.clone()); - - Ok(dedupe_headers(headers)) + Ok(merge_headers(headers, websocket_request.headers.clone())) } pub fn resolve_settings_for_websocket_request( diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 50deb620..6ed7a262 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -1,3 +1,4 @@ +use super::merge_headers; use crate::blob_manager::BlobManager; use crate::client_db::ClientDb; use crate::error::Result; @@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> { } pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { - let mut headers = default_headers(); - headers.extend(workspace.headers.clone()); - headers + merge_headers(default_headers(), workspace.headers.clone()) } pub fn resolve_settings_for_workspace( diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 6fce18aa..124aa25d 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -37,6 +37,7 @@ const BODY_CONTENT_TYPE_PREFERENCE = [ "text/plain", ]; const MAX_EXAMPLE_DEPTH = 8; +const MAX_SCHEMA_RESOLUTION_DEPTH = MAX_EXAMPLE_DEPTH; const MAX_EXAMPLE_PROPERTIES = 25; const MAX_DESCRIPTION_ITEMS = 40; const MAX_NAME_LENGTH = 100; @@ -330,20 +331,14 @@ function importOperation({ headers: inheritedAuthentication.headers, urlParameters: inheritedAuthentication.urlParameters, }; - const pathExampleValues = new Map( - parameters - .map((p) => importState.resolve(p)) - .filter(isRecord) - .filter((p) => stringAt(p, "in") === "path" && stringAt(p, "name") != null) - .map((p) => [stringAt(p, "name") as string, parameterExample(p, importState)] as const), - ); - const { url, placeholderNames } = buildOperationUrl( + const url = buildOperationUrl( operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), path, - pathExampleValues, + parameters, + importState, ); const urlParameters = [ - ...importUrlParameters({ importState, parameters, placeholderNames }), + ...importUrlParameters({ importState, parameters, path }), ...authentication.urlParameters, ]; const headers = mergeHeaders( @@ -700,25 +695,64 @@ function findOrCreateFolderId({ /** * Yaak's `:name` placeholders only substitute when they span a whole path - * segment. A template elsewhere in a segment, like `/report.{format}`, would - * import as text that never substitutes, and its leftover parameter would then - * be sent as a query parameter — so those get their example inlined instead. + * segment and hold a single plain value. Templates elsewhere in a segment + * (like `/report.{format}`), styled ones (label, matrix), and array or object + * values get their serialized example inlined instead — a placeholder row + * cannot express them, and its leftover parameter would leak into the query + * string. */ function buildOperationUrl( baseUrl: string, path: string, - inlineValues: Map, -): { url: string; placeholderNames: Set } { - const placeholderNames = new Set(); - const converted = path.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, (_, prefix, name) => { - placeholderNames.add(name); - return `${prefix}:${name}`; - }); - const inlined = converted.replaceAll(/{([^}/]+)}/g, (match, name) => { - const value = inlineValues.get(name); - return value == null || value === "" ? match : value; - }); - return { url: joinUrlParts(baseUrl, inlined), placeholderNames }; + parameters: unknown[], + importState: ImportState, +): string { + let serializedPath = path; + for (const rawParameter of parameters) { + const parameter = importState.resolve(rawParameter); + if (!isRecord(parameter) || !shouldInlinePathParameter(parameter, importState, path)) continue; + + const name = stringAt(parameter, "name") ?? ""; + if (name.length === 0) continue; + const value = parameterExampleValue(parameter, importState); + const serialized = isRecord(parameter.content) + ? encodePathComponent(serializeContentParameter(parameter, importState)) + : serializePathParameter(name, value, parameter, encodePathComponent); + // A missing example stays a visible template rather than vanishing + if (serialized.length === 0) continue; + serializedPath = serializedPath.replaceAll(`{${name}}`, serialized); + } + return joinUrlParts(baseUrl, serializedPath.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, "$1:$2")); +} + +function shouldInlinePathParameter( + parameter: UnknownRecord, + importState: ImportState, + path: string, +): boolean { + if (stringAt(parameter, "in") !== "path") return false; + const name = stringAt(parameter, "name") ?? ""; + const template = `{${name}}`; + const matchingSegments = path.split("/").filter((segment) => segment.includes(template)); + // A `:name` placeholder matches from the segment start up to a literal `:`, + // so `{id}` and `{id}:cancel` stay placeholders while `report.{format}` can't + const placeholderExpressible = matchingSegments.every( + (segment) => + segment === template || + (segment.startsWith(template) && segment[template.length] === ":"), + ); + if (matchingSegments.length === 0 || !placeholderExpressible) return true; + if (isRecord(parameter.content)) return false; + const value = parameterExampleValue(parameter, importState); + const style = stringAt(parameter, "style"); + return style === "label" || style === "matrix" || Array.isArray(value) || isRecord(value); +} + +function encodePathComponent(value: unknown): string { + return encodeURIComponent(stringifyExampleValue(value)).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); } function importBaseUrl(spec: UnknownRecord): string { @@ -809,47 +843,84 @@ function trimTrailingSlashes(value: string): string { function importUrlParameters({ importState, parameters, - placeholderNames, + path, }: { importState: ImportState; parameters: unknown[]; - placeholderNames: Set; + path: string; }): HttpUrlParameter[] { return parameters .map((p) => importState.resolve(p)) .filter(isRecord) - .filter( - (p) => - stringAt(p, "in") === "query" || - (stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")), - ) - .flatMap((p) => { - const name = stringAt(p, "name") ?? ""; - if (name.length === 0) return []; + .filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path") + .flatMap((p) => serializeUrlParameter(p, importState, path)) + .filter(({ name }) => name.length > 0); +} - // Path parameters are required by definition, and a disabled one would - // leave the literal `:name` in the sent URL even for sloppy specs that - // omit `required: true` - const enabled = p.required === true || stringAt(p, "in") === "path"; - if (stringAt(p, "in") === "query") { - const raw = rawParameterExample(p, importState); - if (Array.isArray(raw)) { - const { separator } = queryArraySerialization(p); - if (separator == null) { - return raw.map((item) => ({ enabled, name, value: stringifyExampleValue(item) })); - } - return [{ enabled, name, value: raw.map(stringifyExampleValue).join(separator) }]; - } - } +function serializeUrlParameter( + parameter: UnknownRecord, + importState: ImportState, + path: string, +): HttpUrlParameter[] { + const name = stringAt(parameter, "name") ?? ""; + const location = stringAt(parameter, "in"); + // Path parameters are required by definition, and a disabled one would + // leave the literal `:name` in the sent URL even for sloppy specs that + // omit `required: true` + const enabled = parameter.required === true || location === "path"; + const value = parameterExampleValue(parameter, importState); + if (isRecord(parameter.content)) { + return [ + { + enabled, + name: location === "path" ? `:${name}` : name, + value: serializeContentParameter(parameter, importState), + }, + ]; + } + if (location === "path") { + if (shouldInlinePathParameter(parameter, importState, path)) return []; + const serialized = serializePathParameter(name, value, parameter); + // An empty path segment makes a URL that matches nothing, so the name at + // least keeps the request sendable and shows what belongs there + return [{ enabled, name: `:${name}`, value: serialized.length > 0 ? serialized : name }]; + } - return [ - { - enabled, - name: stringAt(p, "in") === "path" ? `:${name}` : name, - value: parameterExample(p, importState), - }, - ]; - }); + if (isRecord(value)) { + const entries = Object.entries(value); + const style = stringAt(parameter, "style") ?? "form"; + const explode = parameter.explode !== false; + if (style === "deepObject") { + return entries.map(([key, entryValue]) => ({ + enabled, + name: `${name}[${key}]`, + value: stringifyExampleValue(entryValue), + })); + } + if (style === "form" && explode) { + return entries.map(([key, entryValue]) => ({ + enabled, + name: key, + value: stringifyExampleValue(entryValue), + })); + } + const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [{ enabled, name, value: entries.flat().map(stringifyExampleValue).join(separator) }]; + } + + if (Array.isArray(value)) { + const { separator } = queryArraySerialization(parameter); + if (separator == null) { + return value.map((entryValue) => ({ + enabled, + name, + value: stringifyExampleValue(entryValue), + })); + } + return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }]; + } + + return [{ enabled, name, value: stringifyExampleValue(value) }]; } /** @@ -891,12 +962,16 @@ function importHeaderParameters({ .map((p) => ({ enabled: p.required === true, name: stringAt(p, "name") ?? "", - value: parameterExample(p, importState), + value: serializeParameterValue(p, importState), })) .filter(({ name }) => name.length > 0); } -/** Yaak has no cookie parameter row, so cookie parameters become the header they would produce */ +/** + * Yaak has no cookie parameter row, so each cookie parameter becomes its own + * Cookie header. Rows stay individually toggleable and the send path merges + * the enabled ones into a single header. + */ function importCookieHeader({ importState, parameters, @@ -904,47 +979,131 @@ function importCookieHeader({ importState: ImportState; parameters: unknown[]; }): HttpRequestHeader[] { - const cookieParameters = parameters + return parameters .map((p) => importState.resolve(p)) .filter(isRecord) .filter((p) => stringAt(p, "in") === "cookie") - .filter((p) => (stringAt(p, "name") ?? "").length > 0); - if (cookieParameters.length === 0) return []; - - return [ - { - enabled: cookieParameters.some((p) => p.required === true), + .map((p) => ({ + enabled: p.required === true, name: "Cookie", - value: cookieParameters - .map((p) => `${stringAt(p, "name")}=${parameterExample(p, importState)}`) - .join("; "), - }, - ]; + value: serializeCookieParameter(p, importState), + })) + .filter(({ value }) => value.length > 0); } -function rawParameterExample(parameter: UnknownRecord, importState: ImportState): unknown { +function serializeCookieParameter(parameter: UnknownRecord, importState: ImportState): string { + const name = stringAt(parameter, "name") ?? ""; + if (name.length === 0) return ""; + if (isRecord(parameter.content)) { + return `${name}=${serializeContentParameter(parameter, importState)}`; + } + + const value = parameterExampleValue(parameter, importState); + const explode = parameter.explode !== false; + // Exploded pairs are cookie pairs, which RFC 6265 separates with "; " + if (Array.isArray(value)) { + return explode + ? value.map((entryValue) => `${name}=${stringifyExampleValue(entryValue)}`).join("; ") + : `${name}=${value.map(stringifyExampleValue).join(",")}`; + } + if (isRecord(value)) { + const entries = Object.entries(value); + return explode + ? entries + .map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`) + .join("; ") + : `${name}=${entries.flat().map(stringifyExampleValue).join(",")}`; + } + return `${name}=${stringifyExampleValue(value)}`; +} + +function serializeParameterValue(parameter: UnknownRecord, importState: ImportState): string { + if (isRecord(parameter.content)) return serializeContentParameter(parameter, importState); + return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter); +} + +/** A parameter described by a media type serializes as that media type */ +function serializeContentParameter(parameter: UnknownRecord, importState: ImportState): string { + const [contentType, rawMediaType] = Object.entries(toRecord(parameter.content))[0] ?? []; + const value = mediaTypeExample(toRecord(rawMediaType), importState); + return contentType?.toLowerCase().includes("json") + ? (JSON.stringify(value) ?? "") + : stringifyExampleValue(value); +} + +function serializePathParameter( + name: string, + value: unknown, + parameter: UnknownRecord, + serializeValue: (value: unknown) => string = stringifyExampleValue, +): string { + const style = stringAt(parameter, "style") ?? "simple"; + const explode = parameter.explode === true; + const values = Array.isArray(value) + ? value.map(serializeValue) + : isRecord(value) + ? Object.entries(value).flatMap(([key, entryValue]) => [ + serializeValue(key), + serializeValue(entryValue), + ]) + : [serializeValue(value)]; + + if (style === "label") { + if (explode && isRecord(value)) { + return `.${Object.entries(value) + .map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(".")}`; + } + return `.${values.join(explode ? "." : ",")}`; + } + if (style === "matrix") { + if (explode && Array.isArray(value)) { + return value.map((entryValue) => `;${name}=${serializeValue(entryValue)}`).join(""); + } + if (explode && isRecord(value)) { + return Object.entries(value) + .map(([key, entryValue]) => `;${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(""); + } + return `;${name}=${values.join(",")}`; + } + return serializeSimpleParameter(value, parameter, serializeValue); +} + +function serializeSimpleParameter( + value: unknown, + parameter: UnknownRecord, + serializeValue: (value: unknown) => string = stringifyExampleValue, +): string { + if (Array.isArray(value)) return value.map(serializeValue).join(","); + if (isRecord(value)) { + const entries = Object.entries(value); + return parameter.explode === true + ? entries + .map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(",") + : entries.flat().map(serializeValue).join(","); + } + return serializeValue(value); +} + +function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown { const directExample = firstPresent( parameter.example, firstExampleValue(parameter.examples, importState), ); if (directExample != null) return directExample; + if (isRecord(parameter.content)) { + const mediaType = toRecord(Object.values(parameter.content)[0]); + return mediaTypeExample(mediaType, importState); + } // Swagger 2 parameters carry the schema keywords (type, items, default) // directly on the parameter object - return schemaToExample(importState.resolve(parameter.schema ?? parameter), importState); + return schemaToExample(parameter.schema ?? parameter, importState); } function parameterExample(parameter: UnknownRecord, importState: ImportState): string { - const raw = rawParameterExample(parameter, importState); - // Simple/csv style, the default everywhere but query strings - const example = Array.isArray(raw) - ? raw.map(stringifyExampleValue).join(",") - : stringifyExampleValue(raw); - // An empty path segment makes a URL that matches nothing, so the name at - // least keeps the request sendable and shows what belongs there - if (example === "" && stringAt(parameter, "in") === "path") { - return stringAt(parameter, "name") ?? ""; - } - return example; + return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter); } function importBody({ @@ -975,14 +1134,21 @@ function importBody({ toArray(operation.consumes ?? spec.consumes).find( (c): c is string => typeof c === "string", ) ?? "application/json"; + const schema = importState.resolveSchema(bodyParameter.schema); + const isBinary = stringAt(schema, "format") === "binary"; return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType: yaakBodyType(contentType), - body: { - text: formatBodyText( - schemaToExample(importState.resolve(bodyParameter.schema), importState), - ), - }, + bodyType: isBinary ? "binary" : yaakBodyType(contentType), + body: isBinary + ? {} + : { + text: formatMediaTypeBody( + contentType, + schemaToExample(schema, importState), + schema, + importState, + ), + }, }; } @@ -1000,11 +1166,15 @@ function importBody({ headers: [{ enabled: true, name: "Content-Type", value: contentType }], bodyType: contentType, body: { - form: formParameters.map((p) => ({ - enabled: p.required === true, - name: stringAt(p, "name") ?? "", - value: parameterExample(p, importState), - })), + form: formParameters.map((p) => { + const base = { + enabled: p.required === true, + name: stringAt(p, "name") ?? "", + }; + return stringAt(p, "type") === "file" + ? { ...base, file: "" } + : { ...base, value: parameterExample(p, importState) }; + }), }, }; } @@ -1020,33 +1190,196 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord) const bodyType = yaakBodyType(contentType); if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") { + const example = mediaTypeExample(mediaType, importState); return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], bodyType, body: { - form: schemaToFormParameters(importState.resolve(mediaType.schema), importState), + form: schemaToFormParameters( + mediaType.schema, + importState, + isRecord(example) ? example : undefined, + ), }, }; } + const schema = importState.resolveSchema(mediaType.schema); + const isBinary = bodyType === "binary" || stringAt(schema, "format") === "binary"; + return { headers: [{ enabled: true, name: "Content-Type", value: contentType }], - bodyType, - body: - bodyType === "binary" - ? {} - : { text: formatBodyText(mediaTypeExample(mediaType, importState)) }, + bodyType: isBinary ? "binary" : bodyType, + body: isBinary + ? {} + : { + text: formatMediaTypeBody( + contentType, + mediaTypeExample(mediaType, importState), + schema, + importState, + ), + }, }; } function chooseContentType(contentTypes: string[]): string | null { + const jsonType = contentTypes.find((contentType) => mediaTypeOf(contentType).endsWith("+json")); for (const preference of BODY_CONTENT_TYPE_PREFERENCE) { const exact = contentTypes.find((c) => mediaTypeOf(c) === preference); if (exact != null) return exact; + // A +json suffix type ranks with JSON, ahead of the other preferences + if (preference === "application/json" && jsonType != null) return jsonType; } return contentTypes[0] ?? null; } +function formatMediaTypeBody( + contentType: string, + example: unknown, + schema: unknown, + importState: ImportState, +): string { + const mediaType = mediaTypeOf(contentType); + if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) { + return typeof example === "string" + ? example + : valueToXml(example, schema, importState, "root", true); + } + if (mediaType === "application/json" || mediaType.endsWith("+json")) { + // A string example may be pre-serialized JSON; otherwise it needs quoting + // to be a valid JSON document + if (typeof example === "string") { + try { + JSON.parse(example); + return example; + } catch { + return JSON.stringify(example); + } + } + return JSON.stringify(example, null, 2) ?? ""; + } + return formatBodyText(example); +} + +function valueToXml( + value: unknown, + schema: unknown, + importState: ImportState, + elementName: string, + isDocumentRoot = false, +): string { + const resolvedSchema = toRecord(importState.resolveSchema(schema)); + const schemaXml = toRecord(resolvedSchema.xml); + if (Array.isArray(value)) { + const itemSchema = importState.resolveSchema(resolvedSchema.items); + const shouldWrap = schemaXml.wrapped === true || isDocumentRoot; + const itemName = + stringAt(toRecord(itemSchema).xml, "name") ?? + (shouldWrap ? (stringAt(schemaXml, "name") ?? elementName) : elementName); + const items = value.map((item) => valueToXml(item, itemSchema, importState, itemName)).join(""); + return shouldWrap ? xmlElement(elementName, schemaXml, items) : items; + } + if (isRecord(value)) { + const properties = toRecord(resolvedSchema.properties); + const entries = Object.entries(value).map(([name, propertyValue]) => { + const propertySchema = toRecord(importState.resolveSchema(properties[name])); + return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) }; + }); + const usedPrefixes = new Set(["xml", "xmlns"]); + const prefixesByNamespace = new Map(); + for (const xml of [schemaXml, ...entries.map(({ xml }) => xml)]) { + const namespace = stringAt(xml, "namespace"); + const prefix = stringAt(xml, "prefix"); + if (prefix == null || prefix.length === 0) continue; + usedPrefixes.add(prefix); + if (namespace != null && namespace.length > 0 && !prefixesByNamespace.has(namespace)) { + prefixesByNamespace.set(namespace, prefix); + } + } + const attributes: string[] = []; + const attributeNamespaces: UnknownRecord[] = []; + const children: string[] = []; + for (const { name, propertyValue, propertySchema, xml } of entries) { + if (xml.attribute === true) { + const attributeXml = qualifyXmlAttribute(xml, usedPrefixes, prefixesByNamespace); + attributes.push( + `${qualifiedXmlName(name, attributeXml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`, + ); + attributeNamespaces.push(attributeXml); + } else { + children.push(valueToXml(propertyValue, propertySchema, importState, name)); + } + } + return xmlElement(elementName, schemaXml, children.join(""), attributes, attributeNamespaces); + } + return xmlElement(elementName, schemaXml, escapeXml(stringifyExampleValue(value))); +} + +function xmlElement( + fallbackName: string, + xml: UnknownRecord, + content: string, + attributes: string[] = [], + additionalNamespaces: UnknownRecord[] = [], +): string { + const name = qualifiedXmlName(fallbackName, xml); + const namespaces = new Map(); + for (const metadata of [xml, ...additionalNamespaces]) { + const namespace = stringAt(metadata, "namespace"); + if (namespace == null || namespace.length === 0) continue; + const prefix = stringAt(metadata, "prefix"); + namespaces.set(prefix == null || prefix.length === 0 ? "xmlns" : `xmlns:${prefix}`, namespace); + } + const namespaceAttributes = [...namespaces].map( + ([attribute, namespace]) => `${attribute}="${escapeXml(namespace)}"`, + ); + const attributeText = [...namespaceAttributes, ...attributes].join(" "); + const openingTag = attributeText.length > 0 ? `<${name} ${attributeText}>` : `<${name}>`; + return `${openingTag}${content}`; +} + +function qualifyXmlAttribute( + xml: UnknownRecord, + usedPrefixes: Set, + prefixesByNamespace: Map, +): UnknownRecord { + const namespace = stringAt(xml, "namespace"); + const declaredPrefix = stringAt(xml, "prefix"); + if (namespace != null && namespace.length === 0) { + const { prefix: _prefix, ...unqualifiedXml } = xml; + return unqualifiedXml; + } + if (namespace == null || (declaredPrefix != null && declaredPrefix.length > 0)) return xml; + + // Namespaced attributes need a prefix to be well-formed, so reuse the + // namespace's existing prefix or mint one + const existingPrefix = prefixesByNamespace.get(namespace); + if (existingPrefix != null) return { ...xml, prefix: existingPrefix }; + + let suffix = 1; + while (usedPrefixes.has(`ns${suffix}`)) suffix++; + const generatedPrefix = `ns${suffix}`; + usedPrefixes.add(generatedPrefix); + prefixesByNamespace.set(namespace, generatedPrefix); + return { ...xml, prefix: generatedPrefix }; +} + +function qualifiedXmlName(fallbackName: string, xml: UnknownRecord): string { + const name = stringAt(xml, "name") ?? fallbackName; + const prefix = stringAt(xml, "prefix"); + return prefix == null || prefix.length === 0 ? name : `${prefix}:${name}`; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + function mediaTypeOf(contentType: string): string { return contentType.toLowerCase().split(";")[0]?.trim() ?? ""; } @@ -1075,25 +1408,21 @@ function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): u firstExampleValue(mediaType.examples, importState), ); if (directExample != null) return directExample; - return schemaToExample(importState.resolve(mediaType.schema), importState); + return schemaToExample(mediaType.schema, importState); } -function schemaToFormParameters(schema: unknown, importState: ImportState) { - const resolvedSchema = toRecord(importState.resolve(schema)); - const sources = [ - ...toArray(resolvedSchema.allOf).map((s) => toRecord(importState.resolve(s))), - resolvedSchema, - ]; - const required = sources - .flatMap((s) => toArray(s.required)) - .filter((name): name is string => typeof name === "string"); - const properties = [ - ...new Map(sources.flatMap((s) => Object.entries(toRecord(s.properties)))).entries(), - ].slice(0, MAX_EXAMPLE_PROPERTIES); +function schemaToFormParameters(schema: unknown, importState: ImportState, example?: UnknownRecord) { + const resolvedSchema = toRecord(importState.resolveSchema(schema)); + const required = toArray(resolvedSchema.required).filter( + (name): name is string => typeof name === "string", + ); + const properties = Object.entries(toRecord(resolvedSchema.properties)) + .filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true) + .slice(0, MAX_EXAMPLE_PROPERTIES); return properties.map(([name, property]) => { - const resolvedProperty = toRecord(importState.resolve(property)); - const example = schemaToExample(resolvedProperty, importState); + const resolvedProperty = toRecord(importState.resolveSchema(property)); + const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState); const base = { enabled: required.includes(name), name, @@ -1101,7 +1430,7 @@ function schemaToFormParameters(schema: unknown, importState: ImportState) { if (stringAt(resolvedProperty, "format") === "binary") { return { ...base, file: "" }; } - return { ...base, value: stringifyExampleValue(example) }; + return { ...base, value: stringifyExampleValue(propertyExample) }; }); } @@ -1113,12 +1442,20 @@ function schemaToExample( ): unknown { if (depth > MAX_EXAMPLE_DEPTH) return {}; - const resolved = importState.resolve(schema, visitedRefs); + const schemaRecord = toRecord(schema); + const ref = stringAt(schemaRecord, "$ref"); + const closesReferenceCycle = ref != null && visitedRefs.has(ref); + const nextVisitedRefs = new Set(visitedRefs); + if (ref != null) nextVisitedRefs.add(ref); + + const resolved = importState.resolveSchema(schema, visitedRefs); if (!isRecord(resolved)) return ""; + if (closesReferenceCycle && Object.keys(resolved).length === 0) return {}; const explicitExample = firstPresent( resolved.example, firstExampleValue(resolved.examples, importState), + resolved.const, resolved.default, ); if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved); @@ -1126,34 +1463,78 @@ function schemaToExample( const enumValues = toArray(resolved.enum); if (enumValues.length > 0) return enumValues[0]; + const propertyExample = schemaPropertiesToExample(resolved, importState, depth, nextVisitedRefs); const allOf = toArray(resolved.allOf); if (allOf.length > 0) { - const merged = allOf.reduce((merged, childSchema) => { - const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs); - return isRecord(childExample) ? { ...merged, ...childExample } : merged; + const compositionExample = allOf.reduce((merged, childSchema) => { + const childExample = schemaToExample(childSchema, importState, depth + 1, nextVisitedRefs); + return isRecord(childExample) ? mergeExampleRecords(merged, childExample) : merged; }, {}); - // Sibling properties are their own constraint alongside the allOf branches - return { ...merged, ...objectPropertiesExample(resolved, importState, depth, visitedRefs) }; + return mergeExampleRecords(compositionExample, propertyExample); } const oneOf = toArray(resolved.oneOf); const anyOf = toArray(resolved.anyOf); if (oneOf.length > 0 || anyOf.length > 0) { - return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs); + const compositionExample = schemaToExample( + oneOf[0] ?? anyOf[0], + importState, + depth + 1, + nextVisitedRefs, + ); + return Object.keys(propertyExample).length > 0 + ? mergeExampleRecords(isRecord(compositionExample) ? compositionExample : {}, propertyExample) + : compositionExample; } const type = inferSchemaType(resolved); if (type === "array") { - return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)]; - } - if (type === "object") { - return objectPropertiesExample(resolved, importState, depth, visitedRefs); + return [schemaToExample(resolved.items, importState, depth + 1, nextVisitedRefs)]; } + if (type === "object") return propertyExample; if (type === "integer" || type === "number") return 0; if (type === "boolean") return false; return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? ""; } +/** Request examples omit readOnly properties, which only appear in responses */ +function schemaPropertiesToExample( + schema: UnknownRecord, + importState: ImportState, + depth: number, + visitedRefs: Set, +): UnknownRecord { + const required = toArray(schema.required).filter( + (name): name is string => typeof name === "string", + ); + const properties = Object.entries(toRecord(schema.properties)) + .filter(([, property]) => toRecord(importState.resolveSchema(property)).readOnly !== true) + .sort(([a], [b]) => { + const aRequired = required.includes(a); + const bRequired = required.includes(b); + return aRequired === bRequired ? 0 : aRequired ? -1 : 1; + }); + + return Object.fromEntries( + properties + .slice(0, MAX_EXAMPLE_PROPERTIES) + .map(([name, property]) => [ + name, + schemaToExample(property, importState, depth + 1, visitedRefs), + ]), + ); +} + +function mergeExampleRecords(base: UnknownRecord, overlay: UnknownRecord): UnknownRecord { + const merged = { ...base }; + for (const [name, value] of Object.entries(overlay)) { + const baseValue = merged[name]; + merged[name] = + isRecord(baseValue) && isRecord(value) ? mergeExampleRecords(baseValue, value) : value; + } + return merged; +} + const FORMAT_EXAMPLES: Record = { "date-time": "2026-01-01T00:00:00Z", date: "2026-01-01", @@ -1194,30 +1575,6 @@ function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown return example; } -function objectPropertiesExample( - schema: UnknownRecord, - importState: ImportState, - depth: number, - visitedRefs: Set, -): UnknownRecord { - const required = toArray(schema.required).filter( - (name): name is string => typeof name === "string", - ); - const properties = Object.entries(toRecord(schema.properties)).sort(([a], [b]) => { - const aRequired = required.includes(a); - const bRequired = required.includes(b); - return aRequired === bRequired ? 0 : aRequired ? -1 : 1; - }); - - return Object.fromEntries( - properties - .slice(0, MAX_EXAMPLE_PROPERTIES) - .map(([name, property]) => [ - name, - schemaToExample(property, importState, depth + 1, visitedRefs), - ]), - ); -} function inferSchemaType(schema: UnknownRecord): string { const rawType = schema.type; @@ -1618,12 +1975,16 @@ function buildOAuthVariablesByScheme( ); } +/** Earlier groups win on a name collision; Cookie rows all pass through since the send path merges them */ function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] { const headers: HttpRequestHeader[] = []; - for (const header of headerGroups.flat()) { - const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase()); - if (existing == null) { - headers.push(header); + for (const group of headerGroups) { + const namesFromEarlierGroups = new Set(headers.map((header) => header.name.toLowerCase())); + for (const header of group) { + const name = header.name.toLowerCase(); + if (name === "cookie" || !namesFromEarlierGroups.has(name)) { + headers.push(header); + } } } return headers; @@ -1733,7 +2094,120 @@ class ImportState { return value; } - const resolved = value.$ref + return this.resolve(this.#resolveLocalReference(value.$ref), nextVisitedRefs); + } + + /** Schema Objects allow `$ref` siblings in OpenAPI 3.1 and later. */ + resolveSchema(value: unknown, visitedRefs = new Set(), compositionDepth = 0): unknown { + if (!isRecord(value)) return value; + + let resolved: unknown = value; + const structureVisitedRefs = new Set(visitedRefs); + const siblingLayers: UnknownRecord[] = []; + while (isRecord(resolved) && typeof resolved.$ref === "string") { + const ref = resolved.$ref; + const siblings = Object.fromEntries( + Object.entries(resolved).filter(([key]) => key !== "$ref"), + ); + if (structureVisitedRefs.has(ref)) { + resolved = siblings; + break; + } + if (!ref.startsWith("#/")) { + this.#unresolvedRefs.add(ref); + break; + } + + structureVisitedRefs.add(ref); + siblingLayers.push(siblings); + resolved = this.#resolveLocalReference(ref); + } + if (!isRecord(resolved)) return resolved; + + let merged: UnknownRecord = resolved; + for (let index = siblingLayers.length - 1; index >= 0; index--) { + merged = this.#mergeSchemaObjects(merged, siblingLayers[index] ?? {}); + } + return this.#mergeAllOfStructure(merged, structureVisitedRefs, compositionDepth); + } + + #mergeAllOfStructure( + schema: UnknownRecord, + visitedRefs: Set, + depth: number, + ): UnknownRecord { + if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return schema; + const allOf = toArray(schema.allOf); + if (allOf.length === 0) return schema; + + const composed = allOf.reduce((merged, childSchema) => { + const child = this.resolveSchema(childSchema, new Set(visitedRefs), depth + 1); + if (!isRecord(child)) return merged; + const childStructure = Object.fromEntries( + Object.entries(child).filter(([key]) => key !== "allOf"), + ); + return this.#mergeSchemaObjects(merged, childStructure); + }, {}); + return this.#mergeSchemaObjects(composed, schema); + } + + #mergeSchemaObjects(base: UnknownRecord, overlay: UnknownRecord, depth = 0): UnknownRecord { + const merged: UnknownRecord = { ...base, ...overlay }; + if (depth > MAX_SCHEMA_RESOLUTION_DEPTH) return merged; + + const baseProperties = toRecord(base.properties); + const overlayProperties = toRecord(overlay.properties); + const propertyNames = new Set([ + ...Object.keys(baseProperties), + ...Object.keys(overlayProperties), + ]); + if (propertyNames.size > 0) { + merged.properties = Object.fromEntries( + [...propertyNames].map((name) => { + const baseProperty = baseProperties[name]; + const overlayProperty = overlayProperties[name]; + if (isRecord(baseProperty) && isRecord(overlayProperty)) { + return [name, this.#mergeSchemaObjects(baseProperty, overlayProperty, depth + 1)]; + } + return [name, overlayProperty ?? baseProperty]; + }), + ); + } + + const baseRequired = toArray(base.required).filter( + (name): name is string => typeof name === "string", + ); + const overlayRequired = toArray(overlay.required).filter( + (name): name is string => typeof name === "string", + ); + if (baseRequired.length > 0 || overlayRequired.length > 0) { + merged.required = [...new Set([...baseRequired, ...overlayRequired])]; + } + + const baseAllOf = toArray(base.allOf); + const overlayAllOf = toArray(overlay.allOf); + if (baseAllOf.length > 0 && overlayAllOf.length > 0) { + merged.allOf = [...baseAllOf, ...overlayAllOf]; + } + if (isRecord(base.xml) && isRecord(overlay.xml)) { + merged.xml = { ...base.xml, ...overlay.xml }; + } + if (isRecord(base.items) && isRecord(overlay.items)) { + merged.items = this.#mergeSchemaObjects(base.items, overlay.items, depth + 1); + } + if (isRecord(base.additionalProperties) && isRecord(overlay.additionalProperties)) { + merged.additionalProperties = this.#mergeSchemaObjects( + base.additionalProperties, + overlay.additionalProperties, + depth + 1, + ); + } + + return merged; + } + + #resolveLocalReference(ref: string): unknown { + return ref .slice(2) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) @@ -1742,7 +2216,5 @@ class ImportState { Array.isArray(current) ? current[Number(part)] : toRecord(current)[part], this.#spec, ); - - return this.resolve(resolved, nextVisitedRefs); } } diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index a6399036..1aca27f9 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -1219,7 +1219,8 @@ describe("importer-openapi", () => { ); expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}"); - expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: "Hi {{name}}" }); + // Quoted to be a valid JSON document, braces intact + expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: '"Hi {{name}}"' }); }); test("Ignores header parameters the spec reserves for other mechanisms", async () => { @@ -1269,8 +1270,10 @@ describe("importer-openapi", () => { }), ); + // One row per cookie so each stays toggleable; the send path merges them expect(imported?.resources.httpRequests[0]?.headers).toEqual([ - { enabled: true, name: "Cookie", value: "session=abc; theme=dark" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "theme=dark" }, ]); }); @@ -1456,6 +1459,864 @@ describe("importer-openapi", () => { ]); }); + test("Imports OpenAPI 3.1 schema reference siblings and examples", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Reference Examples", version: "1.0.0" }, + paths: { + "/sibling": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Message", + example: { text: "overridden by sibling" }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/example-ref": { + post: { + requestBody: { + content: { + "application/json": { + examples: { sample: { $ref: "#/components/examples/Message" } }, + }, + }, + }, + responses: {}, + }, + }, + "/schema-values": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + fromExamples: { type: "string", examples: ["first", "second"] }, + fromConst: { const: "fixed" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/sibling-form": { + post: { + requestBody: { + content: { + "multipart/form-data": { + schema: { + $ref: "#/components/schemas/MessageForm", + required: ["extra"], + properties: { extra: { type: "string", default: "sibling" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Message: { type: "object", properties: { text: { default: "base" } } }, + MessageForm: { + $ref: "#/components/schemas/BaseMessageForm", + required: ["middle"], + properties: { + middle: { type: "string", default: "intermediate" }, + optional: { type: "string", default: "optional" }, + }, + }, + BaseMessageForm: { + type: "object", + required: ["base"], + properties: { base: { type: "string", default: "referenced" } }, + }, + }, + examples: { + Message: { value: { text: "resolved example" } }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([ + { text: JSON.stringify({ text: "overridden by sibling" }, null, 2) }, + { text: JSON.stringify({ text: "resolved example" }, null, 2) }, + { text: JSON.stringify({ fromExamples: "first", fromConst: "fixed" }, null, 2) }, + { + form: [ + { enabled: true, name: "base", value: "referenced" }, + { enabled: true, name: "middle", value: "intermediate" }, + { enabled: false, name: "optional", value: "optional" }, + { enabled: true, name: "extra", value: "sibling" }, + ], + }, + ]); + }); + + test("Merges colliding and composed schema properties", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Composed Schema Examples", version: "1.0.0" }, + paths: { + "/colliding-property": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + $ref: "#/components/schemas/BasePayload", + properties: { + shared: { + xml: { name: "renamed" }, + properties: { + local: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/composition-siblings": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + allOf: [ + { + type: "object", + properties: { + shared: { + type: "object", + properties: { + fromBranch: { type: "string", default: "branch" }, + }, + }, + branchOnly: { type: "string", default: "branch" }, + }, + }, + ], + properties: { + shared: { + type: "object", + properties: { + fromSibling: { type: "string", default: "sibling" }, + }, + }, + siblingOnly: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/composition-form": { + post: { + requestBody: { + content: { + "multipart/form-data": { + schema: { + $ref: "#/components/schemas/ComposedForm", + required: ["siblingField"], + properties: { + siblingField: { type: "string", default: "sibling" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Shared: { + type: "object", + xml: { namespace: "urn:shared", prefix: "s" }, + properties: { + inherited: { type: "string", default: "base" }, + }, + }, + BasePayload: { + type: "object", + xml: { name: "payload" }, + properties: { + shared: { + $ref: "#/components/schemas/Shared", + xml: { name: "base-shared" }, + }, + }, + }, + ComposedForm: { + allOf: [ + { + type: "object", + required: ["baseField"], + properties: { + baseField: { type: "string", default: "base" }, + }, + }, + ], + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests.map((request) => request.body)).toEqual([ + { + text: + '' + + "basesibling" + + "", + }, + { + text: JSON.stringify( + { + shared: { fromBranch: "branch", fromSibling: "sibling" }, + branchOnly: "branch", + siblingOnly: "sibling", + }, + null, + 2, + ), + }, + { + form: [ + { enabled: true, name: "baseField", value: "base" }, + { enabled: true, name: "siblingField", value: "sibling" }, + ], + }, + ]); + }); + + test("Stops circular schema references when generating examples", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Circular References", version: "1.0.0" }, + paths: { + "/nodes": { + post: { + requestBody: { + content: { + "application/json": { schema: { $ref: "#/components/schemas/Node" } }, + }, + }, + responses: {}, + }, + }, + }, + components: { + schemas: { + Node: { + type: "object", + properties: { + name: { type: "string", example: "root" }, + child: { + $ref: "#/components/schemas/Node", + required: ["relationship"], + properties: { + relationship: { type: "string", example: "nested" }, + }, + }, + }, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ name: "root", child: { relationship: "nested" } }, null, 2), + }); + }); + + test("Bounds deeply colliding schema merges", async () => { + const depth = 12_000; + const nestedSchema = (leaf: string, levels: number) => + '{"type":"object","properties":{"next":'.repeat(levels) + leaf + "}}".repeat(levels); + const baseSchema = nestedSchema('{"type":"string","default":"base"}', depth); + const siblingProperty = nestedSchema('{"type":"string","example":"sibling"}', depth - 1); + + const imported = await convertOpenApi( + '{"openapi":"3.1.0","info":{"title":"Deep Merge","version":"1.0.0"},' + + '"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":' + + '{"schema":{"$ref":"#/components/schemas/DeepBase","properties":{"next":' + + siblingProperty + + '}}}}},"responses":{}}}},"components":{"schemas":{"DeepBase":' + + baseSchema + + "}}}", + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify( + { + next: { + next: { + next: { + next: { + next: { + next: { + next: { + next: { + next: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + null, + 2, + ), + }); + }); + + test("Bounds deeply nested inline allOf schemas", async () => { + const depth = 12_000; + const schema = + '{"allOf":['.repeat(depth) + '{"type":"string","example":"leaf"}' + "]}".repeat(depth); + const imported = await convertOpenApi( + '{"openapi":"3.1.0","info":{"title":"Deep allOf","version":"1.0.0"},' + + '"paths":{"/deep":{"post":{"requestBody":{"content":{"application/json":{"schema":' + + schema + + '}}},"responses":{}}}}}', + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({}, null, 2), + }); + }); + + test("Resolves long local reference chains without truncating schemas", async () => { + const depth = 12_000; + const schemas: Record = { + [`Ref${depth}`]: { + type: "object", + properties: { target: { type: "string", example: "reached" } }, + }, + }; + for (let index = depth - 1; index >= 0; index--) { + schemas[`Ref${index}`] = { + $ref: `#/components/schemas/Ref${index + 1}`, + ...(index === 1 ? { properties: { middle: { type: "string", example: "sibling" } } } : {}), + }; + } + + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Long Reference Chain", version: "1.0.0" }, + paths: { + "/long-ref": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Ref0", + properties: { outer: { type: "string", example: "request" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + components: { schemas }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ target: "reached", middle: "sibling", outer: "request" }, null, 2), + }); + }); + + test("Imports cookie and content-based parameters", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Parameter Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + parameters: [ + { + name: "session", + in: "cookie", + required: true, + schema: { type: "string", example: "abc" }, + }, + { + name: "debug", + in: "cookie", + schema: { type: "string", example: "verbose" }, + }, + { + name: "prefs", + in: "cookie", + required: true, + schema: { type: "object", example: { theme: "dark", lang: "en" } }, + }, + { + name: "colors", + in: "cookie", + required: true, + explode: false, + schema: { type: "array", example: ["red", "blue"] }, + }, + { + name: "X-Filter", + in: "header", + required: true, + content: { "text/plain": { example: "active" } }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.headers).toEqual([ + { enabled: true, name: "X-Filter", value: "active" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "debug=verbose" }, + // Cookie pairs separate with "; ", never "&" + { enabled: true, name: "Cookie", value: "theme=dark; lang=en" }, + { enabled: true, name: "Cookie", value: "colors=red,blue" }, + ]); + }); + + test("Preserves parameter cookies alongside cookie API-key authentication", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Authenticated Cookie Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + security: [{ basicAuth: [], cookieKey: [] }], + parameters: [ + { + name: "session", + in: "cookie", + required: true, + schema: { type: "string", example: "abc" }, + }, + { + name: "debug", + in: "cookie", + schema: { type: "string", example: "verbose" }, + }, + ], + responses: {}, + }, + }, + }, + components: { + securitySchemes: { + basicAuth: { type: "http", scheme: "basic" }, + cookieKey: { type: "apiKey", in: "cookie", name: "api_key" }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]).toEqual( + expect.objectContaining({ + authenticationType: "basic", + headers: [ + { enabled: true, name: "Cookie", value: "api_key=${[auth_cookie_key_key]}" }, + { enabled: true, name: "Cookie", value: "session=abc" }, + { enabled: false, name: "Cookie", value: "debug=verbose" }, + ], + }), + ); + }); + + test("Serializes structured query parameters according to style and explode", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Serialization Test", version: "1.0.0" }, + paths: { + "/items": { + get: { + parameters: [ + { + name: "filter", + in: "query", + required: true, + style: "deepObject", + explode: true, + schema: { + type: "object", + properties: { + role: { example: "admin" }, + active: { example: true }, + }, + }, + }, + { + name: "tags", + in: "query", + style: "form", + explode: true, + schema: { type: "array", example: ["one", "two"] }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ + { enabled: true, name: "filter[role]", value: "admin" }, + { enabled: true, name: "filter[active]", value: "true" }, + { enabled: false, name: "tags", value: "one" }, + { enabled: false, name: "tags", value: "two" }, + ]); + }); + + test("Emits executable label and matrix path serializations", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Path Serialization Test", version: "1.0.0" }, + paths: { + "/labels/{labels}/matrix/{coordinates}/scalar/{color}/report.{format}": { + get: { + parameters: [ + { + name: "labels", + in: "path", + required: true, + style: "label", + explode: true, + schema: { type: "array", example: ["one/two", "three"] }, + }, + { + name: "coordinates", + in: "path", + required: true, + style: "matrix", + explode: true, + schema: { type: "object", example: { x: "1;spoof=2", y: 2 } }, + }, + { + name: "format", + in: "path", + required: true, + schema: { type: "string", example: "json/evil" }, + }, + { + name: "color", + in: "path", + required: true, + style: "label", + schema: { type: "string", example: "blue" }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]).toEqual( + expect.objectContaining({ + url: "${[baseUrl]}/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2/scalar/.blue/report.json%2Fevil", + urlParameters: [], + }), + ); + }); + + test("Serializes request examples according to their media type", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Media Type Test", version: "1.0.0" }, + paths: { + "/xml": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + type: "object", + xml: { name: "user" }, + properties: { name: { type: "string", example: "Ada" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/json-string": { + post: { + requestBody: { + content: { "application/json": { schema: { type: "string", example: "hello" } } }, + }, + responses: {}, + }, + }, + "/json-preserialized": { + post: { + requestBody: { + content: { + "application/json": { + schema: { type: "string", example: '{"already": "json"}' }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: "Ada", + }); + expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml"); + expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' }); + expect(imported?.resources.httpRequests[2]?.body).toEqual({ text: '{"already": "json"}' }); + }); + + test("Honors XML array wrapping and namespaces", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "XML Metadata Test", version: "1.0.0" }, + paths: { + "/catalog": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + type: "object", + xml: { name: "catalog", namespace: "urn:catalog", prefix: "c" }, + properties: { + id: { + type: "string", + example: "42", + xml: { attribute: true, namespace: "urn:metadata", prefix: "m" }, + }, + externalId: { + type: "string", + example: "external", + xml: { + attribute: true, + name: "external-id", + namespace: "urn:external", + prefix: "ns1", + }, + }, + tenant: { + type: "string", + example: "acme", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + region: { + type: "string", + example: "west", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + legacy: { + type: "string", + example: "plain", + xml: { attribute: true, namespace: "", prefix: "unbound" }, + }, + tags: { + type: "array", + example: ["one", "two"], + xml: { + name: "tags", + namespace: "urn:tags", + prefix: "t", + wrapped: true, + }, + items: { type: "string", xml: { name: "tag" } }, + }, + aliases: { + type: "array", + example: ["Ada", "A"], + xml: { name: "ignored", wrapped: false }, + items: { + type: "string", + xml: { name: "alias", namespace: "urn:aliases", prefix: "a" }, + }, + }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + "/values": { + post: { + requestBody: { + content: { + "application/xml": { + schema: { + type: "array", + example: ["one", "two"], + xml: { name: "values", wrapped: false }, + items: { type: "string", xml: { name: "value" } }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: + '' + + 'onetwo' + + 'Ada' + + 'A' + + "", + }); + expect(imported?.resources.httpRequests[1]?.body).toEqual({ + text: "onetwo", + }); + }); + + test("Omits read-only properties from generated request bodies", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + openapi: "3.0.4", + info: { title: "Read Only Test", version: "1.0.0" }, + paths: { + "/users": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "string", readOnly: true, example: "server-id" }, + name: { type: "string", example: "Ada" }, + }, + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: JSON.stringify({ name: "Ada" }, null, 2), + }); + }); + + test("Imports Swagger 2 file parameters as file form entries", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + swagger: "2.0", + info: { title: "File Upload Test", version: "1.0.0" }, + host: "example.com", + consumes: ["multipart/form-data"], + paths: { + "/upload": { + post: { + parameters: [{ name: "upload", in: "formData", required: true, type: "file" }], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + form: [{ enabled: true, name: "upload", file: "" }], + }); + }); + + test("Serializes Swagger 2 XML request bodies as XML", async () => { + const imported = await convertOpenApi( + JSON.stringify({ + swagger: "2.0", + info: { title: "Swagger XML Test", version: "1.0.0" }, + host: "example.com", + consumes: ["application/xml"], + paths: { + "/users": { + post: { + parameters: [ + { + name: "user", + in: "body", + required: true, + schema: { + type: "object", + xml: { name: "user" }, + properties: { name: { type: "string", example: "Ada" } }, + }, + }, + ], + responses: {}, + }, + }, + }, + }), + ); + + expect(imported?.resources.httpRequests[0]?.body).toEqual({ + text: "Ada", + }); + expect(imported?.resources.httpRequests[0]?.bodyType).toBe("text/xml"); + }); + test("Reports references that point outside the document", async () => { const imported = await convertOpenApi( JSON.stringify({