diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecf835e9..dd4f9082 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,3 +31,7 @@ jobs:
run: vp test
- name: Run Rust Tests
run: cargo test --all --features yaak-app-client/wry
+ - name: OpenAPI import round-trip
+ run: |
+ cargo build -p yaak-cli
+ node plugins/importer-openapi/tests/roundtrip.mjs
diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx b/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx
new file mode 100644
index 00000000..e3df58c4
--- /dev/null
+++ b/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, test, vi } from "vite-plus/test";
+import { CsvViewerInner } from "./CsvViewer";
+
+vi.mock("@yaakapp-internal/ui", () => ({
+ Table: ({ children }: { children: ReactNode }) =>
,
+ TableBody: ({ children }: { children: ReactNode }) => {children},
+ TableCell: ({ children }: { children: ReactNode }) => {children} | ,
+ TableHead: ({ children }: { children: ReactNode }) => {children},
+ TableHeaderCell: ({ children }: { children: ReactNode }) => {children} | ,
+ TableRow: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+describe("CsvViewer", () => {
+ test("renders columns that extend beyond the first row", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("ID de usuario");
+ expect(markup).toContain("42041");
+ expect(markup.match(//g)).toHaveLength(20);
+ });
+});
diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.tsx b/apps/yaak-client/components/responseViewers/CsvViewer.tsx
index fe94480d..f318a437 100644
--- a/apps/yaak-client/components/responseViewers/CsvViewer.tsx
+++ b/apps/yaak-client/components/responseViewers/CsvViewer.tsx
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (text == null) return null;
- return Papa.parse>(text, { header: true, skipEmptyLines: true });
+ return Papa.parse(text, { skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
+ const header = parsed.data[0] ?? [];
+ const rows = parsed.data.slice(1);
+ const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
+ const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
+
return (
- {parsed.meta.fields?.map((field) => (
- {field}
+ {columnIndexes.map((columnIndex) => (
+ {header[columnIndex] ?? ""}
))}
- {parsed.data.map((row, i) => (
+ {rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
- {parsed.meta.fields?.map((key) => (
- {row[key] ?? ""}
+ {row.map((cell, columnIndex) => (
+ // oxlint-disable-next-line react/no-array-index-key
+ {cell}
))}
))}
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/action-copy-curl/src/index.ts b/plugins/action-copy-curl/src/index.ts
index 4f02afa7..1efbaf3c 100644
--- a/plugins/action-copy-curl/src/index.ts
+++ b/plugins/action-copy-curl/src/index.ts
@@ -83,7 +83,9 @@ export async function convertToCurl(request: Partial) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : "";
- xs.push(flag, v);
+ // A bare `;` separates commands and a path can hold spaces, so this
+ // argument needs quoting like every other one.
+ xs.push(flag, quote(v));
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
@@ -157,7 +159,10 @@ export async function convertToCurl(request: Partial) {
}
function quote(arg: string): string {
- const escaped = arg.replace(/'/g, "\\'");
+ // A single-quoted POSIX string takes no escapes, so `\'` does not close it:
+ // the string ends one character early and the rest of the command is left
+ // dangling. Step out of the quotes, emit an escaped quote, step back in.
+ const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
diff --git a/plugins/action-copy-curl/tests/index.test.ts b/plugins/action-copy-curl/tests/index.test.ts
index 824cf718..3f2e167a 100644
--- a/plugins/action-copy-curl/tests/index.test.ts
+++ b/plugins/action-copy-curl/tests/index.test.ts
@@ -120,7 +120,7 @@ describe("exporter-curl", () => {
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
- "--form f=@/foo/bar.png;type=image/png",
+ "--form 'f=@/foo/bar.png;type=image/png'",
].join(" \\\n "),
);
});
@@ -140,11 +140,48 @@ describe("exporter-curl", () => {
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
- `--data '{"foo":"bar\\'s"}'`,
+ `--data '{"foo":"bar'\\''s"}'`,
].join(" \\\n "),
);
});
+ test("Quotes an apostrophe so the command still parses", async () => {
+ // POSIX single quotes take no escapes: `\'` ends the string one
+ // character early and everything after it is left dangling, so the copied
+ // command is a syntax error rather than a request.
+ const command = await convertToCurl({
+ url: "https://yaak.app/it's",
+ method: "POST",
+ bodyType: "application/json",
+ body: { text: `{"note":"don't stop"}` },
+ headers: [{ name: "X-Note", value: "it's fine" }],
+ });
+
+ expect(command).toEqual(
+ [
+ `curl -X POST 'https://yaak.app/it'\\''s'`,
+ `--header 'X-Note: it'\\''s fine'`,
+ `--data '{"note":"don'\\''t stop"}'`,
+ ].join(" \\\n "),
+ );
+ });
+
+ test("Quotes a file form field so its type suffix survives", async () => {
+ // A bare `;` separates commands, so an unquoted `f=@x.png;type=image/png`
+ // reaches curl as `f=@x.png` and the rest runs as its own command.
+ expect(
+ await convertToCurl({
+ url: "https://yaak.app",
+ method: "POST",
+ bodyType: "multipart/form-data",
+ body: { form: [{ name: "f", file: "/my files/a.png", contentType: "image/png" }] },
+ }),
+ ).toEqual(
+ [`curl -X POST 'https://yaak.app'`, `--form 'f=@/my files/a.png;type=image/png'`].join(
+ " \\\n ",
+ ),
+ );
+ });
test("Exports multi-line JSON body", async () => {
expect(
await convertToCurl({
diff --git a/plugins/action-copy-grpcurl/src/index.ts b/plugins/action-copy-grpcurl/src/index.ts
index 1dd6378e..32232980 100644
--- a/plugins/action-copy-grpcurl/src/index.ts
+++ b/plugins/action-copy-grpcurl/src/index.ts
@@ -129,7 +129,10 @@ export async function convert(request: Partial, allProtoFiles: stri
}
function quote(arg: string): string {
- const escaped = arg.replace(/'/g, "\\'");
+ // A single-quoted POSIX string takes no escapes, so `\'` does not close it:
+ // the string ends one character early and the rest of the command is left
+ // dangling. Step out of the quotes, emit an escaped quote, step back in.
+ const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
diff --git a/plugins/action-copy-grpcurl/tests/index.test.ts b/plugins/action-copy-grpcurl/tests/index.test.ts
index e4144a18..9cec88cc 100644
--- a/plugins/action-copy-grpcurl/tests/index.test.ts
+++ b/plugins/action-copy-grpcurl/tests/index.test.ts
@@ -175,4 +175,21 @@ describe("exporter-curl", () => {
].join(" \\\n "),
);
});
+
+ test("Quotes an apostrophe so the command still parses", async () => {
+ // POSIX single quotes take no escapes: `\'` ends the string one
+ // character early and leaves the rest of the command dangling.
+ const command = await convert(
+ {
+ url: "https://yaak.app",
+ service: "Service",
+ method: "Method",
+ message: `{"note":"don't stop"}`,
+ metadata: [{ name: "x-note", value: "it's fine" }],
+ },
+ [],
+ );
+ expect(command).toContain(`'{"note":"don'\\''t stop"}'`);
+ expect(command).toContain(`'x-note: it'\\''s fine'`);
+ });
});
diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts
index dec52c65..2ab6c5b0 100644
--- a/plugins/importer-curl/src/index.ts
+++ b/plugins/importer-curl/src/index.ts
@@ -506,7 +506,17 @@ function importCommand(parseEntries: string[], workspaceId: string) {
form: multipartFormDataFromRaw,
};
} else if (dataParameters.length > 0 && bodyAsGET) {
- urlParameters.push(...dataParameters);
+ // `-G` moves the data into the query string, and Yaak encodes url
+ // parameters on send exactly as it encodes the form body below, so this
+ // needs the same decode -- otherwise a `--data-urlencode` value arrives
+ // here already encoded and goes out encoded twice.
+ urlParameters.push(
+ ...dataParameters.map((parameter) => ({
+ ...parameter,
+ name: decodePercentEncoding(parameter.name),
+ value: decodePercentEncoding(parameter.value),
+ })),
+ );
} else if (
dataParameters.length > 0 &&
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
@@ -515,8 +525,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
body = {
form: dataParameters.map((parameter) => ({
...parameter,
- name: decodeURIComponent(parameter.name || ""),
- value: decodeURIComponent(parameter.value || ""),
+ name: decodePercentEncoding(parameter.name),
+ value: decodePercentEncoding(parameter.value),
})),
};
filteredHeaders.push({
@@ -593,6 +603,34 @@ interface DataParameter {
enabled?: boolean;
}
+/**
+ * Decode a percent-encoded form value, keeping it as-is when it is not one.
+ *
+ * Yaak's form editor holds decoded values and re-encodes them on send, so a
+ * `-d` value has to be decoded on the way in. But curl sends that value
+ * verbatim and does not require it to be valid percent-encoding: `a=100%` is
+ * an ordinary form value, and `decodeURIComponent` throws URIError on it,
+ * which failed the whole import rather than that one parameter.
+ */
+function decodePercentEncoding(value: string | undefined): string {
+ const text = value || "";
+ try {
+ return decodeURIComponent(text);
+ } catch {
+ // Mixed: some of it is percent-encoded and some of it is a stray `%`.
+ // Returning the whole string untouched would leave the encoded part to be
+ // encoded a second time on send, so decode each valid run on its own and
+ // leave the stray byte alone. A run rather than a single escape, because a
+ // non-ASCII character is several escapes that only decode together.
+ return text.replace(/(%[0-9A-Fa-f]{2})+/g, (run) => {
+ try {
+ return decodeURIComponent(run);
+ } catch {
+ return run;
+ }
+ });
+ }
+}
function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
const dataParameters: DataParameter[] = [];
@@ -605,7 +643,11 @@ function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
for (const p of pairs) {
if (typeof p !== "string") continue;
- const params = p.split("&");
+ // `-d` content really is `&`-separated, so splitting it is right. But
+ // `--data-urlencode` encodes its whole argument — an `&` inside it is
+ // data curl percent-encodes, not a separator, so splitting there turned
+ // one parameter into several and changed what the request sends.
+ const params = flagName === "data-urlencode" ? [p] : p.split("&");
for (const param of params) {
const [name, value] = splitOnce(param, "=");
if (param.startsWith("@")) {
diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts
index 3c2d6dc0..79d31a07 100644
--- a/plugins/importer-curl/tests/index.test.ts
+++ b/plugins/importer-curl/tests/index.test.ts
@@ -244,6 +244,107 @@ describe("importer-curl", () => {
});
});
+ test("Keeps an --data-urlencode value whole", () => {
+ // curl encodes the whole argument, so the `&` and the second `=` are data it
+ // percent-encodes, not separators. Splitting on them made two parameters
+ // out of one, and Yaak then re-sent `q=a&b=c` where curl sends
+ // `q=a%26b%3Dc`. One parameter here re-encodes back to what curl sends.
+ expect(convertCurl(`curl --data-urlencode 'q=a&b=c' https://yaak.app`)).toEqual({
+ resources: {
+ workspaces: [baseWorkspace()],
+ httpRequests: [
+ baseRequest({
+ method: "POST",
+ url: "https://yaak.app",
+ bodyType: "application/x-www-form-urlencoded",
+ headers: [
+ {
+ name: "Content-Type",
+ value: "application/x-www-form-urlencoded",
+ enabled: true,
+ },
+ ],
+ body: {
+ form: [{ name: "q", value: "a&b=c", enabled: true }],
+ },
+ }),
+ ],
+ },
+ });
+ });
+
+ test("Imports a data value that is not valid percent-encoding", () => {
+ // curl sends a `-d` value verbatim and does not require it to decode, so
+ // a lone `%` is an ordinary form value. decodeURIComponent threw URIError
+ // on it and failed the whole import.
+ expect(convertCurl(`curl -d 'a=100%' https://yaak.app`)).toEqual({
+ resources: {
+ workspaces: [baseWorkspace()],
+ httpRequests: [
+ baseRequest({
+ method: "POST",
+ url: "https://yaak.app",
+ bodyType: "application/x-www-form-urlencoded",
+ headers: [
+ {
+ name: "Content-Type",
+ value: "application/x-www-form-urlencoded",
+ enabled: true,
+ },
+ ],
+ body: {
+ form: [{ name: "a", value: "100%", enabled: true }],
+ },
+ }),
+ ],
+ },
+ });
+ });
+
+ test("Keeps a valid escape decoded when the value also holds a stray percent", () => {
+ // Handing the whole value back untouched would leave `%25` to be encoded a
+ // second time on send, so each valid run decodes on its own.
+ expect(convertCurl(`curl -d 'a=50%25 and 100%' https://yaak.app`)).toEqual({
+ resources: {
+ workspaces: [baseWorkspace()],
+ httpRequests: [
+ baseRequest({
+ method: "POST",
+ url: "https://yaak.app",
+ bodyType: "application/x-www-form-urlencoded",
+ headers: [
+ {
+ name: "Content-Type",
+ value: "application/x-www-form-urlencoded",
+ enabled: true,
+ },
+ ],
+ body: {
+ form: [{ name: "a", value: "50% and 100%", enabled: true }],
+ },
+ }),
+ ],
+ },
+ });
+ });
+
+ test("Decodes -G --data-urlencode into the query string", () => {
+ // `-G` puts the data in the query string, which is encoded on send just
+ // like the form body, so the value has to arrive here decoded or it goes
+ // out encoded twice.
+ expect(convertCurl(`curl -G --data-urlencode 'q=a&b' https://yaak.app`)).toEqual({
+ resources: {
+ workspaces: [baseWorkspace()],
+ httpRequests: [
+ baseRequest({
+ url: "https://yaak.app",
+ urlParameters: [{ name: "q", value: "a&b", enabled: true }],
+ }),
+ ],
+ },
+ });
+ });
+
test("Imports data params as text", () => {
expect(
convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"),
diff --git a/plugins/importer-openapi/package.json b/plugins/importer-openapi/package.json
index e312db11..6d27c320 100644
--- a/plugins/importer-openapi/package.json
+++ b/plugins/importer-openapi/package.json
@@ -7,7 +7,8 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
- "test": "vp test --run tests"
+ "test": "vp test --run tests",
+ "test:roundtrip": "node tests/roundtrip.mjs"
},
"dependencies": {
"yaml": "^2.8.3"
diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts
index d9f2fb96..124aa25d 100644
--- a/plugins/importer-openapi/src/index.ts
+++ b/plugins/importer-openapi/src/index.ts
@@ -15,13 +15,20 @@ import YAML from "yaml";
type AtLeast = Partial & Pick;
type UnknownRecord = Record;
type ImportResources = {
- workspaces: AtLeast[];
+ workspaces: AtLeast[];
environments: AtLeast[];
folders: AtLeast[];
httpRequests: AtLeast[];
};
+type ImportedAuthentication = Pick & {
+ headers: HttpRequestHeader[];
+ urlParameters: HttpUrlParameter[];
+};
+type AuthenticationVariableRegistry = Map;
+type OAuthVariableNames = { clientId: string; clientSecret: string };
+type ServerOverrideVariable = { name: string; value: string };
-const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
+const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
const BODY_CONTENT_TYPE_PREFERENCE = [
"application/json",
"application/x-www-form-urlencoded",
@@ -30,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;
@@ -54,6 +62,7 @@ export async function convertOpenApi(contents: string): Promise();
const baseUrl = importBaseUrl(spec);
- const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
+ const serverEnvironments = importServerEnvironments(spec);
+ // A local spec has no document URL against which OpenAPI's implicit "/"
+ // server can resolve. Keep the shared variable even when its initial value
+ // is empty so users can configure the host once instead of editing requests.
+ const requestBaseUrl = "${[baseUrl]}";
+ resources.environments.push({
+ model: "environment",
+ id: importState.generateId("environment"),
+ workspaceId: workspace.id,
+ name: "Global Variables",
+ variables: [{ name: "baseUrl", value: baseUrl }],
+ parentModel: "workspace",
+ parentId: null,
+ sortPriority: importState.nextSortPriority(),
+ });
- if (baseUrl.length > 0) {
- resources.environments.push({
- model: "environment",
- id: importState.generateId("environment"),
- workspaceId: workspace.id,
- name: "Global Variables",
- variables: [{ name: "baseUrl", value: baseUrl }],
- parentModel: "workspace",
- parentId: null,
- sortPriority: importState.nextSortPriority(),
- });
- }
+ // Spec-level security is the default for every operation, which is exactly
+ // Yaak's inheritance model: it lives on the workspace, and only operations
+ // that declare their own security carry per-request authentication. API keys
+ // materialized as headers or query parameters go onto inheriting requests
+ // individually — workspace headers would also reach operations that override
+ // or disable security, leaking the credential to endpoints that opted out.
+ const workspaceAuthentication = importAuthentication({
+ authenticationVariables,
+ importState,
+ oauthVariablesByScheme,
+ security: spec.security,
+ spec,
+ useDynamicServerUrls: serverEnvironments.length > 1,
+ });
+ workspace.authentication = workspaceAuthentication.authentication;
+ workspace.authenticationType = workspaceAuthentication.authenticationType;
const folderIdsByTag = new Map();
const routeLabels = new Map();
@@ -103,10 +133,7 @@ export async function convertOpenApi(contents: string): Promise 1,
spec,
workspaceId: workspace.id,
folderId,
+ authenticationVariables,
});
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
resources.httpRequests.push(request);
}
}
+ const authenticationConfigs = [workspace, ...resources.httpRequests];
+ if (authenticationConfigs.some((model) => model.authenticationType === "oauth2")) {
+ const variableNames = new Set(
+ [...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
+ clientId,
+ clientSecret,
+ ]),
+ );
+ if (
+ authenticationConfigs.some(
+ (model) =>
+ model.authenticationType === "oauth2" &&
+ Object.values(toRecord(model.authentication)).some(
+ (value) =>
+ typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")),
+ ),
+ )
+ ) {
+ variableNames.add("baseUrlOrigin");
+ }
+ resources.environments[0]?.variables.push(
+ ...[...variableNames].map((name) => ({ name, value: "" })),
+ );
+ }
+
if (resources.httpRequests.length === 0) return undefined;
+ const baseEnvironment = resources.environments[0];
+ if (baseEnvironment == null) return undefined;
+ baseEnvironment.variables.push(...authenticationVariables.values());
+
+ const environmentSpecificVariables = baseEnvironment.variables;
+ baseEnvironment.variables = [...serverOverrides.values()];
+ resources.environments.push(
+ ...serverEnvironments.map(({ name, url }) => ({
+ model: "environment" as const,
+ id: importState.generateId("environment"),
+ workspaceId: workspace.id,
+ name,
+ variables: environmentSpecificVariables.map((variable) => ({
+ ...variable,
+ value:
+ variable.name === "baseUrl"
+ ? url
+ : variable.name === "baseUrlOrigin"
+ ? serverUrlOrigin(url)
+ : variable.value,
+ })),
+ parentModel: "environment" as const,
+ parentId: null,
+ sortPriority: importState.nextSortPriority(),
+ })),
+ );
+
disambiguateNames(resources.httpRequests, routeLabels);
return {
- resources: deleteUndefinedAttrs(
- convertTemplateSyntax({
- environments: resources.environments,
- folders: resources.folders,
- grpcRequests: [],
- httpRequests: resources.httpRequests,
- websocketRequests: [],
- workspaces: resources.workspaces,
- }),
- ) as PartialImportResources,
+ resources: deleteUndefinedAttrs({
+ environments: resources.environments,
+ folders: resources.folders,
+ grpcRequests: [],
+ httpRequests: resources.httpRequests,
+ websocketRequests: [],
+ workspaces: resources.workspaces,
+ }) as PartialImportResources,
};
}
+/** OpenAPI 3.2 adds QUERY plus a map for extension HTTP methods. */
+function pathItemOperations(
+ pathItem: UnknownRecord,
+ importState: ImportState,
+): { method: string; operation: UnknownRecord }[] {
+ const operations = HTTP_METHODS.flatMap((method) => {
+ const operation = importState.resolve(pathItem[method]);
+ return isRecord(operation) ? [{ method, operation }] : [];
+ });
+
+ for (const [method, rawOperation] of Object.entries(toRecord(pathItem.additionalOperations))) {
+ if (HTTP_METHODS.includes(method.toLowerCase())) continue;
+ const operation = importState.resolve(rawOperation);
+ if (isRecord(operation)) operations.push({ method, operation });
+ }
+ return operations;
+}
+
/**
* Two operations sharing a summary are indistinguishable once imported, so the
* colliding ones get their route appended. Names that are already unique within
@@ -176,26 +276,36 @@ function disambiguateNames(
function importOperation({
importState,
+ inheritedAuthentication,
method,
operation,
+ oauthVariablesByScheme,
path,
pathItem,
pathParameters,
requestBaseUrl,
+ serverOverrides,
+ useDynamicServerUrls,
spec,
workspaceId,
folderId,
+ authenticationVariables,
}: {
importState: ImportState;
+ inheritedAuthentication: ImportedAuthentication;
method: string;
operation: UnknownRecord;
+ oauthVariablesByScheme: Map;
path: string;
pathItem: UnknownRecord;
pathParameters: unknown[];
requestBaseUrl: string;
+ serverOverrides: Map;
+ useDynamicServerUrls: boolean;
spec: UnknownRecord;
workspaceId: string;
folderId: string | null;
+ authenticationVariables: AuthenticationVariableRegistry;
}): ImportResources["httpRequests"][0] {
importState.beginOperation();
const parameters = mergeParameters({
@@ -204,13 +314,45 @@ function importOperation({
operationParameters: toArray(operation.parameters),
});
const body = importBody({ importState, operation, parameters, spec });
- const urlParameters = importUrlParameters({ importState, parameters });
+ // Operations without their own security inherit the workspace's (null
+ // authenticationType), the same way an operation inherits spec security
+ const hasOwnSecurity = Array.isArray(operation.security);
+ const authentication = hasOwnSecurity
+ ? importAuthentication({
+ authenticationVariables,
+ importState,
+ oauthVariablesByScheme,
+ security: operation.security,
+ spec,
+ useDynamicServerUrls,
+ })
+ : {
+ ...emptyAuthentication(),
+ headers: inheritedAuthentication.headers,
+ urlParameters: inheritedAuthentication.urlParameters,
+ };
+ const url = buildOperationUrl(
+ operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
+ path,
+ parameters,
+ importState,
+ );
+ const urlParameters = [
+ ...importUrlParameters({ importState, parameters, path }),
+ ...authentication.urlParameters,
+ ];
const headers = mergeHeaders(
+ authentication.headers,
importHeaderParameters({ importState, parameters }),
+ importCookieHeader({ importState, parameters }),
body.headers,
importAcceptHeader({ importState, operation, spec }),
);
- const authentication = importAuthentication({ importState, operation, spec });
+ const {
+ headers: _authenticationHeaders,
+ urlParameters: _authenticationParameters,
+ ...auth
+ } = authentication;
// Built after everything else, so it can report the refs they left unresolved
const description = importOperationDescription({
@@ -228,13 +370,13 @@ function importOperation({
name: importOperationName(operation, method, path),
description,
method: method.toUpperCase(),
- url: buildOperationUrl(operationBaseUrl({ operation, pathItem, requestBaseUrl }), path),
+ url,
urlParameters,
headers,
body: body.body,
bodyType: body.bodyType,
sortPriority: importState.nextSortPriority(),
- ...authentication,
+ ...auth,
};
}
@@ -283,18 +425,26 @@ function operationBaseUrl({
operation,
pathItem,
requestBaseUrl,
+ serverOverrides,
}: {
operation: UnknownRecord;
pathItem: UnknownRecord;
requestBaseUrl: string;
+ serverOverrides: Map;
}): string {
for (const servers of [operation.servers, pathItem.servers]) {
const override = toArray(servers)
.map((s) => interpolateServerUrl(toRecord(s)))
.find((url) => url.length > 0);
- // Overrides are inlined rather than shared, since only the spec-level base
- // URL becomes the baseUrl variable
- if (override != null) return override;
+ if (override != null) {
+ let variable = serverOverrides.get(override);
+ if (variable == null) {
+ const suffix = serverOverrides.size === 0 ? "" : String(serverOverrides.size + 1);
+ variable = { name: `serverUrl${suffix}`, value: override };
+ serverOverrides.set(override, variable);
+ }
+ return `\${[${variable.name}]}`;
+ }
}
return requestBaseUrl;
}
@@ -350,11 +500,25 @@ function parseSpec(contents: string): unknown {
}
}
+/**
+ * The spec requires string versions, but unquoted YAML like `swagger: 2.0`
+ * parses as a number and such documents are common enough to accept.
+ */
function isOpenApiSpec(value: unknown): value is UnknownRecord {
const spec = toRecord(value);
- const openapi = stringAt(spec, "openapi");
- const swagger = stringAt(spec, "swagger");
- return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
+ const openapi = versionString(spec.openapi);
+ return isRecord(spec.paths) && (/^3(\.|$)/.test(openapi ?? "") || isSwagger2(spec));
+}
+
+function isSwagger2(spec: UnknownRecord): boolean {
+ const swagger = versionString(spec.swagger);
+ return swagger === "2.0" || swagger === "2";
+}
+
+function versionString(value: unknown): string | undefined {
+ if (typeof value === "string") return value;
+ if (typeof value === "number") return String(value);
+ return undefined;
}
function importInfoDescription(info: UnknownRecord): string | undefined {
@@ -529,8 +693,66 @@ function findOrCreateFolderId({
return folder.id;
}
-function buildOperationUrl(baseUrl: string, path: string): string {
- return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
+/**
+ * Yaak's `:name` placeholders only substitute when they span a whole path
+ * 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,
+ 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 {
@@ -544,15 +766,61 @@ function importBaseUrl(spec: UnknownRecord): string {
if (host == null) return stringAt(spec, "basePath") ?? "";
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
- return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
+ return trimTrailingSlashes(joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? ""));
}
+function importServerEnvironments(spec: UnknownRecord): { name: string; url: string }[] {
+ const servers = toArray(spec.servers)
+ .map(toRecord)
+ .map((server, index) => ({
+ name: stringAt(server, "description")?.trim() || `Server ${index + 1}`,
+ url: interpolateServerUrl(server),
+ }))
+ .filter(({ url }) => url.length > 0);
+ if (servers.length === 0) {
+ const hasSwaggerServer =
+ isSwagger2(spec) && (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
+ return [
+ {
+ name: hasSwaggerServer ? "Server 1" : "Default",
+ url: hasSwaggerServer ? importBaseUrl(spec) : "",
+ },
+ ];
+ }
+
+ const nameCounts = new Map();
+ return servers.map((server) => {
+ const count = (nameCounts.get(server.name) ?? 0) + 1;
+ nameCounts.set(server.name, count);
+ return { ...server, name: count === 1 ? server.name : `${server.name} ${count}` };
+ });
+}
+
+function serverUrlOrigin(value: string): string {
+ try {
+ const origin = new URL(value).origin;
+ return origin === "null" ? "" : origin;
+ } catch {
+ if (!value.startsWith("//")) return "";
+ try {
+ return `//${new URL(`https:${value}`).host}`;
+ } catch {
+ return "";
+ }
+ }
+}
+
+/**
+ * Request URLs are `${[baseUrl]}/path`, so a trailing slash here would put a
+ * double slash on the wire. Trimming also turns a bare `/` server into "",
+ * which renders the same URLs without a protocol-relative `//path`.
+ */
function interpolateServerUrl(server: UnknownRecord): string {
let url = stringAt(server, "url") ?? "";
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
}
- return url;
+ return trimTrailingSlashes(url);
}
function joinUrlParts(baseUrl: string, path: string): string {
@@ -575,25 +843,110 @@ 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");
+ // 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 }];
+ }
+
+ 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) }];
+}
+
+/**
+ * OpenAPI 3 query arrays default to form/explode, one parameter per item;
+ * Swagger 2 defaults to comma-separated unless collectionFormat says otherwise.
+ * A null separator means repeated parameters.
+ */
+function queryArraySerialization(parameter: UnknownRecord): { separator: string | null } {
+ const collectionFormat = stringAt(parameter, "collectionFormat");
+ if (collectionFormat != null || parameter.schema == null) {
+ if (collectionFormat === "multi") return { separator: null };
+ return {
+ separator: { csv: ",", ssv: " ", tsv: "\t", pipes: "|" }[collectionFormat ?? "csv"] ?? ",",
+ };
+ }
+ const style = stringAt(parameter, "style");
+ if (style === "spaceDelimited") return { separator: " " };
+ if (style === "pipeDelimited") return { separator: "|" };
+ return parameter.explode === false ? { separator: "," } : { separator: null };
+}
+
+// The spec says header parameters with these names SHALL be ignored; Accept and
+// Content-Type come from the operation's media types, Authorization from its
+// security requirements
+const IGNORED_HEADER_PARAMETERS = new Set(["accept", "authorization", "content-type"]);
+
function importHeaderParameters({
importState,
parameters,
@@ -605,18 +958,152 @@ function importHeaderParameters({
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "header")
+ .filter((p) => !IGNORED_HEADER_PARAMETERS.has((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);
}
+/**
+ * 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,
+}: {
+ importState: ImportState;
+ parameters: unknown[];
+}): 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;
+ // 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(parameter.schema ?? parameter, importState);
+}
+
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
- const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
- if (directExample != null) return stringifyExampleValue(directExample);
- return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
+ return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter);
}
function importBody({
@@ -643,18 +1130,25 @@ function importBody({
.map((p) => importState.resolve(p))
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
if (isRecord(bodyParameter)) {
- const contentType = toArray(operation.consumes ?? spec.consumes).find(
- (c): c is string => typeof c === "string",
- );
- const bodyType = contentType ?? "application/json";
+ const contentType =
+ 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: bodyType }],
- bodyType,
- body: {
- text: formatBodyText(
- schemaToExample(importState.resolve(bodyParameter.schema), importState),
- ),
- },
+ headers: [{ enabled: true, name: "Content-Type", value: contentType }],
+ bodyType: isBinary ? "binary" : yaakBodyType(contentType),
+ body: isBinary
+ ? {}
+ : {
+ text: formatMediaTypeBody(
+ contentType,
+ schemaToExample(schema, importState),
+ schema,
+ importState,
+ ),
+ },
};
}
@@ -672,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) };
+ }),
},
};
}
@@ -689,55 +1187,242 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
if (contentType == null) return { headers: [], body: {}, bodyType: null };
const mediaType = toRecord(content[contentType]);
- const example = mediaTypeExample(mediaType, importState);
+ const bodyType = yaakBodyType(contentType);
- if (
- contentType === "application/x-www-form-urlencoded" ||
- contentType === "multipart/form-data"
- ) {
+ 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: 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: contentType === "application/octet-stream" ? "binary" : contentType,
- body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
+ 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) => c.toLowerCase() === 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 mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
- const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
- if (directExample != null) return directExample;
- return schemaToExample(importState.resolve(mediaType.schema), importState);
+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 schemaToFormParameters(schema: unknown, importState: ImportState) {
- const resolvedSchema = toRecord(importState.resolve(schema));
+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}${name}>`;
+}
+
+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() ?? "";
+}
+
+/**
+ * Yaak's body editors key off a fixed set of body types, while the Content-Type
+ * header keeps the spec's exact media type. Anything unrecognized becomes
+ * "other", the app's plain-text body with an explicit Content-Type.
+ */
+function yaakBodyType(contentType: string): string {
+ const mediaType = mediaTypeOf(contentType);
+ if (mediaType === "application/json" || mediaType.endsWith("+json")) return "application/json";
+ if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) {
+ return "text/xml";
+ }
+ if (mediaType === "application/x-www-form-urlencoded" || mediaType === "multipart/form-data") {
+ return mediaType;
+ }
+ if (mediaType === "application/octet-stream") return "binary";
+ return "other";
+}
+
+function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
+ const directExample = firstPresent(
+ mediaType.example,
+ firstExampleValue(mediaType.examples, importState),
+ );
+ if (directExample != null) return directExample;
+ return schemaToExample(mediaType.schema, importState);
+}
+
+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)).slice(
- 0,
- MAX_EXAMPLE_PROPERTIES,
- );
+ 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,
@@ -745,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) };
});
}
@@ -757,63 +1442,140 @@ 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),
+ firstExampleValue(resolved.examples, importState),
+ resolved.const,
resolved.default,
);
- if (explicitExample != null) return explicitExample;
+ if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved);
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) {
- return 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;
}, {});
+ 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)];
+ return [schemaToExample(resolved.items, importState, depth + 1, nextVisitedRefs)];
}
- if (type === "object") {
- const required = toArray(resolved.required).filter(
- (name): name is string => typeof name === "string",
- );
- const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
+ 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),
- ]),
- );
- }
- if (type === "integer" || type === "number") return 0;
- if (type === "boolean") return false;
- if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
- if (stringAt(resolved, "format") === "date") return "2026-01-01";
- return "";
+ 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",
+ email: "user@example.com",
+ hostname: "example.com",
+ ipv4: "127.0.0.1",
+ ipv6: "::1",
+ uri: "https://example.com",
+ url: "https://example.com",
+ uuid: "00000000-0000-0000-0000-000000000000",
+};
+
+/**
+ * YAML coerces unquoted scalars, so specs routinely carry `example: 12345` on a
+ * `type: string` field. Sending the number fails the spec's own schema, and the
+ * declared type is the author's stated intent.
+ */
+function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown {
+ const rawType = schema.type;
+ const declared =
+ typeof rawType === "string"
+ ? rawType
+ : Array.isArray(rawType)
+ ? rawType.find((t) => t !== "null")
+ : null;
+
+ if (declared === "string" && (typeof example === "number" || typeof example === "boolean")) {
+ return String(example);
+ }
+ if (
+ (declared === "integer" || declared === "number") &&
+ typeof example === "string" &&
+ example.trim() !== "" &&
+ Number.isFinite(Number(example))
+ ) {
+ return Number(example);
+ }
+ return example;
+}
+
+
function inferSchemaType(schema: UnknownRecord): string {
const rawType = schema.type;
if (typeof rawType === "string") return rawType;
@@ -826,53 +1588,164 @@ function inferSchemaType(schema: UnknownRecord): string {
return "string";
}
+/**
+ * Security Requirement Objects are ordered alternatives, so the first one this
+ * importer can represent wins. That makes `[{bearer}, {}]` import the bearer
+ * auth the author listed first, while `[{}, {bearer}]` imports as anonymous.
+ */
function importAuthentication({
+ authenticationVariables,
importState,
- operation,
+ oauthVariablesByScheme,
+ security,
spec,
+ useDynamicServerUrls,
}: {
+ authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
- operation: UnknownRecord;
+ oauthVariablesByScheme: Map;
+ security: unknown;
spec: UnknownRecord;
-}): Pick {
- const security = operation.security ?? spec.security;
- if (!Array.isArray(security) || security.length === 0) {
- return { authenticationType: null, authentication: {} };
+ useDynamicServerUrls: boolean;
+}): ImportedAuthentication {
+ if (!Array.isArray(security)) return emptyAuthentication();
+ if (security.length === 0) {
+ return { ...emptyAuthentication(), authenticationType: "none" };
}
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
- for (const requirement of security) {
- for (const [schemeName, rawScopes] of Object.entries(toRecord(requirement))) {
- const scheme = toRecord(importState.resolve(schemes[schemeName]));
- const type = stringAt(scheme, "type");
- if (type === "oauth2") {
- const oauth2 = importOAuth2(scheme, rawScopes);
- if (oauth2 != null) return oauth2;
- continue;
- }
- if (type === "apiKey") {
- return { authenticationType: "apikey", authentication: importApiKey(scheme, schemeName) };
- }
- // Swagger 2.0 spells basic auth as its own type rather than an HTTP scheme
- if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
- return {
- authenticationType: "basic",
- authentication: { username: "", password: "" },
- };
- }
- if (type === "http" && schemeIs(scheme, "bearer")) {
- return {
- authenticationType: "bearer",
- authentication: { token: "", prefix: "Bearer" },
- };
- }
+ for (const rawRequirement of security) {
+ if (!isRecord(rawRequirement)) continue;
+ if (Object.keys(rawRequirement).length === 0) {
+ return { ...emptyAuthentication(), authenticationType: "none" };
}
+
+ const imported = importSecurityRequirement({
+ authenticationVariables,
+ importState,
+ oauthVariablesByScheme,
+ requirement: rawRequirement,
+ schemes,
+ spec,
+ useDynamicServerUrls,
+ });
+ if (imported != null) return imported;
}
- return { authenticationType: null, authentication: {} };
+ // Declared security this importer cannot represent (e.g. mutualTLS alone)
+ // should not fall back to inheriting some other authentication
+ return { ...emptyAuthentication(), authenticationType: "none" };
+}
+
+function importSecurityRequirement({
+ authenticationVariables,
+ importState,
+ oauthVariablesByScheme,
+ requirement,
+ schemes,
+ spec,
+ useDynamicServerUrls,
+}: {
+ authenticationVariables: AuthenticationVariableRegistry;
+ importState: ImportState;
+ oauthVariablesByScheme: Map;
+ requirement: UnknownRecord;
+ schemes: UnknownRecord;
+ spec: UnknownRecord;
+ useDynamicServerUrls: boolean;
+}): ImportedAuthentication | null {
+ const entries = Object.entries(requirement);
+ const headers: HttpRequestHeader[] = [];
+ const urlParameters: HttpUrlParameter[] = [];
+ let primaryAuthentication: Pick | null =
+ null;
+
+ for (const [schemeName, rawScopes] of entries) {
+ const scheme = toRecord(importState.resolve(schemes[schemeName]));
+ const type = stringAt(scheme, "type");
+ if (type === "apiKey") {
+ const variable = registerAuthenticationVariable(authenticationVariables, schemeName, "key");
+ if (entries.length === 1) {
+ primaryAuthentication = {
+ authenticationType: "apikey",
+ authentication: importApiKey(scheme, schemeName, variable),
+ };
+ } else {
+ materializeApiKey(scheme, schemeName, variable, headers, urlParameters);
+ }
+ continue;
+ }
+
+ let candidate: Pick | null = null;
+ if (type === "oauth2") {
+ candidate = importOAuth2(
+ scheme,
+ rawScopes,
+ importBaseUrl(spec),
+ oauthVariablesByScheme.get(schemeName) ?? {
+ clientId: "oauth_client_id",
+ clientSecret: "oauth_client_secret",
+ },
+ useDynamicServerUrls,
+ );
+ } else if (type === "openIdConnect") {
+ const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
+ candidate = {
+ authenticationType: "bearer",
+ authentication: { token: templateVariable(token), prefix: "Bearer" },
+ };
+ } else if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
+ const username = registerAuthenticationVariable(
+ authenticationVariables,
+ schemeName,
+ "username",
+ );
+ const password = registerAuthenticationVariable(
+ authenticationVariables,
+ schemeName,
+ "password",
+ );
+ candidate = {
+ authenticationType: "basic",
+ authentication: {
+ username: templateVariable(username),
+ password: templateVariable(password),
+ },
+ };
+ } else if (type === "http" && schemeIs(scheme, "bearer")) {
+ const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
+ candidate = {
+ authenticationType: "bearer",
+ authentication: { token: templateVariable(token), prefix: "Bearer" },
+ };
+ }
+
+ // A requirement is an AND. Yaak can combine one auth plugin with explicit
+ // API-key parameters, but cannot represent two auth plugins on one request.
+ if (candidate == null || primaryAuthentication != null) return null;
+ primaryAuthentication = candidate;
+ }
+
+ return {
+ ...(primaryAuthentication ?? {
+ authenticationType: entries.length > 1 ? "none" : null,
+ authentication: {},
+ }),
+ headers,
+ urlParameters,
+ };
+}
+
+function emptyAuthentication(): ImportedAuthentication {
+ return {
+ authenticationType: null,
+ authentication: {},
+ headers: [],
+ urlParameters: [],
+ };
}
function schemeIs(scheme: UnknownRecord, name: string): boolean {
@@ -884,14 +1757,67 @@ function schemeIs(scheme: UnknownRecord, name: string): boolean {
* cookie key becomes the Cookie header it would have ended up in, pre-filled
* with its name. Sending it as a header named after the cookie would just fail.
*/
-function importApiKey(scheme: UnknownRecord, schemeName: string): Record {
+function importApiKey(
+ scheme: UnknownRecord,
+ schemeName: string,
+ variableName: string,
+): Record {
const key = stringAt(scheme, "name") ?? schemeName;
const location = stringAt(scheme, "in");
+ const value = templateVariable(variableName);
if (location === "cookie") {
- return { location: "header", key: "Cookie", value: `${key}=` };
+ return { location: "header", key: "Cookie", value: `${key}=${value}` };
}
- return { location: location === "query" ? "query" : "header", key, value: "" };
+ return { location: location === "query" ? "query" : "header", key, value };
+}
+
+function materializeApiKey(
+ scheme: UnknownRecord,
+ schemeName: string,
+ variableName: string,
+ headers: HttpRequestHeader[],
+ urlParameters: HttpUrlParameter[],
+): void {
+ const key = stringAt(scheme, "name") ?? schemeName;
+ const location = stringAt(scheme, "in");
+ const value = templateVariable(variableName);
+ if (location === "query") {
+ urlParameters.push({ enabled: true, name: key, value });
+ } else if (location === "cookie") {
+ headers.push({ enabled: true, name: "Cookie", value: `${key}=${value}` });
+ } else {
+ headers.push({ enabled: true, name: key, value });
+ }
+}
+
+function registerAuthenticationVariable(
+ variables: AuthenticationVariableRegistry,
+ schemeName: string,
+ field: string,
+): string {
+ const identity = JSON.stringify([schemeName, field]);
+ const existing = variables.get(identity);
+ if (existing != null) return existing.name;
+
+ const schemePart = schemeName
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .replaceAll(/[^a-zA-Z0-9]+/g, "_")
+ .replaceAll(/^_+|_+$/g, "")
+ .toLowerCase();
+ const baseName = `auth_${schemePart || "security"}_${field}`;
+ let name = baseName;
+ let suffix = 2;
+ const names = new Set([...variables.values()].map((variable) => variable.name));
+ while (names.has(name)) {
+ name = `${baseName}_${suffix++}`;
+ }
+ variables.set(identity, { name, value: "" });
+ return name;
+}
+
+function templateVariable(name: string): string {
+ return `\${[${name}]}`;
}
/**
@@ -902,6 +1828,9 @@ function importApiKey(scheme: UnknownRecord, schemeName: string): Record | null {
const scope = toArray(rawScopes)
.filter((s): s is string => typeof s === "string")
@@ -929,24 +1858,44 @@ function importOAuth2(
}
for (const { grantType, flow } of candidates) {
- const authorizationUrl = stringAt(flow, "authorizationUrl");
- const accessTokenUrl = stringAt(flow, "tokenUrl");
+ const authorizationUrl = resolveOAuthUrl(
+ stringAt(flow, "authorizationUrl"),
+ baseUrl,
+ useDynamicServerUrls,
+ );
+ const accessTokenUrl = resolveOAuthUrl(
+ stringAt(flow, "tokenUrl"),
+ baseUrl,
+ useDynamicServerUrls,
+ );
if (authorizationUrl == null && accessTokenUrl == null) continue;
const grantPatch =
grantType === "authorization_code"
- ? { authorizationUrl, accessTokenUrl, clientSecret: "" }
+ ? {
+ authorizationUrl,
+ accessTokenUrl,
+ clientSecret: templateVariable(variableNames.clientSecret),
+ }
: grantType === "implicit"
? { authorizationUrl }
: grantType === "password"
- ? { accessTokenUrl, clientSecret: "", username: "", password: "" }
- : { accessTokenUrl, clientSecret: "" };
+ ? {
+ accessTokenUrl,
+ clientSecret: templateVariable(variableNames.clientSecret),
+ username: "",
+ password: "",
+ }
+ : {
+ accessTokenUrl,
+ clientSecret: templateVariable(variableNames.clientSecret),
+ };
return {
authenticationType: "oauth2",
authentication: {
grantType,
- clientId: "",
+ clientId: templateVariable(variableNames.clientId),
headerPrefix: "Bearer",
...(scope.length > 0 ? { scope } : {}),
...grantPatch,
@@ -957,12 +1906,85 @@ function importOAuth2(
return null;
}
+function resolveOAuthUrl(
+ value: string | undefined,
+ baseUrl: string,
+ useDynamicServerUrls: boolean,
+): string | undefined {
+ if (value == null) return undefined;
+ try {
+ return new URL(value).toString();
+ } catch {
+ // Relative endpoint; resolve it against the API base below.
+ }
+
+ if (value.startsWith("//")) return value;
+ if (useDynamicServerUrls) {
+ return value.startsWith("/")
+ ? `${templateVariable("baseUrlOrigin")}${value}`
+ : joinUrlParts(templateVariable("baseUrl"), value);
+ }
+
+ if (baseUrl.length > 0) {
+ try {
+ return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString();
+ } catch {
+ // A path-only server has no origin to resolve against. Preserve whether
+ // the OAuth endpoint is relative to that path or to the eventual origin.
+ }
+ }
+
+ try {
+ const placeholderOrigin = "https://openapi-import.invalid";
+ const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin);
+ const resolved = new URL(value, relativeBase);
+ return `${templateVariable("baseUrlOrigin")}${resolved.pathname}${resolved.search}${resolved.hash}`;
+ } catch {
+ return joinUrlParts(templateVariable("baseUrlOrigin"), value);
+ }
+}
+
+function buildOAuthVariablesByScheme(
+ importState: ImportState,
+ spec: UnknownRecord,
+): Map {
+ const schemes = {
+ ...toRecord(toRecord(spec.components).securitySchemes),
+ ...toRecord(spec.securityDefinitions),
+ };
+ const oauthSchemeNames = Object.entries(schemes)
+ .filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2")
+ .map(([name]) => name);
+ const usedPrefixes = new Set();
+
+ return new Map(
+ oauthSchemeNames.map((schemeName) => {
+ const basePrefix =
+ oauthSchemeNames.length === 1
+ ? "oauth"
+ : `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`;
+ let prefix = basePrefix;
+ let suffix = 2;
+ while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`;
+ usedPrefixes.add(prefix);
+ return [
+ schemeName,
+ { clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
+ ];
+ }),
+ );
+}
+
+/** 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;
@@ -979,8 +2001,13 @@ function stringifyExampleValue(value: unknown): string {
return JSON.stringify(value);
}
-function firstExampleValue(examples: unknown): unknown {
- const firstExample = Object.values(toRecord(examples))[0];
+/**
+ * `examples` is a map of (possibly `$ref`) Example objects on media types and
+ * parameters, but a plain array of values on OpenAPI 3.1 schemas.
+ */
+function firstExampleValue(examples: unknown, importState: ImportState): unknown {
+ if (Array.isArray(examples)) return examples[0];
+ const firstExample = importState.resolve(Object.values(toRecord(examples))[0]);
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
return firstExample;
}
@@ -1010,23 +2037,6 @@ function isPresent(value: T | null | undefined): value is T {
return value != null && value !== "";
}
-/** Recursively render all nested object properties */
-function convertTemplateSyntax(obj: T): T {
- if (typeof obj === "string") {
- // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
- return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
- }
- if (Array.isArray(obj) && obj != null) {
- return obj.map(convertTemplateSyntax) as T;
- }
- if (typeof obj === "object" && obj != null) {
- return Object.fromEntries(
- Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
- ) as T;
- }
- return obj;
-}
-
function deleteUndefinedAttrs(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
@@ -1084,12 +2094,127 @@ 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", "~"))
- .reduce((current, part) => toRecord(current)[part], this.#spec);
-
- return this.resolve(resolved, nextVisitedRefs);
+ .reduce(
+ (current, part) =>
+ Array.isArray(current) ? current[Number(part)] : toRecord(current)[part],
+ this.#spec,
+ );
}
}
diff --git a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap
index 10458055..f9aac685 100644
--- a/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap
+++ b/plugins/importer-openapi/tests/__snapshots__/index.test.ts.snap
@@ -11,6 +11,16 @@ exports[`importer-openapi > Snapshots real-world fixture apis-guru.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
+ "variables": [],
+ "workspaceId": "GENERATE_ID::WORKSPACE_0",
+ },
+ {
+ "id": "GENERATE_ID::ENVIRONMENT_1",
+ "model": "environment",
+ "name": "Server 1",
+ "parentId": null,
+ "parentModel": "environment",
+ "sortPriority": 9,
"variables": [
{
"name": "baseUrl",
@@ -153,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",
},
@@ -197,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,
@@ -209,11 +214,6 @@ Responses:
"name": ":service",
"value": "graph",
},
- {
- "enabled": true,
- "name": ":api",
- "value": "2.1.0",
- },
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -246,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",
},
{
@@ -298,6 +292,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
+ "authentication": {},
+ "authenticationType": "none",
"description": "Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
@@ -326,6 +322,16 @@ exports[`importer-openapi > Snapshots real-world fixture httpbin.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
+ "variables": [],
+ "workspaceId": "GENERATE_ID::WORKSPACE_0",
+ },
+ {
+ "id": "GENERATE_ID::ENVIRONMENT_1",
+ "model": "environment",
+ "name": "Server 1",
+ "parentId": null,
+ "parentModel": "environment",
+ "sortPriority": 90,
"variables": [
{
"name": "baseUrl",
@@ -611,7 +617,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -640,7 +646,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -669,7 +675,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -698,7 +704,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -727,7 +733,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -756,7 +762,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
- "value": "",
+ "value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -816,12 +822,12 @@ Responses:
{
"enabled": true,
"name": ":user",
- "value": "",
+ "value": "user",
},
{
"enabled": true,
"name": ":passwd",
- "value": "",
+ "value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -840,13 +846,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",
@@ -1073,12 +1073,12 @@ Responses:
{
"enabled": true,
"name": ":name",
- "value": "",
+ "value": "name",
},
{
"enabled": true,
"name": ":value",
- "value": "",
+ "value": "value",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1344,17 +1344,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
- "value": "",
+ "value": "qop",
},
{
"enabled": true,
"name": ":user",
- "value": "",
+ "value": "user",
},
{
"enabled": true,
"name": ":passwd",
- "value": "",
+ "value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1387,17 +1387,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
- "value": "",
+ "value": "qop",
},
{
"enabled": true,
"name": ":user",
- "value": "",
+ "value": "user",
},
{
"enabled": true,
"name": ":passwd",
- "value": "",
+ "value": "passwd",
},
{
"enabled": true,
@@ -1437,17 +1437,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
- "value": "",
+ "value": "qop",
},
{
"enabled": true,
"name": ":user",
- "value": "",
+ "value": "user",
},
{
"enabled": true,
"name": ":passwd",
- "value": "",
+ "value": "passwd",
},
{
"enabled": true,
@@ -1567,7 +1567,7 @@ Responses:
{
"enabled": true,
"name": ":etag",
- "value": "",
+ "value": "etag",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1658,12 +1658,12 @@ Responses:
{
"enabled": true,
"name": ":user",
- "value": "",
+ "value": "user",
},
{
"enabled": true,
"name": ":passwd",
- "value": "",
+ "value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2297,7 +2297,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2330,7 +2330,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2363,7 +2363,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2396,7 +2396,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2429,7 +2429,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2462,7 +2462,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
- "value": "",
+ "value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2589,6 +2589,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
+ "authentication": {},
+ "authenticationType": null,
"description": "A simple HTTP Request & Response Service. Run locally: $ docker run -p 80:80 kennethreitz/httpbin
Contact: me@kennethreitz.org",
@@ -2612,11 +2614,44 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
+ "variables": [],
+ "workspaceId": "GENERATE_ID::WORKSPACE_0",
+ },
+ {
+ "id": "GENERATE_ID::ENVIRONMENT_1",
+ "model": "environment",
+ "name": "Server 1",
+ "parentId": null,
+ "parentModel": "environment",
+ "sortPriority": 3,
"variables": [
{
"name": "baseUrl",
"value": "https://api.nasa.gov/planetary",
},
+ {
+ "name": "auth_api_key_key",
+ "value": "",
+ },
+ ],
+ "workspaceId": "GENERATE_ID::WORKSPACE_0",
+ },
+ {
+ "id": "GENERATE_ID::ENVIRONMENT_2",
+ "model": "environment",
+ "name": "Server 2",
+ "parentId": null,
+ "parentModel": "environment",
+ "sortPriority": 4,
+ "variables": [
+ {
+ "name": "baseUrl",
+ "value": "http://api.nasa.gov/planetary",
+ },
+ {
+ "name": "auth_api_key_key",
+ "value": "",
+ },
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -2640,7 +2675,7 @@ Here's a link: https://example.com",
"authentication": {
"key": "api_key",
"location": "query",
- "value": "",
+ "value": "\${[auth_api_key_key]}",
},
"authenticationType": "apikey",
"body": {},
@@ -2686,6 +2721,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
+ "authentication": {},
+ "authenticationType": null,
"description": "This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
Contact: evan.t.yates@nasa.gov
@@ -2711,10 +2748,20 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
+ "variables": [],
+ "workspaceId": "GENERATE_ID::WORKSPACE_0",
+ },
+ {
+ "id": "GENERATE_ID::ENVIRONMENT_1",
+ "model": "environment",
+ "name": "Server 1",
+ "parentId": null,
+ "parentModel": "environment",
+ "sortPriority": 3,
"variables": [
{
"name": "baseUrl",
- "value": "http://xkcd.com/",
+ "value": "http://xkcd.com",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2778,6 +2825,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
+ "authentication": {},
+ "authenticationType": null,
"description": "Webcomic of romance, sarcasm, math, and language.",
"id": "GENERATE_ID::WORKSPACE_0",
"model": "workspace",
diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts
index 68ca24fb..1aca27f9 100644
--- a/plugins/importer-openapi/tests/index.test.ts
+++ b/plugins/importer-openapi/tests/index.test.ts
@@ -13,6 +13,36 @@ describe("importer-openapi", () => {
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
+ test("Imports OpenAPI 3.2 QUERY and additional operations", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.2.0",
+ info: { title: "OpenAPI 3.2 Operations", version: "1.0.0" },
+ paths: {
+ "/resources": {
+ query: { summary: "Query resources", responses: {} },
+ additionalOperations: {
+ COPY: { summary: "Copy resources", responses: {} },
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests).toEqual([
+ expect.objectContaining({
+ method: "QUERY",
+ name: "Query resources",
+ url: "${[baseUrl]}/resources",
+ }),
+ expect.objectContaining({
+ method: "COPY",
+ name: "Copy resources",
+ url: "${[baseUrl]}/resources",
+ }),
+ ]);
+ });
+
test("Maps operation description to request description", async () => {
const imported = await convertOpenApi(
JSON.stringify({
@@ -120,7 +150,15 @@ describe("importer-openapi", () => {
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
- variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
+ variables: [],
+ }),
+ expect.objectContaining({
+ name: "Server 1",
+ parentModel: "environment",
+ variables: [
+ { name: "baseUrl", value: "https://api.example.com/v1" },
+ { name: "auth_token_auth_token", value: "" },
+ ],
}),
]);
expect(imported?.resources.httpRequests).toEqual([
@@ -129,7 +167,7 @@ describe("importer-openapi", () => {
method: "POST",
url: "${[baseUrl]}/accounts/:accountId/members",
authenticationType: "bearer",
- authentication: { token: "", prefix: "Bearer" },
+ authentication: { token: "${[auth_token_auth_token]}", prefix: "Bearer" },
bodyType: "application/json",
body: {
text: JSON.stringify(
@@ -219,6 +257,11 @@ describe("importer-openapi", () => {
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
+ name: "Global Variables",
+ variables: [],
+ }),
+ expect.objectContaining({
+ name: "Server 1",
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
}),
]);
@@ -229,6 +272,31 @@ describe("importer-openapi", () => {
expect(imported).toBeUndefined();
});
+ test("Creates an editable baseUrl variable when OpenAPI omits servers", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.4",
+ info: { title: "Serverless OpenAPI Test", version: "1.0.0" },
+ paths: {
+ "/api/widgets": { get: { responses: {} } },
+ },
+ }),
+ );
+
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({
+ name: "Global Variables",
+ variables: [],
+ }),
+ expect.objectContaining({
+ name: "Default",
+ parentModel: "environment",
+ variables: [{ name: "baseUrl", value: "" }],
+ }),
+ ]);
+ expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/api/widgets");
+ });
+
test("Prefers operation and path servers over the spec base URL", async () => {
const imported = await convertOpenApi(
JSON.stringify({
@@ -251,8 +319,119 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests.map((r) => r.url)).toEqual([
"${[baseUrl]}/root",
- "https://path.example.com/path-level",
- "https://operation.example.com/operation-level",
+ "${[serverUrl]}/path-level",
+ "${[serverUrl2]}/operation-level",
+ ]);
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({
+ name: "Global Variables",
+ variables: [
+ { name: "serverUrl", value: "https://path.example.com" },
+ { name: "serverUrl2", value: "https://operation.example.com" },
+ ],
+ }),
+ expect.objectContaining({
+ name: "Server 1",
+ variables: [{ name: "baseUrl", value: "https://root.example.com" }],
+ }),
+ ]);
+ });
+
+ test("Creates selectable environments for multiple OpenAPI servers", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.4",
+ info: { title: "Server Environments Test", version: "1.0.0" },
+ servers: [
+ { url: "https://api.example.com/v1", description: "Production" },
+ { url: "https://sandbox.example.com/v1", description: "Sandbox" },
+ ],
+ paths: {
+ "/oauth": { get: { security: [{ oauth: [] }], responses: {} } },
+ "/api-key": { get: { security: [{ apiKey: [] }], responses: {} } },
+ "/fixed": {
+ servers: [{ url: "https://fixed.example.com" }],
+ get: { responses: {} },
+ },
+ },
+ components: {
+ securitySchemes: {
+ oauth: {
+ type: "oauth2",
+ flows: {
+ authorizationCode: {
+ authorizationUrl: "/oauth/authorize",
+ tokenUrl: "oauth/token",
+ scopes: {},
+ },
+ },
+ },
+ apiKey: { type: "apiKey", in: "header", name: "X-API-Key" },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({
+ name: "Global Variables",
+ variables: [{ name: "serverUrl", value: "https://fixed.example.com" }],
+ }),
+ expect.objectContaining({
+ name: "Production",
+ parentModel: "environment",
+ variables: [
+ { name: "baseUrl", value: "https://api.example.com/v1" },
+ { name: "oauth_client_id", value: "" },
+ { name: "oauth_client_secret", value: "" },
+ { name: "baseUrlOrigin", value: "https://api.example.com" },
+ { name: "auth_api_key_key", value: "" },
+ ],
+ }),
+ expect.objectContaining({
+ name: "Sandbox",
+ parentModel: "environment",
+ variables: [
+ { name: "baseUrl", value: "https://sandbox.example.com/v1" },
+ { name: "oauth_client_id", value: "" },
+ { name: "oauth_client_secret", value: "" },
+ { name: "baseUrlOrigin", value: "https://sandbox.example.com" },
+ { name: "auth_api_key_key", value: "" },
+ ],
+ }),
+ ]);
+ expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
+ expect.objectContaining({
+ authorizationUrl: "${[baseUrlOrigin]}/oauth/authorize",
+ accessTokenUrl: "${[baseUrl]}/oauth/token",
+ }),
+ );
+ });
+
+ test("Creates variables for path servers without a top-level server", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.4",
+ info: { title: "Path Server Test", version: "1.0.0" },
+ paths: {
+ "/items": {
+ servers: [{ url: "https://path.example.com" }],
+ get: { responses: {} },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.url).toBe("${[serverUrl]}/items");
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({
+ name: "Global Variables",
+ variables: [{ name: "serverUrl", value: "https://path.example.com" }],
+ }),
+ expect.objectContaining({
+ name: "Default",
+ variables: [{ name: "baseUrl", value: "" }],
+ }),
]);
});
@@ -289,8 +468,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "client_credentials",
- clientId: "",
- clientSecret: "",
+ clientId: "${[oauth_oauth_client_id]}",
+ clientSecret: "${[oauth_oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "read write",
accessTokenUrl: "https://example.com/token",
@@ -302,12 +481,125 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "implicit",
- clientId: "",
+ clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer",
authorizationUrl: "https://example.com/authorize",
},
}),
);
+ expect(imported?.resources.environments[0]?.variables).toEqual([]);
+ expect(imported?.resources.environments[1]).toEqual(
+ expect.objectContaining({
+ name: "Default",
+ variables: [
+ { name: "baseUrl", value: "" },
+ { name: "oauth_oauth_client_id", value: "" },
+ { name: "oauth_oauth_client_secret", value: "" },
+ { name: "oauth_implicitOauth_client_id", value: "" },
+ { name: "oauth_implicitOauth_client_secret", value: "" },
+ ],
+ }),
+ );
+ });
+
+ test("Uses server environment variables for OAuth2 client credentials", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.4",
+ info: { title: "OAuth Environment Test", version: "1.0.0" },
+ servers: [{ url: "https://api.example.com" }],
+ paths: {
+ "/users": {
+ get: { security: [{ oauth: ["read"] }], responses: {} },
+ post: { security: [{ oauth: ["write"] }], responses: {} },
+ },
+ },
+ components: {
+ securitySchemes: {
+ oauth: {
+ type: "oauth2",
+ flows: {
+ authorizationCode: {
+ authorizationUrl: "/oauth/authorize",
+ tokenUrl: "/oauth/token",
+ scopes: { read: "Read users", write: "Write users" },
+ },
+ },
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({ name: "Global Variables", variables: [] }),
+ expect.objectContaining({
+ name: "Server 1",
+ variables: [
+ { name: "baseUrl", value: "https://api.example.com" },
+ { name: "oauth_client_id", value: "" },
+ { name: "oauth_client_secret", value: "" },
+ ],
+ }),
+ ]);
+ expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
+ expect.objectContaining({
+ clientId: "${[oauth_client_id]}",
+ clientSecret: "${[oauth_client_secret]}",
+ authorizationUrl: "https://api.example.com/oauth/authorize",
+ accessTokenUrl: "https://api.example.com/oauth/token",
+ }),
+ expect.objectContaining({
+ clientId: "${[oauth_client_id]}",
+ clientSecret: "${[oauth_client_secret]}",
+ }),
+ ]);
+ });
+
+ test("Uses the server environment origin for OAuth endpoints with a path-only API base", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.4",
+ info: { title: "Path-only OAuth Test", version: "1.0.0" },
+ servers: [{ url: "/api/v1" }],
+ paths: {
+ "/users": { get: { security: [{ oauth: [] }], responses: {} } },
+ },
+ components: {
+ securitySchemes: {
+ oauth: {
+ type: "oauth2",
+ flows: {
+ authorizationCode: {
+ authorizationUrl: "oauth/authorize",
+ tokenUrl: "/oauth/token",
+ scopes: {},
+ },
+ },
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
+ expect.objectContaining({
+ authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize",
+ accessTokenUrl: "${[baseUrlOrigin]}/oauth/token",
+ }),
+ );
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({ name: "Global Variables", variables: [] }),
+ expect.objectContaining({
+ name: "Server 1",
+ variables: [
+ { name: "baseUrl", value: "/api/v1" },
+ { name: "oauth_client_id", value: "" },
+ { name: "oauth_client_secret", value: "" },
+ { name: "baseUrlOrigin", value: "" },
+ ],
+ }),
+ ]);
});
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
@@ -335,8 +627,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "authorization_code",
- clientId: "",
- clientSecret: "",
+ clientId: "${[oauth_client_id]}",
+ clientSecret: "${[oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "admin",
authorizationUrl: "https://example.com/authorize",
@@ -495,7 +787,8 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
- bodyType: "application/xml",
+ // Yaak's XML body type; the header keeps the spec's media type
+ bodyType: "text/xml",
headers: expect.arrayContaining([
{ enabled: true, name: "Content-Type", value: "application/xml" },
]),
@@ -523,16 +816,1505 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
authenticationType: "basic",
- authentication: { username: "", password: "" },
+ authentication: {
+ username: "${[auth_basic_auth_username]}",
+ password: "${[auth_basic_auth_password]}",
+ },
}),
);
// The auth plugin has no cookie location, so it becomes the Cookie header
expect(imported?.resources.httpRequests[1]).toEqual(
expect.objectContaining({
authenticationType: "apikey",
- authentication: { location: "header", key: "Cookie", value: "session=" },
+ authentication: {
+ location: "header",
+ key: "Cookie",
+ value: "session=${[auth_cookie_key_key]}",
+ },
}),
);
+ expect(imported?.resources.environments).toEqual([
+ expect.objectContaining({
+ name: "Global Variables",
+ variables: [],
+ }),
+ expect.objectContaining({
+ name: "Server 1",
+ variables: [
+ { name: "baseUrl", value: "https://example.com" },
+ { name: "auth_basic_auth_username", value: "" },
+ { name: "auth_basic_auth_password", value: "" },
+ { name: "auth_cookie_key_key", value: "" },
+ ],
+ }),
+ ]);
+ });
+
+ test("Respects the order of security alternatives and explicit auth overrides", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "Optional Auth", version: "1.0.0" },
+ security: [{ bearerAuth: [] }],
+ paths: {
+ "/optional-auth-first": {
+ get: { security: [{ bearerAuth: [] }, {}], responses: {} },
+ },
+ "/optional-anonymous-first": {
+ get: { security: [{}, { bearerAuth: [] }], responses: {} },
+ },
+ "/public": { get: { security: [], responses: {} } },
+ },
+ components: {
+ securitySchemes: {
+ bearerAuth: { type: "http", scheme: "bearer" },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.workspaces[0]).toEqual(
+ expect.objectContaining({
+ authenticationType: "bearer",
+ authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
+ }),
+ );
+ expect(imported?.resources.httpRequests).toEqual([
+ // The author listed bearer before the anonymous alternative
+ expect.objectContaining({
+ authenticationType: "bearer",
+ authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
+ }),
+ expect.objectContaining({ authenticationType: "none", authentication: {} }),
+ expect.objectContaining({ authenticationType: "none", authentication: {} }),
+ ]);
+ });
+
+ test("Imports spec-level security as inherited workspace authentication", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "Inherited Auth", version: "1.0.0" },
+ security: [{ bearerAuth: [], queryKey: [], headerKey: [] }],
+ paths: {
+ "/inherits": { get: { responses: {} } },
+ "/own-auth": { get: { security: [{ otherKey: [] }], responses: {} } },
+ "/public": { get: { security: [], responses: {} } },
+ },
+ components: {
+ securitySchemes: {
+ bearerAuth: { type: "http", scheme: "bearer" },
+ queryKey: { type: "apiKey", in: "query", name: "api_key" },
+ headerKey: { type: "apiKey", in: "header", name: "X-Tenant" },
+ otherKey: { type: "apiKey", in: "header", name: "X-Other" },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.workspaces[0]).toEqual(
+ expect.objectContaining({
+ authenticationType: "bearer",
+ authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
+ }),
+ );
+ expect(imported?.resources.httpRequests).toEqual([
+ // No authenticationType means inherit; materialized API keys ride along
+ // on the request since headers or parameters at the workspace level
+ // would also reach operations that opted out
+ expect.objectContaining({
+ authentication: {},
+ headers: [{ enabled: true, name: "X-Tenant", value: "${[auth_header_key_key]}" }],
+ urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
+ }),
+ expect.objectContaining({
+ authenticationType: "apikey",
+ authentication: expect.objectContaining({ key: "X-Other" }),
+ headers: [],
+ urlParameters: [],
+ }),
+ expect.objectContaining({
+ authenticationType: "none",
+ headers: [],
+ urlParameters: [],
+ }),
+ ]);
+ expect(imported?.resources.httpRequests[0]?.authenticationType).toBeNull();
+ });
+
+ test("Serializes array query parameters per style", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Array Params", version: "1.0.0" },
+ paths: {
+ "/exploded": {
+ get: {
+ parameters: [
+ {
+ name: "tags",
+ in: "query",
+ required: true,
+ schema: { type: "array", items: { type: "string" }, example: ["a", "b"] },
+ },
+ ],
+ responses: {},
+ },
+ },
+ "/csv": {
+ get: {
+ parameters: [
+ {
+ name: "ids",
+ in: "query",
+ required: true,
+ explode: false,
+ schema: { type: "array", example: [1, 2, 3] },
+ },
+ ],
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests.map((r) => r.urlParameters)).toEqual([
+ [
+ { enabled: true, name: "tags", value: "a" },
+ { enabled: true, name: "tags", value: "b" },
+ ],
+ [{ enabled: true, name: "ids", value: "1,2,3" }],
+ ]);
+ });
+
+ test("Serializes Swagger 2 array parameters per collectionFormat", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ swagger: "2.0",
+ info: { title: "Swagger Arrays", version: "1.0.0" },
+ host: "example.com",
+ paths: {
+ "/a": {
+ get: {
+ parameters: [
+ {
+ name: "tags",
+ in: "query",
+ required: true,
+ type: "array",
+ items: { type: "string", default: "x" },
+ },
+ {
+ name: "multi",
+ in: "query",
+ required: true,
+ type: "array",
+ collectionFormat: "multi",
+ items: { type: "string", enum: ["m1"] },
+ },
+ ],
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
+ // Swagger defaults to csv
+ { enabled: true, name: "tags", value: "x" },
+ { enabled: true, name: "multi", value: "m1" },
+ ]);
+ });
+
+ test("Imports AND security requirements without dropping API keys", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "Combined Auth", version: "1.0.0" },
+ paths: {
+ "/combined": {
+ get: {
+ security: [{ bearerAuth: [], tenantKey: [], queryKey: [] }],
+ parameters: [
+ {
+ in: "header",
+ name: "X-Tenant-Key",
+ example: "operation-value-must-not-replace-auth",
+ },
+ ],
+ responses: {},
+ },
+ },
+ },
+ components: {
+ securitySchemes: {
+ bearerAuth: { type: "http", scheme: "bearer" },
+ tenantKey: { type: "apiKey", in: "header", name: "X-Tenant-Key" },
+ queryKey: { type: "apiKey", in: "query", name: "api_key" },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests).toEqual([
+ expect.objectContaining({
+ authenticationType: "bearer",
+ authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
+ headers: [{ enabled: true, name: "X-Tenant-Key", value: "${[auth_tenant_key_key]}" }],
+ urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
+ }),
+ ]);
+ });
+
+ test("Keeps distinct credentials for security scheme names that normalize alike", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "Auth Variable Names", version: "1.0.0" },
+ paths: {
+ "/hyphen": { get: { security: [{ "api-key": [] }], responses: {} } },
+ "/underscore": { get: { security: [{ api_key: [] }], responses: {} } },
+ "/hyphen-again": { get: { security: [{ "api-key": [] }], responses: {} } },
+ },
+ components: {
+ securitySchemes: {
+ "api-key": { type: "apiKey", in: "header", name: "X-Hyphen-Key" },
+ api_key: { type: "apiKey", in: "header", name: "X-Underscore-Key" },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.environments[0]?.variables).toEqual([]);
+ expect(imported?.resources.environments[1]).toEqual(
+ expect.objectContaining({
+ name: "Default",
+ variables: [
+ { name: "baseUrl", value: "" },
+ { name: "auth_api_key_key", value: "" },
+ { name: "auth_api_key_key_2", value: "" },
+ ],
+ }),
+ );
+ expect(imported?.resources.httpRequests).toEqual([
+ expect.objectContaining({
+ authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
+ }),
+ expect.objectContaining({
+ authentication: expect.objectContaining({ value: "${[auth_api_key_key_2]}" }),
+ }),
+ expect.objectContaining({
+ authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
+ }),
+ ]);
+ });
+
+ test("Imports OpenID Connect as bearer authentication", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "OpenID Connect", version: "1.0.0" },
+ paths: { "/me": { get: { security: [{ oidc: [] }], responses: {} } } },
+ components: {
+ securitySchemes: {
+ oidc: {
+ type: "openIdConnect",
+ openIdConnectUrl: "https://accounts.example.com/.well-known/openid-configuration",
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests).toEqual([
+ expect.objectContaining({
+ authenticationType: "bearer",
+ authentication: { token: "${[auth_oidc_token]}", prefix: "Bearer" },
+ }),
+ ]);
+ });
+
+ test("Resolves references that point into arrays", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Array Ref Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ get: {
+ parameters: [
+ { name: "limit", in: "query", required: true, schema: { example: "42" } },
+ ],
+ responses: {},
+ },
+ },
+ "/b": {
+ get: { parameters: [{ $ref: "#/paths/~1a/get/parameters/0" }], responses: {} },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[1]?.urlParameters).toEqual([
+ { enabled: true, name: "limit", value: "42" },
+ ]);
+ });
+
+ test("Resolves example references and 3.1 example arrays", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "Examples Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ post: {
+ parameters: [
+ { name: "q", in: "query", schema: { type: "string", examples: ["hello"] } },
+ ],
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: { type: "object" },
+ examples: { main: { $ref: "#/components/examples/Main" } },
+ },
+ },
+ },
+ responses: {},
+ },
+ },
+ },
+ components: { examples: { Main: { value: { x: "from-example" } } } },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.body).toEqual({
+ text: JSON.stringify({ x: "from-example" }, null, 2),
+ });
+ expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
+ { enabled: false, name: "q", value: "hello" },
+ ]);
+ });
+
+ test("Keeps template-looking braces in descriptions and examples", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Braces Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ post: {
+ description: "Use {{placeholders}} in the template",
+ requestBody: {
+ content: {
+ "application/json": { schema: { type: "string", example: "Hi {{name}}" } },
+ },
+ },
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}");
+ // 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 () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Reserved Headers Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ post: {
+ parameters: [
+ { name: "Content-Type", in: "header", schema: { example: "application/xml" } },
+ { name: "Accept", in: "header", schema: { example: "text/html" } },
+ { name: "Authorization", in: "header", schema: { example: "custom" } },
+ { name: "X-Custom", in: "header", schema: { example: "kept" } },
+ ],
+ requestBody: { content: { "application/json": { schema: { type: "object" } } } },
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.headers).toEqual([
+ { enabled: false, name: "X-Custom", value: "kept" },
+ { enabled: true, name: "Content-Type", value: "application/json" },
+ ]);
+ });
+
+ test("Imports cookie parameters as a Cookie header", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Cookie Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ get: {
+ parameters: [
+ { name: "session", in: "cookie", required: true, schema: { example: "abc" } },
+ { name: "theme", in: "cookie", schema: { example: "dark" } },
+ ],
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ // 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" },
+ { enabled: false, name: "Cookie", value: "theme=dark" },
+ ]);
+ });
+
+ test("Inlines path templates that Yaak placeholders cannot express", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Mid-Segment Test", version: "1.0.0" },
+ paths: {
+ "/report.{format}": {
+ get: {
+ parameters: [
+ { name: "format", in: "path", required: true, schema: { example: "csv" } },
+ ],
+ responses: {},
+ },
+ },
+ "/tasks/{id}:cancel": {
+ post: {
+ parameters: [{ name: "id", in: "path", required: true, schema: { example: "7" } }],
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests.map((r) => [r.url, r.urlParameters])).toEqual([
+ ["${[baseUrl]}/report.csv", []],
+ ["${[baseUrl]}/tasks/:id:cancel", [{ enabled: true, name: ":id", value: "7" }]],
+ ]);
+ });
+
+ test("Enables path parameters even when required is omitted", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Sloppy Path Test", version: "1.0.0" },
+ paths: {
+ "/users/{userId}": {
+ get: {
+ parameters: [{ name: "userId", in: "path", schema: { type: "string" } }],
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ // No example either, so the name stands in for an empty path segment
+ expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
+ { enabled: true, name: ":userId", value: "userId" },
+ ]);
+ });
+
+ test("Coerces examples to the declared schema type", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Coercion Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ post: {
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ password: { type: "string", example: 12345 },
+ count: { type: "integer", example: "3" },
+ note: { example: 7 },
+ },
+ },
+ },
+ },
+ },
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.body).toEqual({
+ text: JSON.stringify({ password: "12345", count: 3, note: 7 }, null, 2),
+ });
+ });
+
+ test("Merges allOf branches with sibling properties", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.1.0",
+ info: { title: "AllOf Test", version: "1.0.0" },
+ paths: {
+ "/a": {
+ post: {
+ requestBody: {
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ allOf: [
+ { type: "object", properties: { fromAllOf: { example: "a" } } },
+ ],
+ properties: { sibling: { example: "b" } },
+ },
+ },
+ },
+ },
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.body).toEqual({
+ text: JSON.stringify({ fromAllOf: "a", sibling: "b" }, null, 2),
+ });
+ });
+
+ test("Accepts unquoted YAML version numbers", async () => {
+ const imported = await convertOpenApi(
+ ["swagger: 2.0", "info:", " title: Unquoted Test", ' version: "1"', "host: example.com", "paths:", " /a:", " get:", " responses: {}"].join("\n"),
+ );
+
+ expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/a");
+ // The numeric version must feed server detection too, or the selectable
+ // environment overrides baseUrl with an empty value
+ expect(imported?.resources.environments[1]).toEqual(
+ expect.objectContaining({
+ name: "Server 1",
+ variables: [{ name: "baseUrl", value: "https://example.com" }],
+ }),
+ );
+ });
+
+ test("Normalizes body types to Yaak's editors", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Body Type Test", version: "1.0.0" },
+ paths: {
+ "/vnd-json": {
+ post: {
+ requestBody: {
+ content: { "application/vnd.api+json": { schema: { type: "object" } } },
+ },
+ responses: {},
+ },
+ },
+ "/plain": {
+ post: {
+ requestBody: { content: { "text/plain": { schema: { type: "string" } } } },
+ responses: {},
+ },
+ },
+ },
+ }),
+ );
+
+ expect(
+ imported?.resources.httpRequests.map((r) => [r.bodyType, r.headers?.[0]?.value]),
+ ).toEqual([
+ ["application/json", "application/vnd.api+json"],
+ ["other", "text/plain"],
+ ]);
+ });
+
+ test("Trims trailing slashes from server URLs", async () => {
+ const imported = await convertOpenApi(
+ JSON.stringify({
+ openapi: "3.0.0",
+ info: { title: "Trailing Slash Test", version: "1.0.0" },
+ servers: [{ url: "https://api.example.com/v1/" }],
+ paths: { "/pets": { get: { responses: {} } } },
+ }),
+ );
+
+ expect(imported?.resources.environments[1]?.variables).toEqual([
+ { name: "baseUrl", value: "https://api.example.com/v1" },
+ ]);
+ });
+
+ 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 () => {
diff --git a/plugins/importer-openapi/tests/roundtrip.mjs b/plugins/importer-openapi/tests/roundtrip.mjs
new file mode 100644
index 00000000..5b3637ce
--- /dev/null
+++ b/plugins/importer-openapi/tests/roundtrip.mjs
@@ -0,0 +1,306 @@
+// Round-trip harness: import a spec with the local importer plugin, send every
+// request through the real Yaak CLI pipeline against a Prism mock of the same
+// spec, and report Prism's validation verdict for each request.
+//
+// Prism independently validates each incoming request against the spec (paths,
+// required parameters, body schemas, security), so a violation here is an
+// importer bug found by a second OpenAPI implementation rather than a snapshot
+// of our own output.
+//
+// Usage: node tests/roundtrip.mjs [spec.yaml ...]
+// YAAK_BIN=/path/to/yaak overrides the CLI (defaults to the repo debug build,
+// which embeds the plugins vendored from this checkout).
+
+import { execFileSync, spawn } from "node:child_process";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(here, "../../..");
+const yaakBin = process.env.YAAK_BIN ?? path.join(repoRoot, "target/debug/yaak");
+const specs =
+ process.argv.length > 2
+ ? process.argv.slice(2)
+ : [
+ path.join(here, "fixtures/petstore.yaml"),
+ ...fs
+ .readdirSync(path.join(here, "fixtures/real-world"))
+ .filter((f) => f.endsWith(".yaml"))
+ .map((f) => path.join(here, "fixtures/real-world", f)),
+ ];
+
+if (!fs.existsSync(yaakBin)) {
+ console.error(`Yaak CLI not found at ${yaakBin}. Build it with: cargo build -p yaak-cli`);
+ process.exit(2);
+}
+
+// Pinned so local runs and CI judge against the same validator
+const PRISM_PACKAGE = "@stoplight/prism-cli@5.15.11";
+
+// Accepted spec-quality gray zones, not importer bugs. httpbin's required
+// `url` query parameter has no example, and an empty value is preferable to
+// inventing fake query data even though Prism counts it as missing.
+const KNOWN_FLAGS = new Set(["httpbin.yaml GET ${[baseUrl]}/redirect-to"]);
+
+function yaak(dataDir, args) {
+ return execFileSync(yaakBin, ["--data-dir", dataDir, ...args], {
+ encoding: "utf-8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+}
+
+function yaakJson(dataDir, args) {
+ return JSON.parse(yaak(dataDir, args));
+}
+
+function listIds(output) {
+ return output
+ .split("\n")
+ .map((line) => line.match(/^(\w+_\w+) - /)?.[1])
+ .filter(Boolean);
+}
+
+async function startPrism(spec, port) {
+ for (let attempt = 0; attempt < 20; attempt++, port++) {
+ const prism = spawn(
+ "npx",
+ ["-y", PRISM_PACKAGE, "mock", "--errors", "-p", String(port), "-h", "127.0.0.1", spec],
+ { stdio: ["ignore", "pipe", "pipe"] },
+ );
+ let log = "";
+ prism.stdout.on("data", (d) => (log += d));
+ prism.stderr.on("data", (d) => (log += d));
+ // Generous: the first run downloads Prism through npx
+ const deadline = Date.now() + 120_000;
+ let failed = false;
+ while (Date.now() < deadline) {
+ if (log.includes("Prism is listening")) return { prism, port, getLog: () => log };
+ if (log.includes("EADDRINUSE") || prism.exitCode != null) {
+ failed = true;
+ break;
+ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ prism.kill();
+ if (failed) {
+ // The exit can be observed before its buffered stderr arrives; wait for
+ // the streams to close so the bind error is distinguishable
+ await Promise.race([
+ new Promise((r) => prism.once("close", r)),
+ new Promise((r) => setTimeout(r, 2000)),
+ ]);
+ if (log.includes("EADDRINUSE")) continue;
+ throw new Error(`Prism exited:\n${log}`);
+ }
+ throw new Error(`Prism did not start in time:\n${log}`);
+ }
+ throw new Error("No free port found for Prism");
+}
+
+function pointVariablesAtPrism(variables, prismUrl) {
+ return variables.map((v) => {
+ if (v.name === "baseUrl" || v.name.startsWith("serverUrl")) return { ...v, value: prismUrl };
+ if (v.name === "baseUrlOrigin") return { ...v, value: prismUrl };
+ if (v.value === "") return { ...v, value: "test-value" };
+ return v;
+ });
+}
+
+// Prism reports its verdict in the sl-violations response header. Violations
+// located in the request are importer bugs; violations located in the response
+// mean Prism could not fabricate a spec-valid mock response (the spec's own
+// examples are broken), which says nothing about the import.
+function classify(response, bodyText) {
+ if (response.error) return { verdict: "SEND ERROR", detail: response.error };
+
+ const violationsHeader = (response.headers ?? []).find(
+ (h) => h.name?.toLowerCase() === "sl-violations",
+ );
+ let violations = [];
+ try {
+ violations = JSON.parse(violationsHeader?.value ?? "[]");
+ } catch {}
+ const requestViolations = violations.filter((v) => v.location?.[0] === "request");
+ const detail = requestViolations.map((v) => `${v.location.join(".")}: ${v.message}`).join("; ");
+
+ if (requestViolations.length > 0) return { verdict: "VIOLATION", detail };
+
+ // A spec-defined error response (e.g. an operation whose only response is a
+ // 405) mocks as that status with no Prism error type; only Prism's own error
+ // bodies mark a request Prism could not accept.
+ let body = null;
+ try {
+ body = JSON.parse(bodyText);
+ } catch {}
+ const prismError =
+ typeof body?.type === "string" && body.type.includes("stoplight.io/prism/errors")
+ ? body.type.split("#")[1]
+ : null;
+ if (prismError == null) return { verdict: "ok", detail: "" };
+
+ // Failures to fabricate a mock response say nothing about the request we sent
+ if (prismError === "NO_COMPLEX_OBJECT_TEXT" || prismError === "NO_RESPONSE_DEFINED") {
+ return { verdict: "ok", detail: "" };
+ }
+
+ const bodyViolations = Array.isArray(body.validation)
+ ? body.validation.filter((v) => v.location?.[0] === "request")
+ : [];
+ if (prismError === "VIOLATIONS" && bodyViolations.length === 0) {
+ return { verdict: "ok", detail: "" }; // response-side only
+ }
+ return {
+ verdict:
+ prismError === "VIOLATIONS" || prismError === "UNPROCESSABLE_ENTITY"
+ ? "VIOLATION"
+ : prismError.includes("MATCHED")
+ ? "NO ROUTE"
+ : prismError === "UNAUTHORIZED"
+ ? "SECURITY"
+ : prismError,
+ detail:
+ bodyViolations.map((v) => `${v.location.join(".")}: ${v.message}`).join("; ") ||
+ body.detail ||
+ "",
+ };
+}
+
+let totalProblems = 0;
+let port = 4010;
+
+for (const spec of specs) {
+ const name = path.basename(spec);
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "yaak-roundtrip-"));
+ console.log(`\n=== ${name} ===`);
+
+ try {
+ yaak(dataDir, ["import", spec]);
+ const workspaceId = listIds(yaak(dataDir, ["workspace", "list"]))[0];
+ if (workspaceId == null) {
+ console.log(" IMPORT PRODUCED NO WORKSPACE");
+ totalProblems++;
+ continue;
+ }
+ // Prism mocks redirect responses without a Location header; following them
+ // would fail the send for a reason unrelated to the import. Workspace-level
+ // OAuth2 gets the same dummy-bearer treatment as request-level below.
+ const workspace = yaakJson(dataDir, ["workspace", "show", workspaceId]);
+ yaak(dataDir, [
+ "workspace",
+ "update",
+ "--json",
+ JSON.stringify({
+ id: workspaceId,
+ settingFollowRedirects: false,
+ ...(workspace.authenticationType === "oauth2"
+ ? {
+ authenticationType: "bearer",
+ authentication: { token: "test-token", prefix: "Bearer" },
+ }
+ : {}),
+ }),
+ ]);
+
+ const { prism, port: boundPort, getLog } = await startPrism(spec, port);
+ port = boundPort + 1;
+ try {
+ const prismUrl = `http://127.0.0.1:${boundPort}`;
+ const environmentIds = listIds(yaak(dataDir, ["environment", "list", workspaceId]));
+ let activeEnvironment = null;
+ for (const id of environmentIds) {
+ const environment = yaakJson(dataDir, ["environment", "show", id]);
+ yaak(dataDir, [
+ "environment",
+ "update",
+ "--json",
+ JSON.stringify({
+ id,
+ variables: pointVariablesAtPrism(environment.variables ?? [], prismUrl),
+ }),
+ ]);
+ if (environment.parentModel === "environment" && activeEnvironment == null) {
+ activeEnvironment = id;
+ }
+ }
+
+ const requestIds = listIds(yaak(dataDir, ["request", "list", workspaceId]));
+ const requests = new Map();
+ for (const id of requestIds) {
+ const request = yaakJson(dataDir, ["request", "show", id]);
+ requests.set(id, request);
+ // OAuth2 would try to fetch a real token; Prism only checks that the
+ // Authorization header is present, so a dummy bearer keeps it satisfied.
+ if (request.authenticationType === "oauth2") {
+ yaak(dataDir, [
+ "request",
+ "update",
+ "--json",
+ JSON.stringify({
+ id,
+ authenticationType: "bearer",
+ authentication: { token: "test-token", prefix: "Bearer" },
+ }),
+ ]);
+ }
+ }
+
+ const sendArgs = ["send", workspaceId];
+ if (activeEnvironment != null) sendArgs.push("-e", activeEnvironment);
+ try {
+ yaak(dataDir, sendArgs);
+ } catch {
+ // Individual send failures surface per-request below.
+ }
+
+ let problems = 0;
+ for (const id of requestIds) {
+ const request = requests.get(id);
+ const label = `${request.method} ${request.url}`;
+ let response = null;
+ let bodyText = "";
+ try {
+ response = yaakJson(dataDir, ["response", "show", id]);
+ try {
+ bodyText = yaak(dataDir, ["response", "body", id]);
+ } catch {}
+ } catch {
+ console.log(` NEVER SENT ${label}`);
+ problems++;
+ continue;
+ }
+ const { verdict, detail } = classify(response, bodyText);
+ if (verdict === "ok") continue;
+ if (KNOWN_FLAGS.has(`${name} ${label}`)) {
+ console.log(` known ${label}`);
+ continue;
+ }
+ problems++;
+ console.log(` ${verdict.padEnd(12)} ${label}`);
+ console.log(` sent: ${response.url ?? "?"}`);
+ if (detail) console.log(` ${detail}`);
+ }
+
+ const requestCount = requestIds.length;
+ if (problems === 0) {
+ console.log(` all ${requestCount} requests validated clean against the mock`);
+ } else {
+ console.log(` ${problems}/${requestCount} requests flagged`);
+ totalProblems += problems;
+ }
+ const inputWarnings = getLog()
+ .split("\n")
+ .filter((l) => l.includes("[VALIDATOR]") && !l.includes("output"));
+ if (inputWarnings.length > 0) {
+ console.log(` prism validator log lines: ${inputWarnings.length}`);
+ }
+ } finally {
+ prism.kill();
+ }
+ } finally {
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ }
+}
+
+process.exit(totalProblems > 0 ? 1 : 0);
|