diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index d7931684..fbd68232 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -494,7 +494,55 @@ 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_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/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 08de53f3..5ac84b84 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,56 @@ 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); + return ( + stringAt(parameter, "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,21 +783,24 @@ 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") - .flatMap((p) => serializeUrlParameter(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"); @@ -763,6 +816,7 @@ function serializeUrlParameter( ]; } if (location === "path") { + if (shouldInlinePathParameter(parameter, importState, path)) return []; return [{ enabled, name: `:${name}`, value: serializePathParameter(name, value, parameter) }]; } @@ -832,24 +886,39 @@ function importHeaderParameters({ } function importCookieHeader(parameters: unknown[], importState: ImportState): HttpRequestHeader[] { - const cookies = parameters + return parameters .map((p) => importState.resolve(p)) .filter(isRecord) .filter((p) => stringAt(p, "in") === "cookie") .map((p) => ({ enabled: p.required === true, - name: stringAt(p, "name") ?? "", - value: serializeParameterValue(p, importState), - })) - .filter(({ name }) => name.length > 0); - if (cookies.length === 0) return []; - return [ - { - enabled: cookies.some(({ enabled }) => enabled), name: "Cookie", - value: cookies.map(({ name, value }) => `${name}=${value}`).join("; "), - }, - ]; + 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 { @@ -865,49 +934,60 @@ function serializeContentParameter(parameter: UnknownRecord, importState: Import : stringifyExampleValue(value); } -function serializePathParameter(name: string, value: unknown, parameter: UnknownRecord): string { +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(stringifyExampleValue) + ? value.map(serializeValue) : isRecord(value) ? Object.entries(value).flatMap(([key, entryValue]) => [ - key, - stringifyExampleValue(entryValue), + serializeValue(key), + serializeValue(entryValue), ]) - : [stringifyExampleValue(value)]; + : [serializeValue(value)]; if (style === "label") { if (explode && isRecord(value)) { return `.${Object.entries(value) - .map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`) + .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}=${stringifyExampleValue(entryValue)}`).join(""); + return value.map((entryValue) => `;${name}=${serializeValue(entryValue)}`).join(""); } if (explode && isRecord(value)) { return Object.entries(value) - .map(([key, entryValue]) => `;${key}=${stringifyExampleValue(entryValue)}`) + .map(([key, entryValue]) => `;${serializeValue(key)}=${serializeValue(entryValue)}`) .join(""); } return `;${name}=${values.join(",")}`; } - return serializeSimpleParameter(value, parameter); + return serializeSimpleParameter(value, parameter, serializeValue); } -function serializeSimpleParameter(value: unknown, parameter: UnknownRecord): string { - if (Array.isArray(value)) return value.map(stringifyExampleValue).join(","); +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]) => `${key}=${stringifyExampleValue(entryValue)}`).join(",") - : entries.flat().map(stringifyExampleValue).join(","); + ? entries + .map(([key, entryValue]) => `${serializeValue(key)}=${serializeValue(entryValue)}`) + .join(",") + : entries.flat().map(serializeValue).join(","); } - return stringifyExampleValue(value); + return serializeValue(value); } function parameterExample(parameter: UnknownRecord, importState: ImportState): string { @@ -1521,10 +1601,12 @@ 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) { + if (!namesFromEarlierGroups.has(header.name.toLowerCase())) { + 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 850b1edf..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", }, { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index 86eae6fc..d48ae3a2 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -781,6 +781,11 @@ describe("importer-openapi", () => { required: true, schema: { type: "string", example: "abc" }, }, + { + name: "debug", + in: "cookie", + schema: { type: "string", example: "verbose" }, + }, { name: "X-Filter", in: "header", @@ -798,6 +803,7 @@ describe("importer-openapi", () => { 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" }, ]); }); @@ -847,13 +853,13 @@ describe("importer-openapi", () => { ]); }); - test("Serializes label and matrix path parameters", async () => { + 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}": { + "/labels/{labels}/matrix/{coordinates}/report.{format}": { get: { parameters: [ { @@ -862,7 +868,7 @@ describe("importer-openapi", () => { required: true, style: "label", explode: true, - schema: { type: "array", example: ["one", "two"] }, + schema: { type: "array", example: ["one/two", "three"] }, }, { name: "coordinates", @@ -870,7 +876,13 @@ describe("importer-openapi", () => { required: true, style: "matrix", explode: true, - schema: { type: "object", example: { x: 1, y: 2 } }, + schema: { type: "object", example: { x: "1;spoof=2", y: 2 } }, + }, + { + name: "format", + in: "path", + required: true, + schema: { type: "string", example: "json/evil" }, }, ], responses: {}, @@ -880,10 +892,12 @@ describe("importer-openapi", () => { }), ); - expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([ - { enabled: true, name: ":labels", value: ".one.two" }, - { enabled: true, name: ":coordinates", value: ";x=1;y=2" }, - ]); + expect(imported?.resources.httpRequests[0]).toEqual( + expect.objectContaining({ + url: "${[baseUrl]}/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2/report.json%2Fevil", + urlParameters: [], + }), + ); }); test("Prefers operation-level consumes for Swagger bodies", async () => {