- {buttonContent}
+
+ {children}
);
}
diff --git a/apps/yaak-client/components/core/Tabs/fitTabs.test.ts b/apps/yaak-client/components/core/Tabs/fitTabs.test.ts
new file mode 100644
index 000000000..2ed09df9f
--- /dev/null
+++ b/apps/yaak-client/components/core/Tabs/fitTabs.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test } from "vite-plus/test";
+import { fitTabs } from "./fitTabs";
+
+const tabs = [
+ { value: "body", width: 80, compactWidth: 66 },
+ { value: "headers", width: 100, compactWidth: 86 },
+ { value: "info", width: 60, compactWidth: 46 },
+ { value: "assertions", width: 120, compactWidth: 106 },
+];
+
+describe("responsive request tabs", () => {
+ test("restores the saved order when the pane has room for every tab", () => {
+ expect(fitTabs(tabs, 500, "assertions")).toEqual(
+ tabs.map(({ value, width }) => ({ value, width, spacingCompression: 0 })),
+ );
+ });
+
+ test("tightens gaps and padding without taking any space from labels", () => {
+ expect(fitTabs(tabs, 332, "assertions")).toEqual([
+ { value: "body", width: 73, spacingCompression: 0.5 },
+ { value: "headers", width: 93, spacingCompression: 0.5 },
+ { value: "info", width: 53, spacingCompression: 0.5 },
+ { value: "assertions", width: 113, spacingCompression: 0.5 },
+ ]);
+ expect(fitTabs(tabs, 304, "assertions")).toEqual(
+ tabs.map(({ value, compactWidth }) => ({
+ value,
+ width: compactWidth,
+ spacingCompression: 1,
+ })),
+ );
+ });
+
+ test("moves whole tabs into overflow when compact spacing is not enough", () => {
+ expect(fitTabs(tabs, 258, "assertions")).toEqual([
+ { value: "body", width: 66, spacingCompression: 1 },
+ { value: "headers", width: 86, spacingCompression: 1 },
+ { value: "assertions", width: 106, spacingCompression: 1 },
+ ]);
+ expect(fitTabs(tabs, 257, "assertions").map((tab) => tab.value)).toEqual([
+ "body",
+ "assertions",
+ ]);
+ });
+
+ test("keeps an active tab from the end visible without reordering it", () => {
+ expect(fitTabs(tabs, 172, "assertions")).toEqual([
+ { value: "body", width: 66, spacingCompression: 1 },
+ { value: "assertions", width: 106, spacingCompression: 1 },
+ ]);
+ expect(fitTabs(tabs, 170, "headers").map((tab) => tab.value)).toEqual(["body", "headers"]);
+ });
+
+ test("puts even the active tab into overflow if it cannot fit on its own", () => {
+ expect(fitTabs(tabs, 40, "assertions")).toEqual([]);
+ expect(fitTabs(tabs, 0, "assertions")).toEqual([]);
+ expect(fitTabs(tabs, 90, "assertions")).toEqual([
+ { value: "body", width: 80, spacingCompression: 0 },
+ ]);
+ });
+
+ test("recalculates after a dropdown label or badge changes width", () => {
+ const longLabel = [{ value: "body", width: 200, compactWidth: 186 }, ...tabs.slice(1)];
+ const result = fitTabs(longLabel, 300, "body");
+ expect(result.map((tab) => tab.value)).toEqual(["body", "headers"]);
+ expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeCloseTo(300);
+
+ const largeBadge = [{ value: "body", width: 240, compactWidth: 226 }, ...tabs.slice(1)];
+ expect(fitTabs(largeBadge, 280, "body").map((tab) => tab.value)).toEqual(["body"]);
+ });
+
+ test("does not skip a long tab to squeeze later tabs into the leading group", () => {
+ expect(fitTabs(tabs, 200, "assertions").map((tab) => tab.value)).toEqual([
+ "body",
+ "assertions",
+ ]);
+ expect(fitTabs(tabs, 160).map((tab) => tab.value)).toEqual(["body", "headers"]);
+ });
+
+ test("handles tabs with no spacing to compress", () => {
+ const shortTabs = tabs.filter((tab) => tab.value === "body" || tab.value === "info");
+ expect(fitTabs(shortTabs, 126, "body")).toEqual([
+ { value: "body", width: 73, spacingCompression: 0.5 },
+ { value: "info", width: 53, spacingCompression: 0.5 },
+ ]);
+ const fixedTabs = shortTabs.map((tab) => ({ ...tab, compactWidth: tab.width }));
+ expect(fitTabs(fixedTabs, 126, "body")).toEqual([
+ { value: "body", width: 80, spacingCompression: 0 },
+ ]);
+ expect(fitTabs(fixedTabs, 40, "body")).toEqual([]);
+ });
+
+ test("stays within the pane and each label's bounds throughout resizing", () => {
+ for (let width = 0; width <= 500; width++) {
+ for (const active of tabs) {
+ const result = fitTabs(tabs, width, active.value);
+ expect(result.some((tab) => tab.value === active.value)).toBe(width >= active.compactWidth);
+ expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeLessThanOrEqual(width + 0.001);
+ for (const tab of result) {
+ const original = tabs.find((t) => t.value === tab.value)!;
+ expect(tab.width).toBeGreaterThanOrEqual(original.compactWidth);
+ expect(tab.width).toBeLessThanOrEqual(original.width);
+ expect(tab.spacingCompression).toBeGreaterThanOrEqual(0);
+ expect(tab.spacingCompression).toBeLessThanOrEqual(1);
+ // All removed width must come from spacing, at every pane size.
+ expect(original.width - tab.width).toBeCloseTo(
+ tab.spacingCompression * (original.width - original.compactWidth),
+ );
+ }
+ }
+ }
+ });
+});
diff --git a/apps/yaak-client/components/core/Tabs/fitTabs.ts b/apps/yaak-client/components/core/Tabs/fitTabs.ts
new file mode 100644
index 000000000..f5e372cd5
--- /dev/null
+++ b/apps/yaak-client/components/core/Tabs/fitTabs.ts
@@ -0,0 +1,38 @@
+export const MIN_TAB_PADDING_SCALE = 0.5;
+export const MIN_TAB_GAP_SCALE = 0.25;
+
+export interface TabWidth {
+ value: string;
+ width: number;
+ /** Full label and badges, with the smallest padding and gap. */
+ compactWidth: number;
+}
+
+/** Tighten spacing to fit whole tabs, keeping every visible label intact. */
+export function fitTabs(
+ tabs: TabWidth[],
+ availableWidth: number,
+ activeValue?: string,
+): { value: string; width: number; spacingCompression: number }[] {
+ const available = Math.max(0, availableWidth);
+ // Even the active tab goes into overflow if it cannot fit on its own.
+ const active = tabs.find((tab) => tab.value === activeValue && tab.compactWidth <= available);
+ let remaining = available - (active?.compactWidth ?? 0);
+ const fitted = new Set(active ? [active.value] : []);
+ for (const tab of tabs) {
+ if (tab === active) continue;
+ if (tab.compactWidth > remaining) break;
+ fitted.add(tab.value);
+ remaining -= tab.compactWidth;
+ }
+ const visible = tabs.filter((tab) => fitted.has(tab.value));
+ const naturalWidth = visible.reduce((sum, tab) => sum + tab.width, 0);
+ const deficit = Math.max(0, naturalWidth - available);
+ const spareSpacing = visible.reduce((sum, tab) => sum + tab.width - tab.compactWidth, 0);
+ const spacingCompression = spareSpacing > 0 ? Math.min(1, deficit / spareSpacing) : 0;
+ return visible.map((tab) => ({
+ value: tab.value,
+ spacingCompression,
+ width: tab.width - spacingCompression * (tab.width - tab.compactWidth),
+ }));
+}
diff --git a/apps/yaak-client/components/core/Tabs/useFittingTabs.ts b/apps/yaak-client/components/core/Tabs/useFittingTabs.ts
new file mode 100644
index 000000000..50ed2480c
--- /dev/null
+++ b/apps/yaak-client/components/core/Tabs/useFittingTabs.ts
@@ -0,0 +1,55 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { fitTabs, MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
+
+export function useFittingTabs(enabled: boolean, activeValue?: string) {
+ const viewportRef = useRef
(null);
+ const measurementsRef = useRef(null);
+ const [fittedTabs, setFittedTabs] = useState | null>(null);
+
+ // Reobserve after render: tab labels, badges and available tabs can all change.
+ useLayoutEffect(() => {
+ const viewport = viewportRef.current;
+ const measurements = measurementsRef.current;
+ if (!enabled || !viewport || !measurements) return;
+
+ const measure = () => {
+ const widths = Array.from(measurements.children, (child) => {
+ const width = child.getBoundingClientRect().width;
+ const button = child.querySelector("button");
+ const buttonStyle = button ? getComputedStyle(button) : null;
+ const padding = buttonStyle
+ ? Number.parseFloat(buttonStyle.paddingLeft) + Number.parseFloat(buttonStyle.paddingRight)
+ : 0;
+ const gap = child.firstElementChild
+ ? Number.parseFloat(getComputedStyle(child.firstElementChild).marginRight)
+ : 0;
+ const compactWidth =
+ width - padding * (1 - MIN_TAB_PADDING_SCALE) - gap * (1 - MIN_TAB_GAP_SCALE);
+ return {
+ value: child.getAttribute("data-measure-tab") ?? "",
+ width,
+ compactWidth,
+ };
+ });
+ const next = fitTabs(widths, viewport.clientWidth, activeValue);
+ setFittedTabs((previous) =>
+ previous?.length === next.length &&
+ previous.every(
+ (tab, i) =>
+ tab.value === next[i]?.value &&
+ tab.width === next[i]?.width &&
+ tab.spacingCompression === next[i]?.spacingCompression,
+ )
+ ? previous
+ : next,
+ );
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(viewport);
+ for (const child of measurements.children) observer.observe(child);
+ return () => observer.disconnect();
+ });
+
+ return { viewportRef, measurementsRef, fittedTabs: enabled ? fittedTabs : null };
+}
diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml
index 6ab3cf96c..7dee73802 100644
--- a/crates-cli/yaak-cli/Cargo.toml
+++ b/crates-cli/yaak-cli/Cargo.toml
@@ -9,6 +9,7 @@ name = "yaak"
path = "src/main.rs"
[dependencies]
+yaak-assertions = { workspace = true }
arboard = "3"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs
index b4754ac62..4801afab6 100644
--- a/crates-cli/yaak-cli/src/cli.rs
+++ b/crates-cli/yaak-cli/src/cli.rs
@@ -71,6 +71,9 @@ pub enum Commands {
/// Send a request, folder, or workspace by ID
Send(SendArgs),
+ /// Run HTTP assertions (exit 0: passed, 1: failed/error, 2: invalid configuration)
+ Test(TestArgs),
+
/// Cookie jar commands
CookieJar(CookieJarArgs),
@@ -186,6 +189,24 @@ pub enum AgentCommands {
},
}
+#[derive(Args)]
+pub struct TestArgs {
+ /// Request, folder, or workspace ID; sends HTTP requests sequentially in sidebar order
+ pub id: String,
+
+ /// Stop after the first failed check or request error
+ #[arg(long)]
+ pub fail_fast: bool,
+
+ /// Write a JSON report without URLs, headers, bodies, or assertion values
+ #[arg(long, value_name = "FILE")]
+ pub json: Option,
+
+ /// Write a JUnit XML report without request or assertion values
+ #[arg(long, value_name = "FILE")]
+ pub junit: Option,
+}
+
#[derive(Args)]
pub struct SendArgs {
/// Request, folder, or workspace ID
diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs
index 901371f1a..364b15c3b 100644
--- a/crates-cli/yaak-cli/src/commands/mod.rs
+++ b/crates-cli/yaak-cli/src/commands/mod.rs
@@ -10,3 +10,5 @@ pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
+
+pub mod test;
diff --git a/crates-cli/yaak-cli/src/commands/test.rs b/crates-cli/yaak-cli/src/commands/test.rs
new file mode 100644
index 000000000..0285dac2c
--- /dev/null
+++ b/crates-cli/yaak-cli/src/commands/test.rs
@@ -0,0 +1,442 @@
+use crate::cli::TestArgs;
+use crate::commands::request::resolve_cookie_jar_id;
+use crate::context::CliContext;
+use serde::Serialize;
+use std::collections::HashSet;
+use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
+use yaak_assertions::{AssertionReport, HttpAssertions};
+use yaak_models::models::{Folder, HttpRequest};
+use yaak_models::queries::any_request::AnyRequest;
+use yaak_models::util::UpdateSource;
+use yaak_plugins::events::PluginContext;
+
+// Reports intentionally contain no names, URLs, selectors, expected/actual values, or transport
+// error strings (which can embed URLs/tokens). The stored response has details for local inspection.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CheckResult {
+ assertion_id: String,
+ outcome: String,
+ reason: String,
+}
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RequestResult {
+ request_id: String,
+ outcome: String,
+ reason: String,
+ checks: Vec,
+}
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct RunReport {
+ version: u32,
+ exit_code: i32,
+ requests: Vec,
+}
+
+pub async fn run(
+ ctx: &CliContext,
+ args: TestArgs,
+ environment: Option<&str>,
+ cookie_jar: Option<&str>,
+) -> i32 {
+ match run_inner(ctx, &args, environment, cookie_jar).await {
+ Ok(report) => {
+ if let Err(e) = write_reports(&args, &report) {
+ eprintln!("Could not write test report: {e}");
+ return 2;
+ }
+ for request in &report.requests {
+ println!(
+ "{} {}: {}",
+ request.outcome.to_uppercase(),
+ request.request_id,
+ request.reason
+ );
+ for check in &request.checks {
+ println!(
+ " {} {}: {}",
+ check.outcome.to_uppercase(),
+ check.assertion_id,
+ check.reason
+ );
+ }
+ }
+ let count = |outcome| report.requests.iter().filter(|r| r.outcome == outcome).count();
+ println!(
+ "Test summary: {} passed, {} failed, {} errors, {} unchecked, {} skipped",
+ count("passed"),
+ count("failed"),
+ count("error"),
+ count("unchecked"),
+ count("skipped")
+ );
+ report.exit_code
+ }
+ Err(error) => {
+ eprintln!("Invalid test configuration: {error}");
+ 2
+ }
+ }
+}
+
+async fn run_inner(
+ ctx: &CliContext,
+ args: &TestArgs,
+ environment: Option<&str>,
+ cookie_jar: Option<&str>,
+) -> Result {
+ let (workspace_id, requests) = select_requests(ctx, &args.id)?;
+ let jar = resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar)?;
+ if let Some(id) = environment {
+ ctx.db().get_environment_for_workspace(&workspace_id, id).map_err(|_| {
+ "Selected environment is missing or does not belong to this workspace".to_owned()
+ })?;
+ }
+ // Validate the entire selection before sending anything, including unchecked setup requests.
+ let mut enabled = 0;
+ for request in &requests {
+ yaak_assertions::validate(&request.assertions)
+ .map_err(|e| format!("{}: {e}", request.id))?;
+ enabled += request.assertions.checks.iter().filter(|c| c.enabled).count();
+ }
+ if enabled == 0 {
+ return Err("Selected requests have no enabled assertions".into());
+ }
+ if args.json.is_some() && args.json == args.junit {
+ return Err("JSON and JUnit reports require different files".into());
+ }
+ let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
+ let cancel_task = tokio::spawn(async move {
+ if tokio::signal::ctrl_c().await.is_ok() {
+ let _ = cancel_tx.send(true);
+ }
+ });
+ let plugin_context = PluginContext::new(Some("cli".into()), Some(workspace_id));
+ let response_dir = ctx.data_dir().join("responses");
+ let mut report = RunReport { version: 1, exit_code: 0, requests: Vec::new() };
+ let mut stopped = false;
+ for request in requests {
+ if stopped || *cancel_rx.borrow() {
+ report.requests.push(skipped(
+ &request.id,
+ &request.assertions,
+ if *cancel_rx.borrow() { "Canceled" } else { "Stopped after failure" },
+ ));
+ continue;
+ }
+ let id = request.id.clone();
+ let definition = request.assertions.clone();
+ let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
+ query_manager: ctx.query_manager(),
+ blob_manager: ctx.blob_manager(),
+ request,
+ environment_id: environment,
+ update_source: UpdateSource::Sync,
+ cookie_jar_id: jar.clone(),
+ response_dir: &response_dir,
+ emit_events_to: None,
+ emit_response_body_chunks_to: None,
+ existing_response: None,
+ plugin_manager: ctx.plugin_manager(),
+ encryption_manager: ctx.encryption_manager.clone(),
+ plugin_context: &plugin_context,
+ cancelled_rx: Some(cancel_rx.clone()),
+ connection_manager: ctx.connection_manager(),
+ })
+ .await;
+ let request_result = match result {
+ Ok(result) if result.response.error.is_none() && !*cancel_rx.borrow() => {
+ summarize(&id, &definition, result.response.assertion_results.as_ref())
+ }
+ _ => RequestResult {
+ request_id: id,
+ outcome: "error".into(),
+ reason: if *cancel_rx.borrow() {
+ "Request canceled"
+ } else {
+ "Request did not complete successfully"
+ }
+ .into(),
+ checks: definition
+ .checks
+ .iter()
+ .map(|c| CheckResult {
+ assertion_id: c.id.clone(),
+ outcome: if c.enabled { "error" } else { "skipped" }.into(),
+ reason: if c.enabled {
+ "Request did not complete successfully"
+ } else {
+ "Disabled"
+ }
+ .into(),
+ })
+ .collect(),
+ },
+ };
+ if matches!(request_result.outcome.as_str(), "failed" | "error") {
+ report.exit_code = 1;
+ stopped = args.fail_fast;
+ }
+ report.requests.push(request_result);
+ }
+ if *cancel_rx.borrow() {
+ report.exit_code = 130;
+ }
+ cancel_task.abort();
+ Ok(report)
+}
+
+fn summarize(
+ id: &str,
+ definition: &HttpAssertions,
+ report: Option<&AssertionReport>,
+) -> RequestResult {
+ let checks: Vec = report
+ .map(|r| {
+ r.results
+ .iter()
+ .map(|c| CheckResult {
+ assertion_id: c.assertion_id.clone(),
+ outcome: c.outcome.clone(),
+ reason: c.reason.clone(),
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ let enabled = definition.checks.iter().filter(|c| c.enabled).count();
+ let (outcome, reason) = if report.is_some_and(|r| r.error.is_some())
+ || checks.iter().any(|c| matches!(c.outcome.as_str(), "error" | "invalid"))
+ {
+ ("error", "Assertions could not be evaluated")
+ } else if checks.iter().any(|c| c.outcome == "failed") {
+ ("failed", "Assertions failed")
+ } else if enabled == 0 {
+ ("unchecked", "No enabled assertions")
+ } else if checks.iter().filter(|c| c.outcome == "passed").count() != enabled {
+ ("error", "Assertion results are incomplete")
+ } else {
+ ("passed", "All enabled assertions passed")
+ };
+ RequestResult { request_id: id.into(), outcome: outcome.into(), reason: reason.into(), checks }
+}
+
+fn skipped(id: &str, definition: &HttpAssertions, reason: &str) -> RequestResult {
+ RequestResult {
+ request_id: id.into(),
+ outcome: "skipped".into(),
+ reason: reason.into(),
+ checks: definition
+ .checks
+ .iter()
+ .map(|c| CheckResult {
+ assertion_id: c.id.clone(),
+ outcome: "skipped".into(),
+ reason: reason.into(),
+ })
+ .collect(),
+ }
+}
+
+fn select_requests(ctx: &CliContext, id: &str) -> Result<(String, Vec), String> {
+ let db = ctx.db();
+ if let Ok(request) = db.get_any_request(id) {
+ return match request {
+ AnyRequest::HttpRequest(r) => Ok((r.workspace_id.clone(), vec![r])),
+ _ => Err("Assertions currently support HTTP requests only".into()),
+ };
+ }
+ let (workspace_id, parent) = if let Ok(folder) = db.get_folder(id) {
+ (folder.workspace_id, Some(folder.id))
+ } else if let Ok(workspace) = db.get_workspace(id) {
+ (workspace.id, None)
+ } else {
+ return Err("Could not resolve request, folder, or workspace".into());
+ };
+ let folders = db.list_folders(&workspace_id).map_err(|e| e.to_string())?;
+ let requests = db.list_http_requests(&workspace_id).map_err(|e| e.to_string())?;
+ // Refuse mixed protocols rather than silently producing a green partial suite.
+ let mut folder_ids = HashSet::new();
+ let ordered = ordered_requests(&folders, &requests, parent.as_deref(), &mut folder_ids)?;
+ let selected = |folder: Option<&String>| {
+ parent.is_none() || folder.is_some_and(|f| folder_ids.contains(f))
+ };
+ if db
+ .list_grpc_requests(&workspace_id)
+ .map_err(|e| e.to_string())?
+ .iter()
+ .any(|r| selected(r.folder_id.as_ref()))
+ || db
+ .list_websocket_requests(&workspace_id)
+ .map_err(|e| e.to_string())?
+ .iter()
+ .any(|r| selected(r.folder_id.as_ref()))
+ {
+ return Err(
+ "Selection includes gRPC or WebSocket requests; choose an HTTP request or folder"
+ .into(),
+ );
+ }
+ if parent.is_none() && ordered.len() != requests.len() {
+ return Err("Request folder hierarchy is invalid".into());
+ }
+ Ok((workspace_id, ordered))
+}
+
+fn ordered_requests(
+ folders: &[Folder],
+ requests: &[HttpRequest],
+ parent: Option<&str>,
+ visited: &mut HashSet,
+) -> Result, String> {
+ if let Some(id) = parent {
+ if !visited.insert(id.to_owned()) {
+ return Err("Request folder hierarchy contains a cycle".into());
+ }
+ }
+ let mut children: Vec<_> = folders
+ .iter()
+ .filter(|f| f.folder_id.as_deref() == parent)
+ .map(|f| (f.sort_priority, f.created_at, f.id.as_str(), None::<&HttpRequest>))
+ .collect();
+ children.extend(
+ requests
+ .iter()
+ .filter(|r| r.folder_id.as_deref() == parent)
+ .map(|r| (r.sort_priority, r.created_at, r.id.as_str(), Some(r))),
+ );
+ children.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
+ let mut ordered = Vec::new();
+ for (_, _, id, request) in children {
+ if let Some(request) = request {
+ ordered.push(request.clone());
+ } else {
+ ordered.extend(ordered_requests(folders, requests, Some(id), visited)?);
+ }
+ }
+ Ok(ordered)
+}
+
+fn write_reports(args: &TestArgs, report: &RunReport) -> Result<(), String> {
+ if let Some(path) = &args.json {
+ std::fs::write(path, serde_json::to_vec_pretty(report).map_err(|e| e.to_string())?)
+ .map_err(|e| e.to_string())?;
+ }
+ if let Some(path) = &args.junit {
+ std::fs::write(path, junit(report)).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+fn xml(s: &str) -> String {
+ s.chars()
+ .filter(|&c| {
+ matches!(c, '\t' | '\n' | '\r') || c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}'
+ })
+ .collect::()
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+fn junit(report: &RunReport) -> String {
+ let mut cases = Vec::new();
+ for request in &report.requests {
+ for check in &request.checks {
+ cases.push((
+ request.request_id.as_str(),
+ check.assertion_id.as_str(),
+ check.outcome.as_str(),
+ check.reason.as_str(),
+ ));
+ }
+ if request.checks.is_empty()
+ || request.outcome == "error"
+ && !request.checks.iter().any(|c| c.outcome == "error" || c.outcome == "invalid")
+ {
+ cases.push((
+ request.request_id.as_str(),
+ "request",
+ if request.outcome == "unchecked" { "skipped" } else { &request.outcome },
+ request.reason.as_str(),
+ ));
+ }
+ }
+ let count = |outcome| cases.iter().filter(|c| c.2 == outcome).count();
+ let mut out = format!(
+ "\n\n",
+ cases.len(),
+ count("failed"),
+ count("error") + count("invalid"),
+ count("skipped")
+ );
+ for (request, check, outcome, reason) in cases {
+ out.push_str(&format!(
+ " ",
+ xml(request),
+ xml(check)
+ ));
+ let tag = match outcome {
+ "failed" => Some("failure"),
+ "error" | "invalid" => Some("error"),
+ "skipped" => Some("skipped"),
+ _ => None,
+ };
+ if let Some(tag) = tag {
+ out.push_str(&format!("<{tag} message=\"{}\"/>", xml(reason)));
+ }
+ out.push_str("\n");
+ }
+ out.push_str("\n");
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn sidebar_order_interleaves_folders_and_requests() {
+ let folders =
+ vec![Folder { id: "folder".into(), sort_priority: 2.0, ..Default::default() }];
+ let requests = vec![
+ HttpRequest { id: "last".into(), sort_priority: 3.0, ..Default::default() },
+ HttpRequest {
+ id: "nested".into(),
+ folder_id: Some("folder".into()),
+ ..Default::default()
+ },
+ HttpRequest { id: "first".into(), sort_priority: 1.0, ..Default::default() },
+ ];
+ let ordered = ordered_requests(&folders, &requests, None, &mut HashSet::new()).unwrap();
+ assert_eq!(
+ ordered.iter().map(|r| r.id.as_str()).collect::>(),
+ vec!["first", "nested", "last"]
+ );
+ }
+ #[test]
+ fn junit_escapes_attributes_and_counts_all_outcomes() {
+ let report = RunReport {
+ version: 1,
+ exit_code: 1,
+ requests: vec![
+ RequestResult {
+ request_id: "rq_<&\"".into(),
+ outcome: "failed".into(),
+ reason: "Assertions failed".into(),
+ checks: vec![CheckResult {
+ assertion_id: "check".into(),
+ outcome: "failed".into(),
+ reason: "Value did not match".into(),
+ }],
+ },
+ skipped("rq_skip", &HttpAssertions::default(), "Stopped after failure"),
+ ],
+ };
+ let xml = junit(&report);
+ assert!(xml.contains("tests=\"2\" failures=\"1\" errors=\"0\" skipped=\"1\""));
+ assert!(xml.contains("classname=\"rq_<&"\""));
+ assert!(xml.contains(" {
+ let mut context = CliContext::new(data_dir.clone(), app_id);
+ match resolve_send_execution_context(
+ &context,
+ &args.id,
+ environment.as_deref(),
+ cookie_jar.as_deref(),
+ ) {
+ Ok(execution_context) => {
+ context.init_plugins(execution_context).await;
+ let exit_code = commands::test::run(
+ &context,
+ args,
+ environment.as_deref(),
+ cookie_jar.as_deref(),
+ )
+ .await;
+ context.shutdown().await;
+ exit_code
+ }
+ Err(error) => {
+ eprintln!("Error: {error}");
+ 2
+ }
+ }
+ }
Commands::CookieJar(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::cookie_jar::run(&context, args);
diff --git a/crates-cli/yaak-cli/tests/assertion_commands.rs b/crates-cli/yaak-cli/tests/assertion_commands.rs
new file mode 100644
index 000000000..d3b17b4bb
--- /dev/null
+++ b/crates-cli/yaak-cli/tests/assertion_commands.rs
@@ -0,0 +1,164 @@
+mod common;
+
+use common::http_server::TestHttpServer;
+use common::{cli_cmd, query_manager, seed_workspace};
+use predicates::str::contains;
+use tempfile::TempDir;
+use yaak_models::models::{HttpAssertion, HttpAssertions, HttpRequest};
+use yaak_models::util::UpdateSource;
+
+fn request(id: &str, url: &str, expected: &str) -> HttpRequest {
+ HttpRequest {
+ id: id.into(),
+ workspace_id: "wk_test".into(),
+ url: url.into(),
+ name: "SECRET_REQUEST_NAME".into(),
+ assertions: HttpAssertions {
+ version: 1,
+ checks: vec![HttpAssertion {
+ id: "assertion".into(),
+ selector: "$.token".into(),
+ expected: expected.into(),
+ ..Default::default()
+ }],
+ },
+ ..Default::default()
+ }
+}
+fn save(dir: &std::path::Path, request: &HttpRequest) {
+ query_manager(dir).with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)).unwrap();
+}
+
+#[test]
+fn sends_assertions_persists_snapshots_and_writes_safe_reports() {
+ let dir = TempDir::new().unwrap();
+ seed_workspace(dir.path(), "wk_test");
+ let server = TestHttpServer::spawn_ok(r#"{"token":"SECRET_TOKEN"}"#);
+ let mut req = request("rq_test", &server.url, "SECRET_TOKEN");
+ save(dir.path(), &req);
+ cli_cmd(dir.path())
+ .args(["test", "rq_test", "--environment", "missing"])
+ .assert()
+ .code(2)
+ .stderr(contains("Selected environment"));
+ assert!(
+ query_manager(dir.path())
+ .connect()
+ .list_http_responses("wk_test", None)
+ .unwrap()
+ .is_empty()
+ );
+ let json = dir.path().join("results.json");
+ let junit = dir.path().join("results.xml");
+ let output = cli_cmd(dir.path())
+ .args(["test", "rq_test", "--json"])
+ .arg(&json)
+ .arg("--junit")
+ .arg(&junit)
+ .assert()
+ .success()
+ .stdout(contains("1 passed, 0 failed"))
+ .get_output()
+ .clone();
+ for text in [
+ String::from_utf8_lossy(&output.stdout).into_owned(),
+ std::fs::read_to_string(&json).unwrap(),
+ std::fs::read_to_string(&junit).unwrap(),
+ ] {
+ assert!(!text.contains("SECRET"));
+ assert!(!text.contains(&server.url));
+ assert!(!text.contains("$.token"));
+ }
+ let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
+ assert_eq!(report["requests"][0]["checks"][0]["outcome"], "passed");
+ let manager = query_manager(dir.path());
+ let history = manager.connect().list_http_responses("wk_test", None).unwrap();
+ assert_eq!(history.len(), 1);
+ let original_id = history[0].id.clone();
+ assert_eq!(history[0].assertion_results.as_ref().unwrap().definition, req.assertions);
+ req.assertions.checks[0].expected = "DIFFERENT_SECRET".into();
+ save(dir.path(), &req);
+ assert_eq!(
+ manager
+ .connect()
+ .get_http_response(&original_id)
+ .unwrap()
+ .assertion_results
+ .unwrap()
+ .definition
+ .checks[0]
+ .expected,
+ "SECRET_TOKEN"
+ );
+ cli_cmd(dir.path()).args(["test", "rq_test"]).assert().code(1).stdout(contains("1 failed"));
+ // Ordinary send still prints the response body and succeeds even when a check fails.
+ cli_cmd(dir.path())
+ .args(["send", "rq_test"])
+ .assert()
+ .success()
+ .stdout(contains("SECRET_TOKEN"));
+}
+
+#[test]
+fn invalid_suite_and_empty_suite_do_not_send_and_fail_fast_records_skips() {
+ let dir = TempDir::new().unwrap();
+ seed_workspace(dir.path(), "wk_test");
+ let server = TestHttpServer::spawn_ok(r#"{"token":"actual"}"#);
+ let mut first = request("rq_a", &server.url, "different");
+ first.sort_priority = 0.0;
+ let mut second = request("rq_b", &server.url, "actual");
+ second.sort_priority = 1.0;
+ second.assertions.checks[0].selector = "$[".into();
+ save(dir.path(), &first);
+ save(dir.path(), &second);
+ cli_cmd(dir.path())
+ .args(["test", "wk_test"])
+ .assert()
+ .code(2)
+ .stderr(contains("valid JSONPath"));
+ assert!(
+ query_manager(dir.path())
+ .connect()
+ .list_http_responses("wk_test", None)
+ .unwrap()
+ .is_empty()
+ );
+ second.assertions.checks[0].selector = "token".into();
+ save(dir.path(), &second);
+ let json = dir.path().join("results.json");
+ cli_cmd(dir.path())
+ .args(["test", "wk_test", "--fail-fast", "--json"])
+ .arg(&json)
+ .assert()
+ .code(1)
+ .stdout(contains("1 failed, 0 errors, 0 unchecked, 1 skipped"));
+ let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
+ assert_eq!(report["requests"][1]["requestId"], "rq_b");
+ assert_eq!(report["requests"][1]["outcome"], "skipped");
+ assert_eq!(
+ query_manager(dir.path()).connect().list_http_responses("wk_test", None).unwrap().len(),
+ 1
+ );
+ first.assertions.checks.clear();
+ save(dir.path(), &first);
+ cli_cmd(dir.path())
+ .args(["test", "rq_a"])
+ .assert()
+ .code(2)
+ .stderr(contains("no enabled assertions"));
+}
+
+#[test]
+fn transport_errors_are_distinct_from_failed_comparisons() {
+ let dir = TempDir::new().unwrap();
+ seed_workspace(dir.path(), "wk_test");
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+ let url = format!("http://{}", listener.local_addr().unwrap());
+ drop(listener);
+ save(dir.path(), &request("rq_error", &url, "SECRET"));
+ cli_cmd(dir.path())
+ .args(["test", "rq_error"])
+ .assert()
+ .code(1)
+ .stdout(contains("0 failed, 1 errors"));
+}
diff --git a/crates-server/yaak-web/bindings/gen_assertions.ts b/crates-server/yaak-web/bindings/gen_assertions.ts
new file mode 100644
index 000000000..e42b56f57
--- /dev/null
+++ b/crates-server/yaak-web/bindings/gen_assertions.ts
@@ -0,0 +1,9 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Strings intentionally retain unknown operators/types so they fail validation, rather than
+ * disappearing during import or being treated as an empty, passing collection.
+ */
+export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
+
+export type HttpAssertions = { version: number, checks: Array, };
diff --git a/crates-server/yaak-web/bindings/gen_models.ts b/crates-server/yaak-web/bindings/gen_models.ts
index 4e1936dd1..5831bcfb3 100644
--- a/crates-server/yaak-web/bindings/gen_models.ts
+++ b/crates-server/yaak-web/bindings/gen_models.ts
@@ -1,135 +1,53 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { HttpAssertions } from "./gen_assertions";
-export type Cookie = {
- name: string;
- value: string;
- domain: CookieDomain;
- expires: CookieExpires;
- path: string;
- secure: boolean;
- httpOnly: boolean;
- sameSite: CookieSameSite | null;
-};
+export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
-export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
+export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
-export type CookieExpires = { AtUtc: string } | "SessionEnd";
+export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
-export type HttpRequest = {
- model: "http_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- body: Record;
- bodyType: string | null;
- description: string;
- headers: Array;
- method: string;
- name: string;
- sortPriority: number;
- url: string;
- /**
- * URL parameters used for both path placeholders (`:id`) and query string entries.
- */
- urlParameters: Array;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingFollowRedirects: InheritedBoolSetting;
- settingRequestTimeout: InheritedIntSetting;
- settingHttpVersion: InheritedHttpVersionSetting;
-};
+export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
+/**
+ * URL parameters used for both path placeholders (`:id`) and query string entries.
+ */
+urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
-export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
+export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
-export type HttpResponseEventData =
- | {
- type: "setting";
- name: string;
- value: string;
- source_model?: string;
- source_id?: string;
- source_name?: string;
- }
- | { type: "info"; message: string }
- | {
- type: "redirect";
- url: string;
- status: number;
- behavior: string;
- dropped_body: boolean;
- dropped_headers: Array;
- }
- | {
- type: "send_url";
- method: string;
- scheme: string;
- username: string;
- password: string;
- host: string;
- port: number;
- path: string;
- query: string;
- fragment: string;
- }
- | { type: "receive_url"; version: string; status: string }
- | { type: "header_up"; name: string; value: string }
- | { type: "header_down"; name: string; value: string }
- | { type: "chunk_sent"; bytes: number }
- | { type: "chunk_received"; bytes: number }
- | {
- type: "dns_resolved";
- hostname: string;
- addresses: Array;
- duration: bigint;
- overridden: boolean;
- };
+export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, };
-export type HttpResponseHeader = { name: string; value: string };
+export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
-export type HttpSendSettings = {
- validateCertificates: boolean;
- followRedirects: boolean;
- /**
- * Milliseconds. Zero or negative means no timeout.
- */
- timeoutMs: number;
- sendCookies: boolean;
- storeCookies: boolean;
- httpVersion: HttpVersion;
-};
+export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
+/**
+ * Milliseconds. Zero or negative means no timeout.
+ */
+timeoutMs: number, sendCookies: boolean, storeCookies: boolean, httpVersion: HttpVersion, };
-export type HttpUrlParameter = {
- enabled?: boolean;
- /**
- * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
- * Other entries are appended as query parameters
- */
- name: string;
- value: string;
- id?: string;
-};
+export type HttpUrlParameter = { enabled?: boolean,
+/**
+ * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
+ * Other entries are appended as query parameters
+ */
+name: string, value: string, id?: string, };
export type HttpVersion = "auto" | "http1" | "http2";
-export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
-export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
-export type InheritedIntSetting = { enabled?: boolean; value: number };
+export type InheritedIntSetting = { enabled?: boolean, value: number, };
diff --git a/crates-tauri/yaak-app-client/src/http_request.rs b/crates-tauri/yaak-app-client/src/http_request.rs
index b8a63b9c8..62f854db3 100644
--- a/crates-tauri/yaak-app-client/src/http_request.rs
+++ b/crates-tauri/yaak-app-client/src/http_request.rs
@@ -127,6 +127,21 @@ pub async fn send_http_request_with_context(
r.elapsed_headers = elapsed;
}
r.error = Some(error);
+ r.assertion_results = (!unrendered_request.assertions.is_empty()).then(|| {
+ yaak::assertions::evaluate(
+ &unrendered_request.assertions,
+ yaak::assertions::Response {
+ status: r.status,
+ headers: &[],
+ body: yaak::assertions::Body::Unavailable,
+ completion: if *cancelled_rx.borrow() {
+ yaak::assertions::Completion::Canceled
+ } else {
+ yaak::assertions::Completion::Error
+ },
+ },
+ )
+ });
});
// The send failed, so whatever body exists is the partial one
// already on disk under the response's id.
diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs
index 1e459704b..e3535966c 100644
--- a/crates-tauri/yaak-app-client/src/rpc_ext.rs
+++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs
@@ -834,6 +834,40 @@ async fn cmd_default_headers(
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
}
+async fn cmd_validate_http_assertions(
+ _ctx: ClientCtx,
+ req: CmdValidateHttpAssertionsReq,
+) -> Result> {
+ Ok(yaak::assertions::validation_errors(&req.assertions))
+}
+
+async fn cmd_http_response_json_children(
+ ctx: ClientCtx,
+ req: CmdHttpResponseJsonChildrenReq,
+) -> Result {
+ let queries = ctx.query_manager().clone();
+ tauri::async_runtime::spawn_blocking(move || {
+ let store = yaak::response_body::FileResponseBodyStore::new(&queries);
+ yaak::response_body::json_path_children(&store, &req.response_id, &req.parent)
+ .map_err(crate::error::Error::GenericError)
+ })
+ .await
+ .map_err(|e| crate::error::Error::GenericError(e.to_string()))?
+}
+
+async fn cmd_preview_http_assertions(
+ ctx: ClientCtx,
+ req: CmdPreviewHttpAssertionsReq,
+) -> Result {
+ let queries = ctx.query_manager().clone();
+ tauri::async_runtime::spawn_blocking(move || {
+ yaak::response_body::preview_assertions(&queries, &req.response_id, &req.assertions)
+ .map_err(crate::error::Error::GenericError)
+ })
+ .await
+ .map_err(|e| crate::error::Error::GenericError(e.to_string()))?
+}
+
async fn models_upsert(ctx: ClientCtx, req: ModelsUpsertReq) -> Result {
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
}
diff --git a/crates/common/yaak-rpc-schema/Cargo.toml b/crates/common/yaak-rpc-schema/Cargo.toml
index 7e07b427c..6633e13db 100644
--- a/crates/common/yaak-rpc-schema/Cargo.toml
+++ b/crates/common/yaak-rpc-schema/Cargo.toml
@@ -6,6 +6,7 @@ authors = ["Gregory Schier"]
publish = false
[dependencies]
+yaak-jsonpath = { workspace = true }
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true }
yaak-git = { workspace = true }
diff --git a/crates/common/yaak-rpc-schema/bindings/gen_assertions.ts b/crates/common/yaak-rpc-schema/bindings/gen_assertions.ts
new file mode 100644
index 000000000..a30548a77
--- /dev/null
+++ b/crates/common/yaak-rpc-schema/bindings/gen_assertions.ts
@@ -0,0 +1,14 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { JsonValue } from "./serde_json/JsonValue";
+
+export type AssertionReport = { definition: HttpAssertions, results: Array, error: string | null, };
+
+export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
+
+/**
+ * Strings intentionally retain unknown operators/types so they fail validation, rather than
+ * disappearing during import or being treated as an empty, passing collection.
+ */
+export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
+
+export type HttpAssertions = { version: number, checks: Array, };
diff --git a/crates/common/yaak-rpc-schema/bindings/gen_jsonpath.ts b/crates/common/yaak-rpc-schema/bindings/gen_jsonpath.ts
new file mode 100644
index 000000000..29ec699a5
--- /dev/null
+++ b/crates/common/yaak-rpc-schema/bindings/gen_jsonpath.ts
@@ -0,0 +1,13 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JsonPathChild = {
+/**
+ * A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
+ */
+selector: string, label: string, kind: string, };
+
+export type JsonPathChildren = { children: Array,
+/**
+ * Discovery is sampled/bounded, never an assertion that other keys don't exist.
+ */
+truncated: boolean, };
diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts
index ec4a53a40..7368df023 100644
--- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts
+++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts
@@ -1,522 +1,123 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { AssertionReport, HttpAssertions } from "./gen_assertions";
-export type AnyModel =
- | CookieJar
- | Environment
- | Folder
- | GraphQlIntrospection
- | GrpcConnection
- | GrpcEvent
- | GrpcRequest
- | HttpRequest
- | HttpResponse
- | HttpResponseEvent
- | ImportSource
- | KeyValue
- | Plugin
- | Settings
- | SyncState
- | WebsocketConnection
- | WebsocketEvent
- | WebsocketRequest
- | Workspace
- | WorkspaceMeta;
+export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | ImportSource | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
-export type ClientCertificate = {
- host: string;
- port: number | null;
- crtFile: string | null;
- keyFile: string | null;
- pfxFile: string | null;
- passphrase: string | null;
- enabled?: boolean;
-};
+export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
-export type Cookie = {
- name: string;
- value: string;
- domain: CookieDomain;
- expires: CookieExpires;
- path: string;
- secure: boolean;
- httpOnly: boolean;
- sameSite: CookieSameSite | null;
-};
+export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
-export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
+export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
-export type CookieExpires = { AtUtc: string } | "SessionEnd";
+export type CookieExpires = { "AtUtc": string } | "SessionEnd";
-export type CookieJar = {
- model: "cookie_jar";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- cookies: Array;
- name: string;
-};
+export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, };
export type CookieSameSite = "Strict" | "Lax" | "None";
-export type DnsOverride = {
- hostname: string;
- ipv4: Array;
- ipv6: Array;
- enabled?: boolean;
-};
+export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
-export type EncryptedKey = { encryptedKey: string };
+export type EncryptedKey = { encryptedKey: string, };
-export type Environment = {
- model: "environment";
- id: string;
- workspaceId: string;
- createdAt: string;
- updatedAt: string;
- name: string;
- public: boolean;
- parentModel: string;
- parentId: string | null;
- /**
- * Variables defined in this environment scope.
- * Child environments override parent variables by name.
- */
- variables: Array;
- color: string | null;
- sortPriority: number;
-};
+export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
+/**
+ * Variables defined in this environment scope.
+ * Child environments override parent variables by name.
+ */
+variables: Array, color: string | null, sortPriority: number, };
-export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
+export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
-export type Folder = {
- model: "folder";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- name: string;
- sortPriority: number;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingFollowRedirects: InheritedBoolSetting;
- settingRequestTimeout: InheritedIntSetting;
- settingRequestMessageSize: InheritedIntSetting;
- settingHttpVersion: InheritedHttpVersionSetting;
-};
+export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
-export type GraphQlIntrospection = {
- model: "graphql_introspection";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- content: string | null;
-};
+export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
-export type GrpcConnection = {
- model: "grpc_connection";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- elapsed: number;
- error: string | null;
- method: string;
- service: string;
- status: number;
- state: GrpcConnectionState;
- trailers: { [key in string]?: string };
- url: string;
-};
+export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnectionState = "initialized" | "connected" | "closed";
-export type GrpcEvent = {
- model: "grpc_event";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- connectionId: string;
- content: string;
- error: string | null;
- eventType: GrpcEventType;
- metadata: { [key in string]?: string };
- status: number | null;
-};
+export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
-export type GrpcEventType =
- | "info"
- | "error"
- | "client_message"
- | "server_message"
- | "connection_start"
- | "connection_end";
+export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
-export type GrpcRequest = {
- model: "grpc_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authenticationType: string | null;
- authentication: Record;
- description: string;
- message: string;
- metadata: Array;
- method: string | null;
- name: string;
- service: string | null;
- sortPriority: number;
- /**
- * Server URL (http for plaintext or https for secure)
- */
- url: string;
- settingValidateCertificates: InheritedBoolSetting;
- settingRequestMessageSize: InheritedIntSetting;
-};
+export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number,
+/**
+ * Server URL (http for plaintext or https for secure)
+ */
+url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
-export type HttpRequest = {
- model: "http_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- body: Record;
- bodyType: string | null;
- description: string;
- headers: Array;
- method: string;
- name: string;
- sortPriority: number;
- url: string;
- /**
- * URL parameters used for both path placeholders (`:id`) and query string entries.
- */
- urlParameters: Array;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingFollowRedirects: InheritedBoolSetting;
- settingRequestTimeout: InheritedIntSetting;
- settingHttpVersion: InheritedHttpVersionSetting;
-};
+export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
+/**
+ * URL parameters used for both path placeholders (`:id`) and query string entries.
+ */
+urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
-export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
+export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
-export type HttpResponse = {
- model: "http_response";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- contentLength: number | null;
- contentLengthCompressed: number | null;
- elapsed: number;
- elapsedHeaders: number;
- elapsedDns: number;
- error: string | null;
- headers: Array;
- remoteAddr: string | null;
- requestContentLength: number | null;
- requestHeaders: Array;
- status: number;
- statusReason: string | null;
- state: HttpResponseState;
- url: string;
- version: string | null;
-};
+export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, assertionResults: AssertionReport | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
-export type HttpResponseEvent = {
- model: "http_response_event";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- responseId: string;
- event: HttpResponseEventData;
-};
+export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
-export type HttpResponseEventData =
- | {
- type: "setting";
- name: string;
- value: string;
- source_model?: string;
- source_id?: string;
- source_name?: string;
- }
- | { type: "info"; message: string }
- | {
- type: "redirect";
- url: string;
- status: number;
- behavior: string;
- dropped_body: boolean;
- dropped_headers: Array;
- }
- | {
- type: "send_url";
- method: string;
- scheme: string;
- username: string;
- password: string;
- host: string;
- port: number;
- path: string;
- query: string;
- fragment: string;
- }
- | { type: "receive_url"; version: string; status: string }
- | { type: "header_up"; name: string; value: string }
- | { type: "header_down"; name: string; value: string }
- | { type: "chunk_sent"; bytes: number }
- | { type: "chunk_received"; bytes: number }
- | {
- type: "dns_resolved";
- hostname: string;
- addresses: Array;
- duration: bigint;
- overridden: boolean;
- };
+export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, };
-export type HttpResponseHeader = { name: string; value: string };
+export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
-export type HttpUrlParameter = {
- enabled?: boolean;
- /**
- * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
- * Other entries are appended as query parameters
- */
- name: string;
- value: string;
- id?: string;
-};
+export type HttpUrlParameter = { enabled?: boolean,
+/**
+ * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
+ * Other entries are appended as query parameters
+ */
+name: string, value: string, id?: string, };
export type HttpVersion = "auto" | "http1" | "http2";
-export type ImportSource = {
- model: "import_source";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- importer: string;
- origin: string;
- originLabel: string;
- lastImportedAt: string;
-};
+export type ImportSource = { model: "import_source", id: string, createdAt: string, updatedAt: string, workspaceId: string, importer: string, origin: string, originLabel: string, lastImportedAt: string, };
-export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
-export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
-export type InheritedIntSetting = { enabled?: boolean; value: number };
+export type InheritedIntSetting = { enabled?: boolean, value: number, };
-export type KeyValue = {
- model: "key_value";
- id: string;
- createdAt: string;
- updatedAt: string;
- key: string;
- namespace: string;
- value: string;
-};
+export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
-export type Plugin = {
- model: "plugin";
- id: string;
- createdAt: string;
- updatedAt: string;
- checkedAt: string | null;
- directory: string;
- enabled: boolean;
- url: string | null;
- source: PluginSource;
-};
+export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
-export type ProxySetting =
- | {
- type: "enabled";
- http: string;
- https: string;
- auth: ProxySettingAuth | null;
- bypass: string;
- disabled: boolean;
- }
- | { type: "disabled" };
+export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
-export type ProxySettingAuth = { user: string; password: string };
+export type ProxySettingAuth = { user: string, password: string, };
-export type Settings = {
- model: "settings";
- id: string;
- createdAt: string;
- updatedAt: string;
- appearance: string;
- clientCertificates: Array;
- coloredMethods: boolean;
- editorFont: string | null;
- editorFontSize: number;
- editorKeymap: EditorKeymap;
- editorSoftWrap: boolean;
- hideWindowControls: boolean;
- useNativeTitlebar: boolean;
- interfaceFont: string | null;
- interfaceFontSize: number;
- interfaceScale: number;
- openWorkspaceNewWindow: boolean | null;
- proxy: ProxySetting | null;
- themeDark: string;
- themeLight: string;
- updateChannel: string;
- hideLicenseBadge: boolean;
- promptFeedback: boolean;
- autoupdate: boolean;
- autoDownloadUpdates: boolean;
- checkNotifications: boolean;
- hotkeys: { [key in string]?: Array };
-};
+export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array }, };
-export type SyncModel =
- | ({ type: "workspace" } & Workspace)
- | ({ type: "environment" } & Environment)
- | ({ type: "folder" } & Folder)
- | ({ type: "http_request" } & HttpRequest)
- | ({ type: "grpc_request" } & GrpcRequest)
- | ({ type: "websocket_request" } & WebsocketRequest);
+export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
-export type SyncState = {
- model: "sync_state";
- id: string;
- workspaceId: string;
- createdAt: string;
- updatedAt: string;
- flushedAt: string;
- modelId: string;
- checksum: string;
- relPath: string;
- syncDir: string;
-};
+export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
-export type WebsocketConnection = {
- model: "websocket_connection";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- elapsed: number;
- error: string | null;
- headers: Array;
- state: WebsocketConnectionState;
- status: number;
- url: string;
-};
+export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
-export type WebsocketEvent = {
- model: "websocket_event";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- requestId: string;
- connectionId: string;
- isServer: boolean;
- message: Array;
- messageType: WebsocketEventType;
-};
+export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array, messageType: WebsocketEventType, };
-export type WebsocketEventType =
- | "binary"
- | "close"
- | "error"
- | "frame"
- | "open"
- | "ping"
- | "pong"
- | "text";
+export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
-export type WebsocketRequest = {
- model: "websocket_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- message: string;
- name: string;
- sortPriority: number;
- url: string;
- /**
- * URL parameters used for both path placeholders (`:id`) and query string entries.
- */
- urlParameters: Array;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingRequestMessageSize: InheritedIntSetting;
-};
+export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string,
+/**
+ * URL parameters used for both path placeholders (`:id`) and query string entries.
+ */
+urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
-export type Workspace = {
- model: "workspace";
- id: string;
- createdAt: string;
- updatedAt: string;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- name: string;
- encryptionKeyChallenge: string | null;
- settingValidateCertificates: boolean;
- settingFollowRedirects: boolean;
- settingRequestTimeout: number;
- settingRequestMessageSize: number;
- settingDnsOverrides: Array;
- settingSendCookies: boolean;
- settingStoreCookies: boolean;
- settingHttpVersion: HttpVersion;
-};
+export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
-export type WorkspaceMeta = {
- model: "workspace_meta";
- id: string;
- workspaceId: string;
- createdAt: string;
- updatedAt: string;
- encryptionKey: EncryptedKey | null;
- settingSyncDir: string | null;
-};
+export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
index 49eb2d2db..4cbc477a3 100644
--- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
+++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
@@ -1,8 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from "./gen_api";
+import type { AssertionReport, HttpAssertions } from "./gen_assertions";
import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events";
import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git";
import type { ServiceDefinition } from "./gen_grpc";
+import type { JsonPathChildren } from "./gen_jsonpath";
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
import type { PluginMetadata } from "./gen_search";
import type { SyncOp } from "./gen_sync";
@@ -145,6 +147,8 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
+export type CmdHttpResponseJsonChildrenReq = { responseId: string, parent: string, };
+
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
@@ -175,6 +179,8 @@ export type CmdPluginsUpdateAllReq = Record;
export type CmdPluginsUpdatesReq = Record;
+export type CmdPreviewHttpAssertionsReq = { responseId: string, assertions: HttpAssertions, };
+
export type CmdReloadPluginsReq = Record;
export type CmdRenderTemplateReq = { template: string, workspaceId: string, environmentId: string | null, purpose: RenderPurpose | null, ignoreError: boolean | null, };
@@ -211,6 +217,8 @@ export type CmdTemplateFunctionSummariesReq = Record;
export type CmdTemplateTokensToStringReq = { tokens: Tokens, };
+export type CmdValidateHttpAssertionsReq = { assertions: HttpAssertions, };
+
export type CmdWebsocketRequestActionsReq = Record;
export type CmdWorkspaceActionsReq = Record;
@@ -256,6 +264,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
-export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], };
+export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_validate_http_assertions: [CmdValidateHttpAssertionsReq, { [key in string]?: string }], cmd_preview_http_assertions: [CmdPreviewHttpAssertionsReq, AssertionReport], cmd_http_response_json_children: [CmdHttpResponseJsonChildrenReq, JsonPathChildren], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], };
export type WatchResult = { unlistenEvent: string, };
diff --git a/crates/common/yaak-rpc-schema/bindings/serde_json/JsonValue.ts b/crates/common/yaak-rpc-schema/bindings/serde_json/JsonValue.ts
new file mode 100644
index 000000000..3ad5da8dc
--- /dev/null
+++ b/crates/common/yaak-rpc-schema/bindings/serde_json/JsonValue.ts
@@ -0,0 +1,3 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null;
diff --git a/crates/common/yaak-rpc-schema/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs
index 75d4876db..1f78176b4 100644
--- a/crates/common/yaak-rpc-schema/src/lib.rs
+++ b/crates/common/yaak-rpc-schema/src/lib.rs
@@ -41,6 +41,29 @@ use yaak_templates::Tokens;
// -- Response types that belong to the schema rather than to an engine crate --
+#[derive(Debug, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct CmdValidateHttpAssertionsReq {
+ pub assertions: yaak_models::models::HttpAssertions,
+}
+
+#[derive(Debug, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct CmdPreviewHttpAssertionsReq {
+ pub response_id: String,
+ pub assertions: yaak_models::models::HttpAssertions,
+}
+
+#[derive(Debug, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_rpc.ts")]
+pub struct CmdHttpResponseJsonChildrenReq {
+ pub response_id: String,
+ pub parent: String,
+}
+
/// What the frontend learns about the host it is talking to.
#[derive(Debug, Default, Serialize, TS)]
#[serde(default, rename_all = "camelCase")]
@@ -923,6 +946,9 @@ macro_rules! with_commands {
($callback:ident) => {
$callback! {
cmd_metadata(CmdMetadataReq) -> AppMetaData,
+ cmd_validate_http_assertions(CmdValidateHttpAssertionsReq) -> HashMap,
+ cmd_preview_http_assertions(CmdPreviewHttpAssertionsReq) -> yaak_models::models::AssertionReport,
+ cmd_http_response_json_children(CmdHttpResponseJsonChildrenReq) -> yaak_jsonpath::JsonPathChildren,
cmd_template_tokens_to_string(CmdTemplateTokensToStringReq) -> String,
cmd_render_template(CmdRenderTemplateReq) -> String,
cmd_send_feedback(CmdSendFeedbackReq) -> (),
diff --git a/crates/yaak-assertions/Cargo.toml b/crates/yaak-assertions/Cargo.toml
new file mode 100644
index 000000000..23304d1ac
--- /dev/null
+++ b/crates/yaak-assertions/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "yaak-assertions"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+yaak-jsonpath = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+schemars = { workspace = true }
+ts-rs = { workspace = true, features = ["serde-json-impl"] }
diff --git a/crates/yaak-assertions/bindings/gen_assertions.ts b/crates/yaak-assertions/bindings/gen_assertions.ts
new file mode 100644
index 000000000..a30548a77
--- /dev/null
+++ b/crates/yaak-assertions/bindings/gen_assertions.ts
@@ -0,0 +1,14 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { JsonValue } from "./serde_json/JsonValue";
+
+export type AssertionReport = { definition: HttpAssertions, results: Array, error: string | null, };
+
+export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
+
+/**
+ * Strings intentionally retain unknown operators/types so they fail validation, rather than
+ * disappearing during import or being treated as an empty, passing collection.
+ */
+export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
+
+export type HttpAssertions = { version: number, checks: Array, };
diff --git a/crates/yaak-assertions/bindings/serde_json/JsonValue.ts b/crates/yaak-assertions/bindings/serde_json/JsonValue.ts
new file mode 100644
index 000000000..3ad5da8dc
--- /dev/null
+++ b/crates/yaak-assertions/bindings/serde_json/JsonValue.ts
@@ -0,0 +1,3 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null;
diff --git a/crates/yaak-assertions/src/lib.rs b/crates/yaak-assertions/src/lib.rs
new file mode 100644
index 000000000..7be8beba0
--- /dev/null
+++ b/crates/yaak-assertions/src/lib.rs
@@ -0,0 +1,540 @@
+//! Pure response assertions, shared by the desktop, CLI, and browser worker.
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::cmp::Ordering;
+use std::collections::HashSet;
+use ts_rs::TS;
+pub use yaak_jsonpath::MAX_BODY_BYTES;
+use yaak_jsonpath::{JsonPath, normalized_path};
+
+pub const MAX_CHECKS: usize = 100;
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(default, rename_all = "camelCase")]
+#[ts(export, export_to = "gen_assertions.ts")]
+pub struct HttpAssertions {
+ pub version: u32,
+ pub checks: Vec,
+}
+
+impl Default for HttpAssertions {
+ fn default() -> Self {
+ Self { version: 1, checks: Vec::new() }
+ }
+}
+
+impl HttpAssertions {
+ pub fn is_empty(&self) -> bool {
+ self.version == 1 && self.checks.is_empty()
+ }
+ pub fn needs_body(&self) -> bool {
+ self.checks.iter().any(|c| c.enabled && c.target == "json")
+ }
+}
+
+/// Strings intentionally retain unknown operators/types so they fail validation, rather than
+/// disappearing during import or being treated as an empty, passing collection.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
+#[serde(default, rename_all = "camelCase")]
+#[ts(export, export_to = "gen_assertions.ts")]
+pub struct HttpAssertion {
+ pub id: String,
+ pub enabled: bool,
+ pub target: String,
+ pub selector: String,
+ pub operator: String,
+ pub expected: String,
+ pub expected_type: String,
+}
+
+impl Default for HttpAssertion {
+ fn default() -> Self {
+ Self {
+ id: String::new(),
+ enabled: true,
+ target: "json".into(),
+ selector: String::new(),
+ operator: "equals".into(),
+ expected: String::new(),
+ expected_type: "text".into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_assertions.ts")]
+pub struct AssertionResult {
+ pub assertion_id: String,
+ pub outcome: String,
+ pub reason: String,
+ pub actual: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_assertions.ts")]
+pub struct AssertionReport {
+ pub definition: HttpAssertions,
+ pub results: Vec,
+ pub error: Option,
+}
+
+#[derive(Clone, Copy)]
+pub enum Completion {
+ Complete,
+ Error,
+ Canceled,
+}
+pub enum Body<'a> {
+ Bytes(&'a [u8]),
+ TooLarge,
+ Unavailable,
+}
+
+pub struct Response<'a> {
+ pub status: i32,
+ pub headers: &'a [(String, String)],
+ pub body: Body<'a>,
+ pub completion: Completion,
+}
+
+fn expected_value(check: &HttpAssertion) -> Result {
+ match check.expected_type.as_str() {
+ "text" => Ok(Value::String(check.expected.clone())),
+ "number" => match serde_json::from_str::(check.expected.trim()) {
+ Ok(v) if v.is_number() => Ok(v),
+ _ => Err("Expected value must be a number"),
+ },
+ "boolean" => match check.expected.trim() {
+ "true" => Ok(Value::Bool(true)),
+ "false" => Ok(Value::Bool(false)),
+ _ => Err("Expected value must be true or false"),
+ },
+ "null" => Ok(Value::Null),
+ _ => Err("Unsupported expected value type"),
+ }
+}
+
+pub fn validate_check(check: &HttpAssertion) -> Result<(), &'static str> {
+ if check.id.is_empty() {
+ return Err("Assertion ID is required");
+ }
+ if check.selector.len() > 1024 || check.expected.len() > 16 * 1024 {
+ return Err("Assertion input is too long");
+ }
+ match check.target.as_str() {
+ "json" => {
+ if check.selector.trim().is_empty()
+ || JsonPath::parse(&normalized_path(&check.selector)).is_err()
+ {
+ return Err("Enter a valid JSONPath, for example $.user.id");
+ }
+ }
+ "header" if !check.selector.trim().is_empty() => {}
+ "status" => {}
+ "header" => return Err("Header name is required"),
+ _ => return Err("Unsupported assertion target"),
+ }
+ if check.operator == "exists" {
+ return Ok(());
+ }
+ if !matches!(
+ check.operator.as_str(),
+ "equals" | "not_equals" | "greater_than" | "less_than" | "contains"
+ ) {
+ return Err("Unsupported comparison");
+ }
+ let expected = expected_value(check)?;
+ if matches!(check.operator.as_str(), "greater_than" | "less_than") && !expected.is_number() {
+ return Err("Numeric comparisons require a number");
+ }
+ if check.operator == "contains" && !expected.is_string() {
+ return Err("Contains requires a text value");
+ }
+ if check.target == "status"
+ && (!expected.is_number()
+ || !matches!(
+ check.operator.as_str(),
+ "equals" | "not_equals" | "greater_than" | "less_than"
+ ))
+ {
+ return Err("Status comparisons require a number");
+ }
+ if check.target == "header" && !expected.is_string() {
+ return Err("Header comparisons require text");
+ }
+ Ok(())
+}
+
+pub fn validate(definition: &HttpAssertions) -> Result<(), String> {
+ if definition.version != 1 {
+ return Err("Unsupported assertion version".into());
+ }
+ if definition.checks.len() > MAX_CHECKS {
+ return Err("At most 100 assertions are supported per request".into());
+ }
+ let mut ids = HashSet::new();
+ for check in &definition.checks {
+ if !ids.insert(&check.id) {
+ return Err("Assertion IDs must be unique".into());
+ }
+ if check.enabled {
+ validate_check(check).map_err(str::to_owned)?;
+ }
+ }
+ Ok(())
+}
+
+pub fn validation_errors(definition: &HttpAssertions) -> std::collections::HashMap {
+ let mut errors = std::collections::HashMap::new();
+ if definition.version != 1 || definition.checks.len() > MAX_CHECKS {
+ errors.insert(String::new(), validate(definition).unwrap_err());
+ return errors;
+ }
+ let mut ids = HashSet::new();
+ for check in &definition.checks {
+ if !ids.insert(&check.id) {
+ errors.insert(check.id.clone(), "Assertion IDs must be unique".into());
+ }
+ if check.enabled
+ && let Err(message) = validate_check(check)
+ {
+ errors.insert(check.id.clone(), message.into());
+ }
+ }
+ errors
+}
+
+fn numeric_cmp(a: &Value, b: &Value) -> Option {
+ if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
+ return Some(a.cmp(&b));
+ }
+ if let (Some(a), Some(b)) = (a.as_u64(), b.as_u64()) {
+ return Some(a.cmp(&b));
+ }
+ if a.as_i64().is_some_and(|n| n < 0) && b.as_u64().is_some() {
+ return Some(Ordering::Less);
+ }
+ if b.as_i64().is_some_and(|n| n < 0) && a.as_u64().is_some() {
+ return Some(Ordering::Greater);
+ }
+ for value in [a, b] {
+ if value.as_u64().is_some_and(|n| n > 9_007_199_254_740_992)
+ || value.as_i64().is_some_and(|n| n < -9_007_199_254_740_992)
+ {
+ return None;
+ }
+ }
+ let (a, b) = (a.as_f64()?, b.as_f64()?);
+ // Avoid rounding distinct large integers into the same floating-point value.
+ if a.abs() > 9_007_199_254_740_992.0 || b.abs() > 9_007_199_254_740_992.0 {
+ return None;
+ }
+ a.partial_cmp(&b)
+}
+
+fn compare(actual: &Value, expected: &Value, op: &str) -> Result {
+ if op == "contains" {
+ return actual
+ .as_str()
+ .zip(expected.as_str())
+ .map(|(a, b)| a.contains(b))
+ .ok_or("Value is not text");
+ }
+ if actual.is_number() && expected.is_number() {
+ let cmp = numeric_cmp(actual, expected)
+ .ok_or("Number is outside the supported comparison range")?;
+ return Ok(match op {
+ "equals" => cmp.is_eq(),
+ "not_equals" => !cmp.is_eq(),
+ "greater_than" => cmp.is_gt(),
+ "less_than" => cmp.is_lt(),
+ _ => false,
+ });
+ }
+ if matches!(op, "greater_than" | "less_than") {
+ return Err("Value is not a number");
+ }
+ if !(actual.is_string() && expected.is_string()
+ || actual.is_boolean() && expected.is_boolean()
+ || actual.is_null() && expected.is_null())
+ {
+ return Err("Value has a different type");
+ }
+ Ok(if op == "not_equals" { actual != expected } else { actual == expected })
+}
+
+pub fn evaluate(definition: &HttpAssertions, response: Response<'_>) -> AssertionReport {
+ let error = validate(definition).err();
+ if definition.checks.len() > MAX_CHECKS {
+ return AssertionReport { definition: definition.clone(), results: Vec::new(), error };
+ }
+ let json = if error.is_none()
+ && matches!(response.completion, Completion::Complete)
+ && definition.needs_body()
+ {
+ match response.body {
+ Body::Bytes(bytes) if bytes.len() <= MAX_BODY_BYTES => {
+ serde_json::from_slice::(bytes)
+ .map_err(|_| "Response body is not valid UTF-8 JSON")
+ }
+ Body::TooLarge | Body::Bytes(_) => {
+ Err("Response body exceeds the 10 MiB assertion limit")
+ }
+ Body::Unavailable => Err("Response body is unavailable"),
+ }
+ } else {
+ Ok(Value::Null)
+ };
+ let results = definition
+ .checks
+ .iter()
+ .map(|check| {
+ let result = |outcome: &str, reason: &str, actual: Option| AssertionResult {
+ assertion_id: check.id.clone(),
+ outcome: outcome.into(),
+ reason: reason.into(),
+ actual,
+ };
+ if !check.enabled {
+ return result("skipped", "Disabled", None);
+ }
+ if let Some(error) = &error {
+ return result("invalid", error, None);
+ }
+ match response.completion {
+ Completion::Canceled => return result("error", "Request canceled", None),
+ Completion::Error => {
+ return result("error", "Request did not complete successfully", None);
+ }
+ Completion::Complete => {}
+ }
+ let status = Value::from(response.status);
+ let header_values: Vec = if check.target == "header" {
+ response
+ .headers
+ .iter()
+ .filter(|(name, _)| name.eq_ignore_ascii_case(check.selector.trim()))
+ .map(|(_, value)| Value::String(value.clone()))
+ .collect()
+ } else {
+ Vec::new()
+ };
+ // Borrow JSON matches: recursive selectors can select overlapping subtrees.
+ // Cloning every selected subtree would multiply memory well beyond the body limit.
+ let values: Result, &str> = match check.target.as_str() {
+ "status" => Ok(vec![&status]),
+ "header" => Ok(header_values.iter().collect()),
+ "json" => json.as_ref().map_err(|e| *e).map(|body| {
+ JsonPath::parse(&normalized_path(&check.selector))
+ .expect("validated path")
+ .query(body)
+ .all()
+ }),
+ _ => unreachable!("validated target"),
+ };
+ let values = match values {
+ Ok(values) => values,
+ Err(message) => return result("error", message, None),
+ };
+ if values.is_empty() {
+ return result("failed", "Value was not found", None);
+ }
+ if check.operator == "exists" {
+ return result("passed", "Value exists", None);
+ }
+ if check.target == "json" && values.len() != 1 {
+ return result("error", "JSONPath must select one value for this comparison", None);
+ }
+ let expected = expected_value(check).expect("validated expected value");
+ let mut reason = "Value did not match";
+ let mut all_different = true;
+ for actual in &values {
+ match compare(actual, &expected, &check.operator) {
+ Ok(true) if check.operator != "not_equals" => {
+ return result("passed", "Comparison passed", None);
+ }
+ Ok(true) => {}
+ Ok(false) => all_different = false,
+ Err(message) => {
+ reason = message;
+ all_different = false;
+ }
+ }
+ }
+ if check.operator == "not_equals" && all_different {
+ return result("passed", "Comparison passed", None);
+ }
+ // Details are local to the response; CLI reports deliberately omit actual values.
+ let actual = values
+ .first()
+ .filter(|v| {
+ v.is_null()
+ || v.is_boolean()
+ || v.is_number()
+ || v.as_str().is_some_and(|s| s.len() <= 1024)
+ })
+ .map(|v| (*v).clone());
+ result("failed", reason, actual)
+ })
+ .collect();
+ AssertionReport { definition: definition.clone(), results, error }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn check(path: &str, op: &str, expected: &str, kind: &str) -> HttpAssertion {
+ HttpAssertion {
+ id: "check".into(),
+ selector: path.into(),
+ operator: op.into(),
+ expected: expected.into(),
+ expected_type: kind.into(),
+ ..Default::default()
+ }
+ }
+ fn run(check: HttpAssertion, bytes: &[u8]) -> AssertionResult {
+ evaluate(
+ &HttpAssertions { checks: vec![check], ..Default::default() },
+ Response {
+ status: 200,
+ headers: &[],
+ body: Body::Bytes(bytes),
+ completion: Completion::Complete,
+ },
+ )
+ .results
+ .remove(0)
+ }
+ #[test]
+ fn json_paths_and_strict_scalar_types() {
+ let body = br#"{"user":{"id":123,"name":"Greg","active":true,"deleted":null},"items":[1,2],"a.b":"value"}"#;
+ for (path, op, expected, kind, outcome) in [
+ ("$.user.id", "equals", "123", "number", "passed"),
+ ("user.id", "equals", "123.0", "number", "passed"),
+ ("user.id", "equals", "123", "text", "failed"),
+ ("user.id", "not_equals", "123", "text", "failed"),
+ ("user.id", "greater_than", "122", "number", "passed"),
+ ("user.id", "less_than", "124", "number", "passed"),
+ ("user.name", "contains", "reg", "text", "passed"),
+ ("user.active", "equals", "true", "boolean", "passed"),
+ ("user.deleted", "exists", "", "text", "passed"),
+ ("user.deleted", "equals", "", "null", "passed"),
+ ("user.missing", "equals", "", "null", "failed"),
+ ("user.missing", "not_equals", "anything", "text", "failed"),
+ ("items[*]", "equals", "1", "number", "error"),
+ ("items[*]", "exists", "", "text", "passed"),
+ ("['a.b']", "equals", "value", "text", "passed"),
+ ("user", "equals", "{}", "text", "failed"),
+ ] {
+ assert_eq!(
+ run(check(path, op, expected, kind), body).outcome,
+ outcome,
+ "{path} {op} {expected} {kind}"
+ );
+ }
+ }
+ #[test]
+ fn invalid_configs_are_not_failures_or_passes() {
+ for c in [
+ check("$[", "equals", "1", "number"),
+ check("id", "equals", "NaN", "number"),
+ check("id", "greater_than", "2", "text"),
+ check("id", "script", "", "text"),
+ check("id", "equals", "yes", "boolean"),
+ ] {
+ assert_eq!(run(c, b"{}").outcome, "invalid");
+ }
+ let c = check("id", "exists", "", "text");
+ assert!(validate(&HttpAssertions { version: 2, checks: vec![] }).is_err());
+ assert!(
+ validate(&HttpAssertions { checks: vec![c.clone(), c], ..Default::default() }).is_err()
+ );
+ }
+ #[test]
+ fn duplicate_headers_match_case_insensitively_without_joining() {
+ let headers = vec![
+ ("X-Test".into(), "one".into()),
+ ("x-test".into(), "two".into()),
+ ];
+ for (op, expected, outcome) in [
+ ("equals", "two", "passed"),
+ ("equals", "one,two", "failed"),
+ ("not_equals", "one", "failed"),
+ ("not_equals", "three", "passed"),
+ ] {
+ let mut c = check("X-TEST", op, expected, "text");
+ c.target = "header".into();
+ let report = evaluate(
+ &HttpAssertions { checks: vec![c], ..Default::default() },
+ Response {
+ status: 200,
+ headers: &headers,
+ body: Body::Unavailable,
+ completion: Completion::Complete,
+ },
+ );
+ assert_eq!(report.results[0].outcome, outcome);
+ }
+ }
+ #[test]
+ fn incomplete_requests_cannot_pass_even_a_status_check() {
+ for completion in [Completion::Error, Completion::Canceled] {
+ let mut c = check("", "equals", "200", "number");
+ c.target = "status".into();
+ let report = evaluate(
+ &HttpAssertions { checks: vec![c], ..Default::default() },
+ Response { status: 200, headers: &[], body: Body::Bytes(b"{}"), completion },
+ );
+ assert_eq!(report.results[0].outcome, "error");
+ }
+ }
+ #[test]
+ fn body_errors_do_not_prevent_status_checks() {
+ let c = check("id", "exists", "", "text");
+ for body in [
+ Body::Unavailable,
+ Body::TooLarge,
+ Body::Bytes(b"not json"),
+ Body::Bytes(b"\xff"),
+ ] {
+ let mut status = check("", "equals", "200", "number");
+ status.target = "status".into();
+ status.id = "status".into();
+ let report = evaluate(
+ &HttpAssertions { checks: vec![c.clone(), status], ..Default::default() },
+ Response { status: 200, headers: &[], body, completion: Completion::Complete },
+ );
+ assert_eq!(report.results[0].outcome, "error");
+ assert_eq!(report.results[1].outcome, "passed");
+ }
+ }
+ #[test]
+ fn disabled_checks_and_large_numbers() {
+ let mut c = check("invalid path", "unknown", "", "bad");
+ c.enabled = false;
+ assert_eq!(run(c, b"{}").outcome, "skipped");
+ assert_eq!(
+ run(check("id", "equals", "9007199254740993", "number"), br#"{"id":9007199254740992}"#)
+ .outcome,
+ "failed"
+ );
+ assert_eq!(
+ run(check("id", "equals", "9007199254740993", "number"), br#"{"id":9007199254740993}"#)
+ .outcome,
+ "passed"
+ );
+ assert_eq!(
+ run(
+ check("id", "equals", "9007199254740992.0", "number"),
+ br#"{"id":9007199254740993}"#
+ )
+ .outcome,
+ "failed"
+ );
+ }
+}
diff --git a/crates/yaak-git/bindings/gen_assertions.ts b/crates/yaak-git/bindings/gen_assertions.ts
new file mode 100644
index 000000000..e42b56f57
--- /dev/null
+++ b/crates/yaak-git/bindings/gen_assertions.ts
@@ -0,0 +1,9 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Strings intentionally retain unknown operators/types so they fail validation, rather than
+ * disappearing during import or being treated as an empty, passing collection.
+ */
+export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
+
+export type HttpAssertions = { version: number, checks: Array, };
diff --git a/crates/yaak-git/bindings/gen_models.ts b/crates/yaak-git/bindings/gen_models.ts
index 2474d8eed..66f80936e 100644
--- a/crates/yaak-git/bindings/gen_models.ts
+++ b/crates/yaak-git/bindings/gen_models.ts
@@ -1,179 +1,54 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { HttpAssertions } from "./gen_assertions";
-export type DnsOverride = {
- hostname: string;
- ipv4: Array;
- ipv6: Array;
- enabled?: boolean;
-};
+export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, };
-export type Environment = {
- model: "environment";
- id: string;
- workspaceId: string;
- createdAt: string;
- updatedAt: string;
- name: string;
- public: boolean;
- parentModel: string;
- parentId: string | null;
- /**
- * Variables defined in this environment scope.
- * Child environments override parent variables by name.
- */
- variables: Array;
- color: string | null;
- sortPriority: number;
-};
+export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
+/**
+ * Variables defined in this environment scope.
+ * Child environments override parent variables by name.
+ */
+variables: Array, color: string | null, sortPriority: number, };
-export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
+export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
-export type Folder = {
- model: "folder";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- name: string;
- sortPriority: number;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingFollowRedirects: InheritedBoolSetting;
- settingRequestTimeout: InheritedIntSetting;
- settingRequestMessageSize: InheritedIntSetting;
- settingHttpVersion: InheritedHttpVersionSetting;
-};
+export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
-export type GrpcRequest = {
- model: "grpc_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authenticationType: string | null;
- authentication: Record;
- description: string;
- message: string;
- metadata: Array;
- method: string | null;
- name: string;
- service: string | null;
- sortPriority: number;
- /**
- * Server URL (http for plaintext or https for secure)
- */
- url: string;
- settingValidateCertificates: InheritedBoolSetting;
- settingRequestMessageSize: InheritedIntSetting;
-};
+export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number,
+/**
+ * Server URL (http for plaintext or https for secure)
+ */
+url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
-export type HttpRequest = {
- model: "http_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- body: Record;
- bodyType: string | null;
- description: string;
- headers: Array;
- method: string;
- name: string;
- sortPriority: number;
- url: string;
- /**
- * URL parameters used for both path placeholders (`:id`) and query string entries.
- */
- urlParameters: Array;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingFollowRedirects: InheritedBoolSetting;
- settingRequestTimeout: InheritedIntSetting;
- settingHttpVersion: InheritedHttpVersionSetting;
-};
+export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
+/**
+ * URL parameters used for both path placeholders (`:id`) and query string entries.
+ */
+urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
-export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
+export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
-export type HttpUrlParameter = {
- enabled?: boolean;
- /**
- * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
- * Other entries are appended as query parameters
- */
- name: string;
- value: string;
- id?: string;
-};
+export type HttpUrlParameter = { enabled?: boolean,
+/**
+ * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
+ * Other entries are appended as query parameters
+ */
+name: string, value: string, id?: string, };
export type HttpVersion = "auto" | "http1" | "http2";
-export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
-export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
-export type InheritedIntSetting = { enabled?: boolean; value: number };
+export type InheritedIntSetting = { enabled?: boolean, value: number, };
-export type SyncModel =
- | ({ type: "workspace" } & Workspace)
- | ({ type: "environment" } & Environment)
- | ({ type: "folder" } & Folder)
- | ({ type: "http_request" } & HttpRequest)
- | ({ type: "grpc_request" } & GrpcRequest)
- | ({ type: "websocket_request" } & WebsocketRequest);
+export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
-export type WebsocketRequest = {
- model: "websocket_request";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- folderId: string | null;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- message: string;
- name: string;
- sortPriority: number;
- url: string;
- /**
- * URL parameters used for both path placeholders (`:id`) and query string entries.
- */
- urlParameters: Array;
- settingSendCookies: InheritedBoolSetting;
- settingStoreCookies: InheritedBoolSetting;
- settingValidateCertificates: InheritedBoolSetting;
- settingRequestMessageSize: InheritedIntSetting;
-};
+export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string,
+/**
+ * URL parameters used for both path placeholders (`:id`) and query string entries.
+ */
+urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
-export type Workspace = {
- model: "workspace";
- id: string;
- createdAt: string;
- updatedAt: string;
- authentication: Record;
- authenticationType: string | null;
- description: string;
- headers: Array;
- name: string;
- encryptionKeyChallenge: string | null;
- settingValidateCertificates: boolean;
- settingFollowRedirects: boolean;
- settingRequestTimeout: number;
- settingRequestMessageSize: number;
- settingDnsOverrides: Array;
- settingSendCookies: boolean;
- settingStoreCookies: boolean;
- settingHttpVersion: HttpVersion;
-};
+export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
diff --git a/crates/yaak-jsonpath/Cargo.toml b/crates/yaak-jsonpath/Cargo.toml
new file mode 100644
index 000000000..deafd5b95
--- /dev/null
+++ b/crates/yaak-jsonpath/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "yaak-jsonpath"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+serde_json_path = "0.7.2"
+ts-rs = { workspace = true }
diff --git a/crates/yaak-jsonpath/bindings/gen_jsonpath.ts b/crates/yaak-jsonpath/bindings/gen_jsonpath.ts
new file mode 100644
index 000000000..29ec699a5
--- /dev/null
+++ b/crates/yaak-jsonpath/bindings/gen_jsonpath.ts
@@ -0,0 +1,13 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JsonPathChild = {
+/**
+ * A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
+ */
+selector: string, label: string, kind: string, };
+
+export type JsonPathChildren = { children: Array,
+/**
+ * Discovery is sampled/bounded, never an assertion that other keys don't exist.
+ */
+truncated: boolean, };
diff --git a/crates/yaak-jsonpath/src/lib.rs b/crates/yaak-jsonpath/src/lib.rs
new file mode 100644
index 000000000..42200c40b
--- /dev/null
+++ b/crates/yaak-jsonpath/src/lib.rs
@@ -0,0 +1,226 @@
+//! JSON selection and discovery shared by assertions and host APIs. No I/O or plugin runtime.
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::BTreeMap;
+use ts_rs::TS;
+
+pub use serde_json_path::JsonPath;
+pub const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
+pub const MAX_SELECTOR_BYTES: usize = 1024;
+const MAX_CHILDREN: usize = 100;
+const MAX_PARENTS: usize = 20;
+const MAX_ARRAY_INDICES: usize = 20;
+
+pub fn normalized_path(selector: &str) -> String {
+ let s = selector.trim();
+ if s.starts_with('$') {
+ s.into()
+ } else if s.starts_with('[') {
+ format!("${s}")
+ } else {
+ format!("$.{s}")
+ }
+}
+
+pub fn parse(selector: &str) -> Result {
+ if selector.trim().is_empty() || selector.len() > MAX_SELECTOR_BYTES {
+ return Err("Enter a valid JSONPath");
+ }
+ JsonPath::parse(&normalized_path(selector)).map_err(|_| "Enter a valid JSONPath")
+}
+
+pub fn parse_body(bytes: &[u8]) -> Result {
+ if bytes.len() > MAX_BODY_BYTES {
+ return Err("Response body exceeds the 10 MiB limit");
+ }
+ serde_json::from_slice(bytes).map_err(|_| "Response body is not valid UTF-8 JSON")
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_jsonpath.ts")]
+pub struct JsonPathChild {
+ /// A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
+ pub selector: String,
+ pub label: String,
+ pub kind: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export, export_to = "gen_jsonpath.ts")]
+pub struct JsonPathChildren {
+ pub children: Vec,
+ /// Discovery is sampled/bounded, never an assertion that other keys don't exist.
+ pub truncated: bool,
+}
+
+fn kind(value: &Value) -> &'static str {
+ match value {
+ Value::Null => "null",
+ Value::Bool(_) => "boolean",
+ Value::Number(_) => "number",
+ Value::String(_) => "text",
+ Value::Array(_) => "array",
+ Value::Object(_) => "object",
+ }
+}
+
+fn key_selector(key: &str) -> String {
+ let mut chars = key.chars();
+ let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
+ if first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
+ format!(".{key}")
+ } else {
+ format!("[{}]", serde_json::to_string(key).expect("string is JSON serializable"))
+ }
+}
+
+/// Enumerate just the next level. Wildcard parents sample up to 20 matches, merging keys/types.
+/// Returns structure only; response values (possibly secrets) never appear in suggestions.
+pub fn children(document: &Value, parent: &str) -> Result {
+ let query = parse(parent)?;
+ let parents = query.query(document).all();
+ let mut result =
+ JsonPathChildren { truncated: parents.len() > MAX_PARENTS, ..Default::default() };
+ let mut indices = BTreeMap::::new();
+ for value in parents.into_iter().take(MAX_PARENTS) {
+ let mut add = |selector: String, label: String, kind: &str| {
+ if selector.len() + parent.len() > MAX_SELECTOR_BYTES {
+ result.truncated = true;
+ return;
+ }
+ if let Some(&index) = indices.get(&selector) {
+ if result.children[index].kind != kind {
+ result.children[index].kind = "mixed".into();
+ }
+ } else if result.children.len() < MAX_CHILDREN {
+ indices.insert(selector.clone(), result.children.len());
+ result.children.push(JsonPathChild { selector, label, kind: kind.into() });
+ } else {
+ result.truncated = true;
+ }
+ };
+ match value {
+ Value::Object(object) => {
+ for (key, value) in object {
+ add(key_selector(key), key.clone(), kind(value));
+ }
+ }
+ Value::Array(array) => {
+ if !array.is_empty() {
+ add("[*]".into(), "[*]".into(), "all items");
+ }
+ for (index, value) in array.iter().take(MAX_ARRAY_INDICES).enumerate() {
+ add(format!("[{index}]"), format!("[{index}]"), kind(value));
+ }
+ if array.len() > MAX_ARRAY_INDICES {
+ result.truncated = true;
+ }
+ }
+ _ => {}
+ }
+ }
+ Ok(result)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn discovers_one_level_at_a_time_and_merges_wildcard_children() {
+ let document = json!({"users": [{"name": "SECRET", "id": 1}, {"id": "two", "active": true}], "total": 2});
+ let root = children(&document, "$").unwrap();
+ assert_eq!(
+ root.children.iter().map(|c| c.label.as_str()).collect::>(),
+ ["total", "users"]
+ );
+ assert!(!serde_json::to_string(&root).unwrap().contains("SECRET"));
+ let users = children(&document, "users").unwrap();
+ assert_eq!(
+ users.children.iter().map(|c| c.selector.as_str()).collect::>(),
+ ["[*]", "[0]", "[1]"]
+ );
+ let fields = children(&document, "$.users[*]").unwrap();
+ assert_eq!(fields.children.iter().find(|c| c.label == "id").unwrap().kind, "mixed");
+ assert_eq!(fields.children.iter().find(|c| c.label == "active").unwrap().kind, "boolean");
+ assert!(children(&document, "$.users[0].id").unwrap().children.is_empty());
+ assert!(children(&document, "$.missing").unwrap().children.is_empty());
+ }
+
+ #[test]
+ fn suggestions_round_trip_unusual_keys_without_interpreting_them() {
+ for key in [
+ "",
+ "a.b",
+ "space key",
+ "quote\"key",
+ "single'key",
+ "back\\slash",
+ "bracket]key",
+ "*",
+ "..",
+ "$",
+ "@number()",
+ "line\nbreak\t\u{0}",
+ "emoji 🤔",
+ "__proto__",
+ "constructor",
+ ] {
+ let document = json!({key: "needle", "decoy": "wrong"});
+ let found = children(&document, "$")
+ .unwrap()
+ .children
+ .into_iter()
+ .find(|c| c.label == key)
+ .unwrap();
+ assert_eq!(
+ parse(&format!("${}", found.selector)).unwrap().query(&document).all(),
+ vec![&json!("needle")],
+ "key {key:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn discovery_is_bounded_and_marks_sampling() {
+ let document = json!((0..30).map(|i| json!({"id": i})).collect::>());
+ let items = children(&document, "$").unwrap();
+ assert_eq!(items.children.len(), MAX_ARRAY_INDICES + 1);
+ assert!(items.truncated);
+ assert!(children(&document, "$[*]").unwrap().truncated);
+ let many = Value::Object((0..110).map(|i| (format!("key{i}"), Value::Null)).collect());
+ let result = children(&many, "$").unwrap();
+ assert_eq!(result.children.len(), MAX_CHILDREN);
+ assert!(result.truncated);
+ assert!(parse_body(&vec![b' '; MAX_BODY_BYTES + 1]).is_err());
+ }
+
+ // Characterize the existing plugin's expressions before migrating its engine.
+ #[test]
+ fn standard_plugin_filters_work_but_javascript_extensions_are_not_supported() {
+ let document = json!({"items": [{"id": 1}, {"id": 2}, {"id": 3}], "space key": 4});
+ for (path, expected) in [
+ ("$.items[1].id", json!([2])),
+ ("$['space key']", json!([4])),
+ ("$.items[*].id", json!([1, 2, 3])),
+ ("$..id", json!([1, 2, 3])),
+ ("$.items[0:2].id", json!([1, 2])),
+ ("$.items[0,2].id", json!([1, 3])),
+ ("$.items[?(@.id > 1)].id", json!([2, 3])),
+ ("$.missing", json!([])),
+ ] {
+ assert_eq!(
+ serde_json::to_value(parse(path).unwrap().query(&document).all()).unwrap(),
+ expected,
+ "{path}"
+ );
+ }
+ assert!(parse("$.items[(@.length-1)].id").is_err());
+ assert!(parse("$[\"items\"].$").is_err());
+ // serde_json cannot represent the unpaired UTF-16 surrogate accepted by JS JSON.parse.
+ assert!(parse_body(br#"{"\ud800":"value"}"#).is_err());
+ }
+}
diff --git a/crates/yaak-models/Cargo.toml b/crates/yaak-models/Cargo.toml
index e9cff8cbf..c027b0203 100644
--- a/crates/yaak-models/Cargo.toml
+++ b/crates/yaak-models/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
publish = false
[dependencies]
+yaak-assertions = { workspace = true }
chrono = { version = "0.4.38", features = ["serde"] }
yaak-database = { workspace = true }
hex = { workspace = true }
diff --git a/crates/yaak-models/bindings/gen_assertions.ts b/crates/yaak-models/bindings/gen_assertions.ts
new file mode 100644
index 000000000..a30548a77
--- /dev/null
+++ b/crates/yaak-models/bindings/gen_assertions.ts
@@ -0,0 +1,14 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { JsonValue } from "./serde_json/JsonValue";
+
+export type AssertionReport = { definition: HttpAssertions, results: Array, error: string | null, };
+
+export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
+
+/**
+ * Strings intentionally retain unknown operators/types so they fail validation, rather than
+ * disappearing during import or being treated as an empty, passing collection.
+ */
+export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
+
+export type HttpAssertions = { version: number, checks: Array, };
diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts
index c4a4a1f4f..437936103 100644
--- a/crates/yaak-models/bindings/gen_models.ts
+++ b/crates/yaak-models/bindings/gen_models.ts
@@ -1,309 +1,74 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModelChangeEvent } from "./ModelChangeEvent";
+import type { AssertionReport, HttpAssertions } from "./gen_assertions";
-export type AnyModel =
- | CookieJar
- | Environment
- | Folder
- | GraphQlIntrospection
- | GrpcConnection
- | GrpcEvent
- | GrpcRequest
- | HttpRequest
- | HttpResponse
- | HttpResponseEvent
- | ImportSource
- | KeyValue
- | Plugin
- | Settings
- | SyncState
- | WebsocketConnection
- | WebsocketEvent
- | WebsocketRequest
- | Workspace
- | WorkspaceMeta;
+export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | ImportSource | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
-export type ClientCertificate = {
- host: string;
- port: number | null;
- crtFile: string | null;
- keyFile: string | null;
- pfxFile: string | null;
- passphrase: string | null;
- enabled?: boolean;
-};
+export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
-export type Cookie = {
- name: string;
- value: string;
- domain: CookieDomain;
- expires: CookieExpires;
- path: string;
- secure: boolean;
- httpOnly: boolean;
- sameSite: CookieSameSite | null;
-};
+export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
-export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
+export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
-export type CookieExpires = { AtUtc: string } | "SessionEnd";
+export type CookieExpires = { "AtUtc": string } | "SessionEnd";
-export type CookieJar = {
- model: "cookie_jar";
- id: string;
- createdAt: string;
- updatedAt: string;
- workspaceId: string;
- cookies: Array;
- name: string;
-};
+export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, };
export type CookieSameSite = "Strict" | "Lax" | "None";
-export type DnsOverride = {
- hostname: string;
- ipv4: Array;
- ipv6: Array;
- enabled?: boolean;
-};
+export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
-export type EncryptedKey = { encryptedKey: string };
+export type EncryptedKey = { encryptedKey: string, };
-export type Environment = {
- model: "environment";
- id: string;
- workspaceId: string;
- createdAt: string;
- updatedAt: string;
- name: string;
- public: boolean;
- parentModel: string;
- parentId: string | null;
- /**
- * Variables defined in this environment scope.
- * Child environments override parent variables by name.
- */
- variables: Array;
- color: string | null;
- sortPriority: number;
-};
+export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
+/**
+ * Variables defined in this environment scope.
+ * Child environments override parent variables by name.
+ */
+variables: Array