Merge branch 'main' into claude/serene-dewdney-4c1527

This commit is contained in:
Gregory Schier
2026-08-20 09:06:44 -07:00
committed by GitHub
21 changed files with 4025 additions and 324 deletions
+4
View File
@@ -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
@@ -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 }) => <table>{children}</table>,
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
}));
describe("CsvViewer", () => {
test("renders columns that extend beyond the first row", () => {
const markup = renderToStaticMarkup(
<CsvViewerInner
text={[
"startDate,2026-02-03T00:00-03:00",
"endDate,2026-02-03T23:59:59-03:00",
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
].join("\n")}
/>,
);
expect(markup).toContain("ID de usuario");
expect(markup).toContain("42041");
expect(markup.match(/<td>/g)).toHaveLength(20);
});
});
@@ -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<Record<string, string>>(text, { header: true, skipEmptyLines: true });
return Papa.parse<string[]>(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 (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
{columnIndexes.map((columnIndex) => (
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ""}</TableCell>
{row.map((cell, columnIndex) => (
// oxlint-disable-next-line react/no-array-index-key
<TableCell key={columnIndex}>{cell}</TableCell>
))}
</TableRow>
))}
+136 -11
View File
@@ -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<String, serde_json::Va
}
fn build_headers(r: &HttpRequest) -> 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() {
+2 -4
View File
@@ -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(
@@ -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(
@@ -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::<Vec<_>>();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].value, "required=1");
assert_eq!(cookies[1].value, "optional=1");
assert!(!cookies[1].enabled);
}
}
+54 -16
View File
@@ -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<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
let mut index_by_name: HashMap<String, usize> = HashMap::new();
let mut deduped: Vec<HttpRequestHeader> = 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<HttpRequestHeader>,
child: Vec<HttpRequestHeader>,
) -> Vec<HttpRequestHeader> {
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
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<_>>(),
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<_>>(),
vec![("X-Parent", "kept"), ("accept", "application/json")],
);
}
}
@@ -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<Vec<HttpRequestHeader>> {
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(
+2 -3
View File
@@ -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<HttpRequestHeader> {
let mut headers = default_headers();
headers.extend(workspace.headers.clone());
headers
merge_headers(default_headers(), workspace.headers.clone())
}
pub fn resolve_settings_for_workspace(
+7 -2
View File
@@ -83,7 +83,9 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
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<HttpRequest>) {
}
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}'`;
}
+39 -2
View File
@@ -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({
+4 -1
View File
@@ -129,7 +129,10 @@ export async function convert(request: Partial<GrpcRequest>, 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}'`;
}
@@ -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'`);
});
});
+46 -4
View File
@@ -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("@")) {
+101
View File
@@ -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"),
+2 -1
View File
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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.<br/> <br/> <b>Run locally: </b> <code>$ docker run -p 80:80 kennethreitz/httpbin</code>
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",
File diff suppressed because it is too large Load Diff
@@ -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);