mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-28 06:14:03 +02:00
Merge branch 'main' into claude/serene-dewdney-4c1527
This commit is contained in:
@@ -31,3 +31,7 @@ jobs:
|
|||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --features yaak-app-client/wry
|
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 }) {
|
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||||
const parsed = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
if (text == null) return null;
|
if (text == null) return null;
|
||||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
if (parsed === null) return null;
|
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 (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<Table className={classNames(className, "text-sm")}>
|
<Table className={classNames(className, "text-sm")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{parsed.meta.fields?.map((field) => (
|
{columnIndexes.map((columnIndex) => (
|
||||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{parsed.data.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
{parsed.meta.fields?.map((key) => (
|
{row.map((cell, columnIndex) => (
|
||||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
|
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+136
-11
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_header(&mut self, header: (String, String)) {
|
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) =
|
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;
|
existing.1 = header.1;
|
||||||
} else {
|
} 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)> {
|
fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
|
||||||
r.headers
|
// RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into
|
||||||
.iter()
|
// the first one
|
||||||
.filter_map(|h| {
|
let mut headers: Vec<(String, String)> = Vec::new();
|
||||||
if h.enabled && !h.name.is_empty() {
|
for h in &r.headers {
|
||||||
Some((h.name.clone(), h.value.clone()))
|
if !h.enabled || h.name.is_empty() {
|
||||||
} else {
|
continue;
|
||||||
None
|
}
|
||||||
|
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(
|
async fn build_body(
|
||||||
@@ -494,7 +512,114 @@ mod tests {
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::BTreeMap;
|
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]
|
#[test]
|
||||||
fn test_build_url_no_params() {
|
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::client_db::ClientDb;
|
||||||
use crate::connection_or_tx::ConnectionOrTx;
|
use crate::connection_or_tx::ConnectionOrTx;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.append(&mut workspace_headers);
|
headers.append(&mut workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut folder.headers.clone());
|
Ok(merge_headers(headers, folder.headers.clone()))
|
||||||
|
|
||||||
Ok(headers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_folder(
|
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::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
metadata.append(&mut workspace_metadata);
|
metadata.append(&mut workspace_metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.append(&mut grpc_request.metadata.clone());
|
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(metadata))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_grpc_request(
|
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::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.append(&mut workspace_headers);
|
headers.append(&mut workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut http_request.headers.clone());
|
Ok(merge_headers(headers, http_request.headers.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(headers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_http_request(
|
pub fn resolve_settings_for_http_request(
|
||||||
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
|
|||||||
Ok(children)
|
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;
|
const MAX_HISTORY_ITEMS: usize = 20;
|
||||||
|
|
||||||
use crate::models::HttpRequestHeader;
|
use crate::models::HttpRequestHeader;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
|
/// Merge a more-specific header layer over its parent. Names in the child replace
|
||||||
/// Preserves the order of first occurrence for each header name.
|
/// inherited values case-insensitively, while duplicates declared together in
|
||||||
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
|
/// either layer remain independent entries.
|
||||||
let mut index_by_name: HashMap<String, usize> = HashMap::new();
|
pub(crate) fn merge_headers(
|
||||||
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
|
mut parent: Vec<HttpRequestHeader>,
|
||||||
for header in headers {
|
child: Vec<HttpRequestHeader>,
|
||||||
let key = header.name.to_lowercase();
|
) -> Vec<HttpRequestHeader> {
|
||||||
if let Some(&idx) = index_by_name.get(&key) {
|
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
|
||||||
deduped[idx] = header;
|
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
|
||||||
} else {
|
parent.extend(child);
|
||||||
index_by_name.insert(key, deduped.len());
|
parent
|
||||||
deduped.push(header);
|
}
|
||||||
}
|
|
||||||
}
|
#[cfg(test)]
|
||||||
deduped
|
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::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
|
|||||||
&self,
|
&self,
|
||||||
websocket_request: &WebsocketRequest,
|
websocket_request: &WebsocketRequest,
|
||||||
) -> Result<Vec<HttpRequestHeader>> {
|
) -> Result<Vec<HttpRequestHeader>> {
|
||||||
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
|
|
||||||
|
|
||||||
// Resolved headers should be from furthest to closest ancestor, to override logically.
|
// Resolved headers should be from furthest to closest ancestor, to override logically.
|
||||||
let mut headers = Vec::new();
|
let mut headers = Vec::new();
|
||||||
|
|
||||||
headers.append(&mut workspace.headers.clone());
|
|
||||||
|
|
||||||
if let Some(folder_id) = websocket_request.folder_id.clone() {
|
if let Some(folder_id) = websocket_request.folder_id.clone() {
|
||||||
let parent_folder = self.get_folder(&folder_id)?;
|
let parent_folder = self.get_folder(&folder_id)?;
|
||||||
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
|
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 workspace_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append(&mut websocket_request.headers.clone());
|
Ok(merge_headers(headers, websocket_request.headers.clone()))
|
||||||
|
|
||||||
Ok(dedupe_headers(headers))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_websocket_request(
|
pub fn resolve_settings_for_websocket_request(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::merge_headers;
|
||||||
use crate::blob_manager::BlobManager;
|
use crate::blob_manager::BlobManager;
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
||||||
let mut headers = default_headers();
|
merge_headers(default_headers(), workspace.headers.clone())
|
||||||
headers.extend(workspace.headers.clone());
|
|
||||||
headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_settings_for_workspace(
|
pub fn resolve_settings_for_workspace(
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
|
|||||||
if (p.file) {
|
if (p.file) {
|
||||||
let v = `${p.name}=@${p.file}`;
|
let v = `${p.name}=@${p.file}`;
|
||||||
v += p.contentType ? `;type=${p.contentType}` : "";
|
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 {
|
} else {
|
||||||
xs.push(flag, quote(`${p.name}=${p.value}`));
|
xs.push(flag, quote(`${p.name}=${p.value}`));
|
||||||
}
|
}
|
||||||
@@ -157,7 +159,10 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function quote(arg: string): string {
|
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}'`;
|
return `'${escaped}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("exporter-curl", () => {
|
|||||||
`curl -X PUT 'https://yaak.app'`,
|
`curl -X PUT 'https://yaak.app'`,
|
||||||
`--form 'a=aaa'`,
|
`--form 'a=aaa'`,
|
||||||
`--form 'b=bbb'`,
|
`--form 'b=bbb'`,
|
||||||
"--form f=@/foo/bar.png;type=image/png",
|
"--form 'f=@/foo/bar.png;type=image/png'",
|
||||||
].join(" \\\n "),
|
].join(" \\\n "),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -140,11 +140,48 @@ describe("exporter-curl", () => {
|
|||||||
[
|
[
|
||||||
`curl -X POST 'https://yaak.app'`,
|
`curl -X POST 'https://yaak.app'`,
|
||||||
`--header 'Content-Type: application/json'`,
|
`--header 'Content-Type: application/json'`,
|
||||||
`--data '{"foo":"bar\\'s"}'`,
|
`--data '{"foo":"bar'\\''s"}'`,
|
||||||
].join(" \\\n "),
|
].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 () => {
|
test("Exports multi-line JSON body", async () => {
|
||||||
expect(
|
expect(
|
||||||
await convertToCurl({
|
await convertToCurl({
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function quote(arg: string): string {
|
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}'`;
|
return `'${escaped}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,4 +175,21 @@ describe("exporter-curl", () => {
|
|||||||
].join(" \\\n "),
|
].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,
|
form: multipartFormDataFromRaw,
|
||||||
};
|
};
|
||||||
} else if (dataParameters.length > 0 && bodyAsGET) {
|
} 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 (
|
} else if (
|
||||||
dataParameters.length > 0 &&
|
dataParameters.length > 0 &&
|
||||||
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
|
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
|
||||||
@@ -515,8 +525,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
body = {
|
body = {
|
||||||
form: dataParameters.map((parameter) => ({
|
form: dataParameters.map((parameter) => ({
|
||||||
...parameter,
|
...parameter,
|
||||||
name: decodeURIComponent(parameter.name || ""),
|
name: decodePercentEncoding(parameter.name),
|
||||||
value: decodeURIComponent(parameter.value || ""),
|
value: decodePercentEncoding(parameter.value),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
filteredHeaders.push({
|
filteredHeaders.push({
|
||||||
@@ -593,6 +603,34 @@ interface DataParameter {
|
|||||||
enabled?: boolean;
|
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[] {
|
function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
|
||||||
const dataParameters: DataParameter[] = [];
|
const dataParameters: DataParameter[] = [];
|
||||||
|
|
||||||
@@ -605,7 +643,11 @@ function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
|
|||||||
|
|
||||||
for (const p of pairs) {
|
for (const p of pairs) {
|
||||||
if (typeof p !== "string") continue;
|
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) {
|
for (const param of params) {
|
||||||
const [name, value] = splitOnce(param, "=");
|
const [name, value] = splitOnce(param, "=");
|
||||||
if (param.startsWith("@")) {
|
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", () => {
|
test("Imports data params as text", () => {
|
||||||
expect(
|
expect(
|
||||||
convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"),
|
convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"),
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yaakcli build",
|
"build": "yaakcli build",
|
||||||
"dev": "yaakcli dev",
|
"dev": "yaakcli dev",
|
||||||
"test": "vp test --run tests"
|
"test": "vp test --run tests",
|
||||||
|
"test:roundtrip": "node tests/roundtrip.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.8.3"
|
"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,
|
"parentId": null,
|
||||||
"parentModel": "workspace",
|
"parentModel": "workspace",
|
||||||
"sortPriority": 0,
|
"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": [
|
"variables": [
|
||||||
{
|
{
|
||||||
"name": "baseUrl",
|
"name": "baseUrl",
|
||||||
@@ -153,18 +163,13 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API",
|
"name": "Retrieve one version of a particular API",
|
||||||
"sortPriority": 5,
|
"sortPriority": 5,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":provider",
|
"name": ":provider",
|
||||||
"value": "apis.guru",
|
"value": "apis.guru",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -197,7 +202,7 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API with a serviceName.",
|
"name": "Retrieve one version of a particular API with a serviceName.",
|
||||||
"sortPriority": 6,
|
"sortPriority": 6,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:service/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -209,11 +214,6 @@ Responses:
|
|||||||
"name": ":service",
|
"name": ":service",
|
||||||
"value": "graph",
|
"value": "graph",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -246,14 +246,8 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "List all APIs for a particular provider",
|
"name": "List all APIs for a particular provider",
|
||||||
"sortPriority": 7,
|
"sortPriority": 7,
|
||||||
"url": "\${[baseUrl]}/:provider.json",
|
"url": "\${[baseUrl]}/apis.guru.json",
|
||||||
"urlParameters": [
|
"urlParameters": [],
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":provider",
|
|
||||||
"value": "apis.guru",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -298,6 +292,8 @@ Responses:
|
|||||||
"websocketRequests": [],
|
"websocketRequests": [],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
{
|
{
|
||||||
|
"authentication": {},
|
||||||
|
"authenticationType": "none",
|
||||||
"description": "Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
|
"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).
|
**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)
|
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,
|
"parentId": null,
|
||||||
"parentModel": "workspace",
|
"parentModel": "workspace",
|
||||||
"sortPriority": 0,
|
"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": [
|
"variables": [
|
||||||
{
|
{
|
||||||
"name": "baseUrl",
|
"name": "baseUrl",
|
||||||
@@ -611,7 +617,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -640,7 +646,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -669,7 +675,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -698,7 +704,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -727,7 +733,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -756,7 +762,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -816,12 +822,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -840,13 +846,7 @@ Responses:
|
|||||||
- 200: Sucessful authentication.
|
- 200: Sucessful authentication.
|
||||||
- 401: Unsuccessful authentication.",
|
- 401: Unsuccessful authentication.",
|
||||||
"folderId": "GENERATE_ID::FOLDER_1",
|
"folderId": "GENERATE_ID::FOLDER_1",
|
||||||
"headers": [
|
"headers": [],
|
||||||
{
|
|
||||||
"enabled": false,
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
@@ -1073,12 +1073,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":name",
|
"name": ":name",
|
||||||
"value": "",
|
"value": "name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":value",
|
"name": ":value",
|
||||||
"value": "",
|
"value": "value",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1344,17 +1344,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1387,17 +1387,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1437,17 +1437,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1567,7 +1567,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":etag",
|
"name": ":etag",
|
||||||
"value": "",
|
"value": "etag",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1658,12 +1658,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2297,7 +2297,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2330,7 +2330,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2363,7 +2363,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2396,7 +2396,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2429,7 +2429,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2462,7 +2462,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2589,6 +2589,8 @@ Responses:
|
|||||||
"websocketRequests": [],
|
"websocketRequests": [],
|
||||||
"workspaces": [
|
"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>
|
"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",
|
Contact: me@kennethreitz.org",
|
||||||
@@ -2612,11 +2614,44 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
|
|||||||
"parentId": null,
|
"parentId": null,
|
||||||
"parentModel": "workspace",
|
"parentModel": "workspace",
|
||||||
"sortPriority": 0,
|
"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": [
|
"variables": [
|
||||||
{
|
{
|
||||||
"name": "baseUrl",
|
"name": "baseUrl",
|
||||||
"value": "https://api.nasa.gov/planetary",
|
"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",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -2640,7 +2675,7 @@ Here's a link: https://example.com",
|
|||||||
"authentication": {
|
"authentication": {
|
||||||
"key": "api_key",
|
"key": "api_key",
|
||||||
"location": "query",
|
"location": "query",
|
||||||
"value": "",
|
"value": "\${[auth_api_key_key]}",
|
||||||
},
|
},
|
||||||
"authenticationType": "apikey",
|
"authenticationType": "apikey",
|
||||||
"body": {},
|
"body": {},
|
||||||
@@ -2686,6 +2721,8 @@ Responses:
|
|||||||
"websocketRequests": [],
|
"websocketRequests": [],
|
||||||
"workspaces": [
|
"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
|
"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
|
Contact: evan.t.yates@nasa.gov
|
||||||
@@ -2711,10 +2748,20 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
|
|||||||
"parentId": null,
|
"parentId": null,
|
||||||
"parentModel": "workspace",
|
"parentModel": "workspace",
|
||||||
"sortPriority": 0,
|
"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": [
|
"variables": [
|
||||||
{
|
{
|
||||||
"name": "baseUrl",
|
"name": "baseUrl",
|
||||||
"value": "http://xkcd.com/",
|
"value": "http://xkcd.com",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2778,6 +2825,8 @@ Responses:
|
|||||||
"websocketRequests": [],
|
"websocketRequests": [],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
{
|
{
|
||||||
|
"authentication": {},
|
||||||
|
"authenticationType": null,
|
||||||
"description": "Webcomic of romance, sarcasm, math, and language.",
|
"description": "Webcomic of romance, sarcasm, math, and language.",
|
||||||
"id": "GENERATE_ID::WORKSPACE_0",
|
"id": "GENERATE_ID::WORKSPACE_0",
|
||||||
"model": "workspace",
|
"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