mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-20 02:13:58 +02:00
fix(openapi): preserve serialized parameter behavior
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user