mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-21 19:04:05 +02:00
Merge branch 'main' into claude/serene-dewdney-4c1527
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}'`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("@")) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1314
-189
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);
|
||||
Reference in New Issue
Block a user