diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index d7931684..a9d85397 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 { @@ -494,7 +505,76 @@ 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())]); + } + + #[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 920c0288..8253022f 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -302,7 +302,7 @@ function importOperation({ useDynamicServerUrls, }); const urlParameters = [ - ...importUrlParameters({ importState, parameters }), + ...importUrlParameters({ importState, parameters, path }), ...authentication.urlParameters, ]; const headers = mergeHeaders( @@ -336,6 +336,8 @@ function importOperation({ url: buildOperationUrl( operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }), path, + parameters, + importState, ), urlParameters, headers, @@ -645,8 +647,58 @@ function findOrCreateFolderId({ return folder.id; } -function buildOperationUrl(baseUrl: string, path: string): string { - return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1")); +function buildOperationUrl( + baseUrl: string, + path: string, + 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); + serializedPath = serializedPath.replaceAll( + `{${name}}`, + isRecord(parameter.content) + ? encodePathComponent(serializeContentParameter(parameter, importState)) + : serializePathParameter(name, value, parameter, encodePathComponent), + ); + } + return joinUrlParts(baseUrl, serializedPath.replaceAll(/{([^}/]+)}/g, ":$1")); +} + +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)); + if (matchingSegments.length === 0 || matchingSegments.some((segment) => segment !== template)) { + 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 { @@ -733,25 +785,82 @@ function trimTrailingSlashes(value: string): string { function importUrlParameters({ importState, parameters, + path, }: { importState: ImportState; parameters: unknown[]; + path: string; }): HttpUrlParameter[] { return parameters .map((p) => importState.resolve(p)) .filter(isRecord) .filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path") - .map((p) => ({ - enabled: p.required === true, - name: - stringAt(p, "in") === "path" - ? `:${stringAt(p, "name") ?? ""}` - : (stringAt(p, "name") ?? ""), - value: parameterExample(p, importState), - })) + .flatMap((p) => serializeUrlParameter(p, importState, path)) .filter(({ name }) => name.length > 0); } +function serializeUrlParameter( + parameter: UnknownRecord, + importState: ImportState, + path: string, +): HttpUrlParameter[] { + const name = stringAt(parameter, "name") ?? ""; + const location = stringAt(parameter, "in"); + const enabled = parameter.required === true; + 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 []; + return [{ enabled, name: `:${name}`, value: serializePathParameter(name, value, parameter) }]; + } + + 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 style = stringAt(parameter, "style") ?? "form"; + const explode = parameter.explode !== false; + if (style === "form" && explode) { + return value.map((entryValue) => ({ + enabled, + name, + value: stringifyExampleValue(entryValue), + })); + } + const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }]; + } + + return [{ enabled, name, value: stringifyExampleValue(value) }]; +} + function importHeaderParameters({ importState, parameters, @@ -763,18 +872,138 @@ function importHeaderParameters({ .map((p) => importState.resolve(p)) .filter(isRecord) .filter((p) => stringAt(p, "in") === "header") + .filter( + (p) => + !["accept", "authorization", "content-type"].includes( + (stringAt(p, "name") ?? "").toLowerCase(), + ), + ) .map((p) => ({ enabled: p.required === true, name: stringAt(p, "name") ?? "", - value: parameterExample(p, importState), + value: serializeParameterValue(p, importState), })) - .filter(({ name }) => name.length > 0); + .filter(({ name }) => name.length > 0) + .concat(importCookieHeader(parameters, importState)); +} + +function importCookieHeader(parameters: unknown[], importState: ImportState): HttpRequestHeader[] { + return parameters + .map((p) => importState.resolve(p)) + .filter(isRecord) + .filter((p) => stringAt(p, "in") === "cookie") + .map((p) => ({ + enabled: p.required === true, + name: "Cookie", + value: serializeCookieParameter(p, importState), + })) + .filter(({ value }) => value.length > 0); +} + +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; + 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); +} + +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 parameterExample(parameter: UnknownRecord, importState: ImportState): string { + return stringifyExampleValue(parameterExampleValue(parameter, importState)); +} + +function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown { const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples)); - if (directExample != null) return stringifyExampleValue(directExample); - return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState)); + if (directExample != null) return directExample; + if (isRecord(parameter.content)) { + const mediaType = toRecord(Object.values(parameter.content)[0]); + return mediaTypeExample(mediaType, importState); + } + return schemaToExample(importState.resolve(parameter.schema), importState); } function importBody({ @@ -1374,10 +1603,13 @@ function buildOAuthVariablesByScheme( 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; diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap index 86a2eff5..121a8bee 100644 --- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap +++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap @@ -163,18 +163,13 @@ Responses: "model": "http_request", "name": "Retrieve one version of a particular API", "sortPriority": 5, - "url": "\${[baseUrl]}/specs/:provider/:api.json", + "url": "\${[baseUrl]}/specs/:provider/2.1.0.json", "urlParameters": [ { "enabled": true, "name": ":provider", "value": "apis.guru", }, - { - "enabled": true, - "name": ":api", - "value": "2.1.0", - }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, @@ -207,7 +202,7 @@ Responses: "model": "http_request", "name": "Retrieve one version of a particular API with a serviceName.", "sortPriority": 6, - "url": "\${[baseUrl]}/specs/:provider/:service/:api.json", + "url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json", "urlParameters": [ { "enabled": true, @@ -219,11 +214,6 @@ Responses: "name": ":service", "value": "graph", }, - { - "enabled": true, - "name": ":api", - "value": "2.1.0", - }, ], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, @@ -256,14 +246,8 @@ Responses: "model": "http_request", "name": "List all APIs for a particular provider", "sortPriority": 7, - "url": "\${[baseUrl]}/:provider.json", - "urlParameters": [ - { - "enabled": true, - "name": ":provider", - "value": "apis.guru", - }, - ], + "url": "\${[baseUrl]}/apis.guru.json", + "urlParameters": [], "workspaceId": "GENERATE_ID::WORKSPACE_0", }, { @@ -860,13 +844,7 @@ Responses: - 200: Sucessful authentication. - 401: Unsuccessful authentication.", "folderId": "GENERATE_ID::FOLDER_1", - "headers": [ - { - "enabled": false, - "name": "Authorization", - "value": "", - }, - ], + "headers": [], "id": "GENERATE_ID::HTTP_REQUEST_15", "method": "GET", "model": "http_request", diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index deabbfba..8043eb62 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -766,6 +766,194 @@ describe("importer-openapi", () => { ]); }); + 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: "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" }, + ]); + }); + + 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("Prefers operation-level consumes for Swagger bodies", async () => { const imported = await convertOpenApi( JSON.stringify({