diff --git a/Cargo.lock b/Cargo.lock index 49e25e670..eade521bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3781,6 +3781,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -7906,6 +7915,56 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom 7.1.3", + "regex 1.13.1", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.20", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -11237,9 +11296,11 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "yaak-assertions", "yaak-core", "yaak-crypto", "yaak-http", + "yaak-jsonpath", "yaak-models", "yaak-plugins", "yaak-templates", @@ -11342,6 +11403,17 @@ dependencies = [ "yaak-window", ] +[[package]] +name = "yaak-assertions" +version = "0.1.0" +dependencies = [ + "schemars 0.8.22", + "serde", + "serde_json", + "ts-rs", + "yaak-jsonpath", +] + [[package]] name = "yaak-cli" version = "0.1.0" @@ -11374,6 +11446,7 @@ dependencies = [ "webbrowser", "yaak", "yaak-api", + "yaak-assertions", "yaak-core", "yaak-crypto", "yaak-http", @@ -11543,6 +11616,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "yaak-jsonpath" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "serde_json_path", + "ts-rs", +] + [[package]] name = "yaak-license" version = "0.1.0" @@ -11603,6 +11686,7 @@ dependencies = [ "thiserror 2.0.20", "ts-rs", "urlencoding", + "yaak-assertions", "yaak-core", "yaak-database", "yaak-templates", @@ -11708,6 +11792,7 @@ dependencies = [ "ts-rs", "yaak-git", "yaak-grpc", + "yaak-jsonpath", "yaak-models", "yaak-plugins", "yaak-sse", @@ -11811,6 +11896,8 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "yaak-assertions", + "yaak-jsonpath", "yaak-lifecycle", "yaak-models", "yaak-templates", diff --git a/Cargo.toml b/Cargo.toml index 1e877ca55..6f321b2e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ resolver = "2" members = [ "crates/yaak", + "crates/yaak-assertions", + "crates/yaak-jsonpath", "crates/yaak-commands", # Common/foundation crates "crates/common/yaak-database", @@ -82,6 +84,8 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" } # Internal crates - shared yaak-core = { path = "crates/yaak-core" } yaak = { path = "crates/yaak" } +yaak-assertions = { path = "crates/yaak-assertions" } +yaak-jsonpath = { path = "crates/yaak-jsonpath" } yaak-commands = { path = "crates/yaak-commands" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } diff --git a/apps/yaak-client/components/HttpAssertionPreview.tsx b/apps/yaak-client/components/HttpAssertionPreview.tsx new file mode 100644 index 000000000..24be6b118 --- /dev/null +++ b/apps/yaak-client/components/HttpAssertionPreview.tsx @@ -0,0 +1,79 @@ +import deepEqual from "@gilbarbara/deep-equal"; +import { useMutation } from "@tanstack/react-query"; +import type { AssertionReport, HttpAssertions, HttpResponse } from "@yaakapp-internal/models"; +import { format } from "date-fns"; +import { rpc } from "../lib/rpc"; +import { Button } from "./core/Button"; +import { HttpAssertionReport } from "./HttpAssertionResults"; + +interface Props { + assertions: HttpAssertions; + response: HttpResponse | null; +} + +export function HttpAssertionPreview(props: Props) { + // A response switch (including a new send) discards results and in-flight UI + // state. The old evaluation may finish, but it cannot label the new response. + return ; +} + +function ResponsePreview({ assertions, response }: Props) { + const preview = useMutation({ + mutationFn: (definition: HttpAssertions) => + rpc("cmd_preview_http_assertions", { + responseId: response?.id, + assertions: definition, + }), + retry: false, + gcTime: 0, + }); + const unavailable = !response + ? "Send a request first to get a response to try." + : response.state !== "closed" + ? "Wait for the response to finish." + : !assertions.checks.some((check) => check.enabled) + ? "Enable an assertion to try it." + : null; + const changed = preview.data != null && !deepEqual(preview.data.definition, assertions); + + return ( +
+ +

+ {unavailable ?? "Preview against the selected response without sending again."} +

+ {preview.isError && ( +

+ Could not preview assertions:{" "} + {preview.error instanceof Error ? preview.error.message : String(preview.error)} +

+ )} + {preview.data && response && ( +
+

Preview

+

+ Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results + are unchanged. +

+ {changed && ( +

+ Assertions changed. Try again to update this preview. +

+ )} + +
+ )} +
+ ); +} diff --git a/apps/yaak-client/components/HttpAssertionResults.tsx b/apps/yaak-client/components/HttpAssertionResults.tsx new file mode 100644 index 000000000..940073c38 --- /dev/null +++ b/apps/yaak-client/components/HttpAssertionResults.tsx @@ -0,0 +1,81 @@ +import type { AssertionReport, HttpResponse } from "@yaakapp-internal/models"; +import classNames from "classnames"; +import { EmptyStateText } from "./EmptyStateText"; + +export function HttpAssertionResults({ response }: { response: HttpResponse }) { + const report = response.assertionResults; + if (!report) + return Assertions were not evaluated for this response.; + if (!report.error && report.results.length === 0) + return Waiting for the response to finish.; + return ( +
+

Checks saved with this response.

+ +
+ ); +} + +export function HttpAssertionReport({ report }: { report: AssertionReport }) { + const passed = report.results.filter((r) => r.outcome === "passed").length; + const failed = report.results.filter((r) => r.outcome === "failed").length; + const errors = report.results.filter( + (r) => r.outcome === "error" || r.outcome === "invalid", + ).length; + const skipped = report.results.filter((r) => r.outcome === "skipped").length; + return ( +
+

+ {passed} passed · {failed} failed · {errors} errors · {skipped} skipped +

+ {report.error &&

{report.error}

} + {report.results.map((result, index) => { + const check = report.definition.checks[index]; + return ( +
+
+ + {check?.target === "status" ? "Status" : check?.selector} + + + {result.outcome} + +
+ {check && ( +

+ {check.operator.replaceAll("_", " ")} + {check.operator !== "exists" && ( + <> + {" "} + {check.expectedType === "null" + ? "null" + : check.expectedType === "text" + ? JSON.stringify(check.expected) + : check.expected}{" "} + ({check.expectedType}) + + )} +

+ )} +

{result.reason}

+ {result.actual != null && ( +

Actual: {JSON.stringify(result.actual)}

+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/yaak-client/components/HttpAssertionsEditor.tsx b/apps/yaak-client/components/HttpAssertionsEditor.tsx new file mode 100644 index 000000000..599c6cbd0 --- /dev/null +++ b/apps/yaak-client/components/HttpAssertionsEditor.tsx @@ -0,0 +1,216 @@ +import { useQuery } from "@tanstack/react-query"; +import type { HttpAssertion, HttpAssertions, HttpRequest } from "@yaakapp-internal/models"; +import { patchModelDebounced } from "@yaakapp-internal/models"; +import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; +import { JsonPathInput } from "./JsonPathInput"; +import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; +import { useStateWithDeps } from "../hooks/useStateWithDeps"; +import { generateId } from "../lib/generateId"; +import { rpc } from "../lib/rpc"; +import { Button } from "./core/Button"; +import { Checkbox } from "./core/Checkbox"; +import { IconButton } from "./core/IconButton"; +import { PlainInput } from "./core/PlainInput"; +import { Select } from "./core/Select"; +import { HttpAssertionPreview } from "./HttpAssertionPreview"; + +const EMPTY: HttpAssertions = { version: 1, checks: [] }; +const operators = [ + { value: "equals", label: "Equals" }, + { value: "not_equals", label: "Does not equal" }, + { value: "greater_than", label: "Greater than" }, + { value: "less_than", label: "Less than" }, + { value: "contains", label: "Contains" }, + { value: "exists", label: "Exists" }, +]; + +export function HttpAssertionsEditor({ request }: { request: HttpRequest }) { + const updateKey = useRequestUpdateKey(request.id); + const { activeResponse } = usePinnedHttpResponse(request.id); + const [definition, setDefinition] = useStateWithDeps(request.assertions ?? EMPTY, [updateKey]); + const validation = useQuery({ + queryKey: ["validate_assertions", definition], + queryFn: () => + rpc>("cmd_validate_http_assertions", { assertions: definition }), + staleTime: Infinity, + gcTime: 0, + retry: false, + }); + + const save = (checks: HttpAssertion[]) => { + const next = { ...definition, checks }; + setDefinition(next); + patchModelDebounced(request, { assertions: next }); + }; + const update = (id: string, patch: Partial) => + save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c))); + + return ( +
+

+ Check the response each time you send. Results appear in the response’s Assertions tab. +

+ {definition.checks.map((check, index) => ( +
+
+ update(check.id, { enabled })} + /> + save(definition.checks.filter((c) => c.id !== check.id))} + /> +
+
+ + check.target === "json" || + (check.target === "status" + ? o.value !== "contains" && o.value !== "exists" + : !["greater_than", "less_than"].includes(o.value)), + )} + onChange={(operator) => + update(check.id, { + operator, + ...(["greater_than", "less_than"].includes(operator) + ? { expectedType: "number" } + : operator === "contains" + ? { expectedType: "text" } + : {}), + }) + } + /> + {check.operator !== "exists" && ( +
+ {check.expectedType !== "null" && ( +
+ update(check.id, { expected })} + /> +
+ )} + {check.target === "json" && + !["greater_than", "less_than", "contains"].includes(check.operator) && ( +
+ + {sampled && ( +

+ Suggestions are sampled. You can enter any path. +

+ )} +
+ ); +} diff --git a/apps/yaak-client/components/core/Editor/json/pathCompletion.test.ts b/apps/yaak-client/components/core/Editor/json/pathCompletion.test.ts new file mode 100644 index 000000000..501c166c2 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json/pathCompletion.test.ts @@ -0,0 +1,93 @@ +import { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { jsonPathCompletion, jsonPathCompletionTarget } from "./pathCompletion"; +import type { JsonPathChildren } from "./pathCompletion"; + +const root: JsonPathChildren = { + children: [ + { label: "users", selector: ".users", kind: "array" }, + { label: "total", selector: ".total", kind: "number" }, + { label: "a.b", selector: '["a.b"]', kind: "text" }, + { label: "a,b", selector: '["a,b"]', kind: "text" }, + ], + truncated: false, +}; +function context(text: string, explicit = false, pos = text.length) { + return new CompletionContext(EditorState.create({ doc: text }), pos, explicit); +} + +describe("progressive JSONPath completion", () => { + it.each([ + ["", "$", ""], + ["$", "$", ""], + ["$.", "$", "."], + ["us", "$", "us"], + ["users.na", "$.users", ".na"], + ["$.users[", "$.users", "["], + ["$.users[0].na", "$.users[0]", ".na"], + ["$.users[*].", "$.users[*]", "."], + ['$["a.b"].', '$["a.b"]', "."], + ['$["a.b', "$", '["a.b'], + ["$['a.b", "$", "['a.b"], + ['$["a,b', "$", '["a,b'], + ['$.users[?(@.name == "a.b")].', '$.users[?(@.name == "a.b")]', "."], + ])("finds the next level in %s", (text, parent, fragment) => { + expect(jsonPathCompletionTarget(text)).toEqual({ parent, fragment }); + }); + + it("loads only the parent, filters the typed prefix, and inserts a complete escaped path", async () => { + const load = vi.fn(async () => root); + const complete = jsonPathCompletion(load); + const result = await complete(context("us")); + expect(load).toHaveBeenCalledExactlyOnceWith("$"); + expect(result?.options.map((o) => o.label)).toEqual(["users"]); + const quoted = await complete(context('$["a.b')); + const option = quoted!.options[0]!; + const dispatch = vi.fn(); + expect(option.label).toBe("a.b"); + if (typeof option.apply !== "function") throw new Error("Expected a completion function"); + option.apply({ dispatch } as unknown as EditorView, option, 0, 6); + expect(dispatch.mock.calls[0]?.[0].changes).toEqual({ from: 0, to: 6, insert: '$["a.b"]' }); + expect((await complete(context("$['a.b")))?.options.map((o) => o.label)).toEqual(["a.b"]); + }); + + it("explores a completed container without turning the path into an invalid draft", async () => { + const array: JsonPathChildren = { + children: [ + { label: "[*]", selector: "[*]", kind: "all items" }, + { label: "[0]", selector: "[0]", kind: "object" }, + ], + truncated: false, + }; + const load = vi.fn(async (parent: string) => (parent === "$.users" ? array : root)); + const result = await jsonPathCompletion(load)(context("$.users", true)); + expect(load).toHaveBeenCalledExactlyOnceWith("$.users"); + expect(result?.options.map((o) => o.label)).toEqual(["[*]", "[0]"]); + expect( + (await jsonPathCompletion(load)(context("$.users[0")))?.options.map((o) => o.label), + ).toEqual(["[0]"]); + }); + + it("does not suggest descendants at the root or duplicate the current scalar path", async () => { + const load = vi.fn(async (parent: string) => + parent === "$" ? root : { children: [], truncated: false }, + ); + const result = await jsonPathCompletion(load)(context("")); + expect(result?.options.map((o) => o.label)).toEqual(["users", "total", "a.b", "a,b"]); + expect(await jsonPathCompletion(load)(context("$.total", true))).toBeNull(); + }); + + it("leaves complex expressions, mid-path editing, unavailable data, and stale results alone", async () => { + const load = vi.fn(async () => root); + expect(await jsonPathCompletion(load)(context("$.users[?(@.na"))).toBeNull(); + expect(await jsonPathCompletion(load)(context("$..na"))).toBeNull(); + expect(await jsonPathCompletion(load)(context("$.users[0].name", false, 5))).toBeNull(); + expect(load).not.toHaveBeenCalled(); + expect(await jsonPathCompletion(async () => null)(context("$"))).toBeNull(); + const stale = context("$."); + Object.defineProperty(stale, "aborted", { get: () => true }); + expect(await jsonPathCompletion(load)(stale)).toBeNull(); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/json/pathCompletion.ts b/apps/yaak-client/components/core/Editor/json/pathCompletion.ts new file mode 100644 index 000000000..093e64c4e --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json/pathCompletion.ts @@ -0,0 +1,119 @@ +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { startCompletion } from "@codemirror/autocomplete"; +import type { RpcSchema } from "@yaakapp-internal/rpc-schema"; + +export type JsonPathChildren = RpcSchema["cmd_http_response_json_children"][1]; +export type LoadJsonPathChildren = (parent: string) => Promise; + +export function normalizeJsonPath(value: string): string { + const text = value.trim(); + if (!text) return "$"; + return text.startsWith("$") ? text : text.startsWith("[") ? `$${text}` : `$.${text}`; +} + +/** Locate the unfinished child accessor, leaving complete JSONPath expressions to Rust. */ +export function jsonPathCompletionTarget( + text: string, +): { parent: string; fragment: string } | null { + text = text.trim(); + if (text.length > 1024) return null; + if (!text || text === "$") return { parent: "$", fragment: "" }; + let quote = ""; + let escaped = false; + let depth = 0; + let boundary = -1; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quote) { + if (escaped) escaped = false; + else if (c === "\\") escaped = true; + else if (c === quote) quote = ""; + continue; + } + if (depth > 0 && (c === '"' || c === "'")) quote = c; + else if (c === "[") { + if (depth === 0) boundary = i; + depth++; + } else if (c === "]") { + if (--depth < 0) return null; + } else if (depth === 0 && c === ".") boundary = i; + } + if (depth === 0 && text.endsWith("]")) return { parent: normalizeJsonPath(text), fragment: "" }; + if (boundary === -1) return text.startsWith("$") ? null : { parent: "$", fragment: text }; + const fragment = text.slice(boundary); + // Don't try to complete inside filters, unions, or recursive descent. + if ( + depth > 1 || + fragment.startsWith("[?") || + fragment.startsWith("[(") || + text[boundary - 1] === "." || + fragment.startsWith("[...") || + (fragment.includes(",") && !fragment.startsWith('["') && !fragment.startsWith("['")) + ) + return null; + return { parent: normalizeJsonPath(text.slice(0, boundary)), fragment }; +} + +function matches(child: JsonPathChildren["children"][number], fragment: string): boolean { + if (!fragment) return true; + if (!fragment.startsWith("[")) return child.label.startsWith(fragment.replace(/^\./, "")); + if (child.selector.startsWith("[") && !child.selector.startsWith('["')) + return child.selector.startsWith(fragment); + const quoted = JSON.stringify(child.label); + const accessor = fragment.startsWith("['") + ? `['${quoted.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}']` + : `[${quoted}]`; + return accessor.startsWith(fragment); +} + +/** Complete a path progressively. Discovery is cached by response + parent in the caller. */ +export function jsonPathCompletion(load: LoadJsonPathChildren) { + return async (context: CompletionContext): Promise => { + // Never replace later path segments while the user edits the middle of an expression. + if (context.pos !== context.state.doc.length) return null; + context.addEventListener("abort", () => {}, { onDocChange: true }); + const text = context.state.doc.toString(); + let target = jsonPathCompletionTarget(text); + if (!target) return null; + let children: JsonPathChildren | null = null; + if (context.explicit && text.trim()) { + // Accepting a container or explicitly opening completion explores its children, without + // inserting an unfinished dot/bracket (the container itself may be the assertion target). + const parent = normalizeJsonPath(text); + children = await load(parent); + if (context.aborted) return null; + if (children?.children.length) target = { parent, fragment: "" }; + else children = null; + } + children ??= await load(target.parent); + if (context.aborted || !children) return null; + const parent = target.parent; + const options = children.children + .filter((child) => matches(child, target.fragment)) + .filter((child) => `${parent}${child.selector}` !== normalizeJsonPath(text)) + .map((child) => { + const path = `${parent}${child.selector}`; + return { + label: child.label, + displayLabel: child.selector.startsWith('["') ? child.selector : child.label, + detail: child.kind, + type: child.selector.startsWith("[") ? "variable" : "property", + apply( + view: Parameters[0], + _completion: unknown, + from: number, + to: number, + ) { + view.dispatch({ + changes: { from, to, insert: path }, + selection: { anchor: from + path.length }, + userEvent: "input.complete", + }); + if (["object", "array", "all items", "mixed"].includes(child.kind)) + startCompletion(view); + }, + }; + }); + return options.length ? { from: 0, to: context.pos, options, filter: false } : null; + }; +} diff --git a/apps/yaak-client/components/core/Tabs/Tabs.tsx b/apps/yaak-client/components/core/Tabs/Tabs.tsx index 2ff69ec70..1265dc3d5 100644 --- a/apps/yaak-client/components/core/Tabs/Tabs.tsx +++ b/apps/yaak-client/components/core/Tabs/Tabs.tsx @@ -10,7 +10,7 @@ import { useSensors, } from "@dnd-kit/core"; import classNames from "classnames"; -import type { ReactNode, Ref } from "react"; +import type { CSSProperties, ReactNode, Ref } from "react"; import { forwardRef, memo, @@ -27,11 +27,19 @@ import { fireAndForget } from "../../../lib/fireAndForget"; import { ErrorBoundary } from "../../ErrorBoundary"; import type { ButtonProps } from "../Button"; import { Button } from "../Button"; +import { Dropdown } from "../Dropdown"; +import { IconButton } from "../IconButton"; import { Icon } from "@yaakapp-internal/ui"; import type { RadioDropdownProps } from "../RadioDropdown"; import { RadioDropdown } from "../RadioDropdown"; +import { useFittingTabs } from "./useFittingTabs"; +import { MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs"; -export type TabItem = +export type TabItem = { + /** Stable label for the visibility menu, including tabs with dropdown labels. */ + menuLabel?: string; + hiddenByDefault?: boolean; +} & ( | { value: string; label: string; @@ -44,7 +52,8 @@ export type TabItem = options: Omit; leftSlot?: ReactNode; rightSlot?: ReactNode; - }; + } +); interface TabsStorage { order: string[]; @@ -72,6 +81,8 @@ interface Props { storageKey?: string | string[]; /** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */ activeTabKey?: string; + /** Enables show/hide controls, scoped independently of tab order and selection. */ + visibilityStorageKey?: string | string[]; } export const Tabs = forwardRef(function Tabs( @@ -87,6 +98,7 @@ export const Tabs = forwardRef(function Tabs( layout = "vertical", storageKey, activeTabKey, + visibilityStorageKey, }: Props, forwardedRef: Ref, ) { @@ -108,10 +120,33 @@ export const Tabs = forwardRef(function Tabs( const savedOrder = storage.order; + const visibilityKey = visibilityStorageKey ?? ["tabs", "default_visibility"]; + const { value: visibility, set: setVisibility } = useKeyValue>({ + namespace: "no_sync", + key: visibilityKey, + fallback: {}, + }); + const { value: wrapPreference, set: setWrapPreference } = useKeyValue({ + namespace: "no_sync", + key: [...(Array.isArray(visibilityKey) ? visibilityKey : [visibilityKey]), "wrap"], + fallback: false, + }); + const isVisible = useCallback( + (tab: TabItem) => + !("hidden" in tab && tab.hidden) && + (!visibilityStorageKey || (visibility?.[tab.value] ?? !tab.hiddenByDefault)), + [visibility, visibilityStorageKey], + ); + // Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined; const [internalValue, setInternalValue] = useState(undefined); - const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value; + const requestedValue = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value; + // Dynamic tabs can disappear when switching responses or other contexts. + // Keep the saved preference, but display an available tab until it returns. + const value = originalTabs.some((t) => t.value === requestedValue && isVisible(t)) + ? requestedValue + : originalTabs.find(isVisible)?.value; // Helper to normalize storage (handle migration from old format) const normalizeStorage = useCallback( @@ -123,6 +158,9 @@ export const Tabs = forwardRef(function Tabs( // Handle tab change - update internal state, storage if we have a key, and call prop callback const onChangeValue = useCallback( async (newValue: string) => { + if (visibilityStorageKey) { + await setVisibility((s) => ({ ...s, [newValue]: true })); + } setInternalValue(newValue); if (storageKey && activeTabKey) { await setStorage((s) => { @@ -135,7 +173,15 @@ export const Tabs = forwardRef(function Tabs( } onChangeValueProp?.(newValue); }, - [storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage], + [ + storageKey, + activeTabKey, + setStorage, + onChangeValueProp, + normalizeStorage, + visibilityStorageKey, + setVisibility, + ], ); // Expose imperative methods via ref @@ -199,6 +245,18 @@ export const Tabs = forwardRef(function Tabs( }, [originalTabs, savedOrder, storageKey]); const tabs = storageKey ? orderedTabs : originalTabs; + const responsive = !!visibilityStorageKey && layout === "vertical"; + const wrapTabs = responsive && !!wrapPreference; + const { viewportRef, measurementsRef, fittedTabs } = useFittingTabs( + responsive && !wrapTabs, + value, + ); + const fitsInStrip = useCallback( + (tab: TabItem) => + isVisible(tab) && (fittedTabs == null || fittedTabs.some((t) => t.value === tab.value)), + [isVisible, fittedTabs], + ); + const overflowTabs = tabs.filter((tab) => isVisible(tab) && !fitsInStrip(tab)); // Update tabs when value changes useEffect(() => { @@ -290,69 +348,168 @@ export const Tabs = forwardRef(function Tabs( const tabButtons = useMemo(() => { const items: ReactNode[] = []; tabs.forEach((t, i) => { - if ("hidden" in t && t.hidden) { + if (!fitsInStrip(t)) { return; } - const isActive = t.value === value; - const showDropMarkerBefore = hoveredIndex === i; - - if (showDropMarkerBefore) { - items.push( -
- -
, - ); - } - + const next = tabs[i + 1]; + const fitted = fittedTabs?.find((tab) => tab.value === t.value); items.push( , ); }); return items; - }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); + }, [ + tabs, + value, + addBorders, + layout, + reorderable, + isDragging, + onChangeValue, + hoveredIndex, + fitsInStrip, + fittedTabs, + wrapTabs, + ]); - const tabList = ( + const tabStrip = (
{tabButtons} - {hoveredIndex === tabs.length && ( -
- -
+
+ {responsive && !wrapTabs && ( +
+ {tabs.filter(isVisible).map((tab) => ( +
+ +
+ ))} +
+ )} +
+ ); + + // Keep the menu outside the scroll viewport so tabs cannot paint behind or past it. + const tabList = visibilityStorageKey ? ( +
+
{tabStrip}
+
+ 0 + ? [ + { type: "separator" as const, label: "More tabs" }, + ...overflowTabs.map((tab) => ({ + label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value), + icon: tab.value === value ? ("check" as const) : ("empty" as const), + rightSlot: tab.rightSlot, + onSelect: () => onChangeValue(tab.value), + })), + { type: "separator" as const }, + ] + : []), + { + label: "Show tabs", + submenu: [ + { type: "separator", label: "In this workspace" }, + ...tabs + .filter((t) => !("hidden" in t && t.hidden)) + .map((tab) => ({ + label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value), + icon: isVisible(tab) + ? ("check_square_checked" as const) + : ("check_square_unchecked" as const), + disabled: isVisible(tab) && tabs.filter(isVisible).length <= 1, + keepOpenOnSelect: true, + onSelect: async () => { + if (!isVisible(tab)) { + await onChangeValue(tab.value); + } else { + if (tab.value === value) { + const next = tabs.find((t) => t.value !== tab.value && isVisible(t)); + if (next) await onChangeValue(next.value); + } + await setVisibility((s) => ({ ...s, [tab.value]: false })); + } + }, + })), + ], + }, + { + label: "Wrap tabs", + hidden: layout !== "vertical", + icon: wrapTabs ? "check_square_checked" : "check_square_unchecked", + keepOpenOnSelect: true, + onSelect: () => setWrapPreference((wrap) => !wrap), + }, + ]} + > + +
+ ) : ( + tabStrip ); return ( @@ -361,9 +518,13 @@ export const Tabs = forwardRef(function Tabs( className={classNames( className, "tabs-container", - "h-full grid", + "h-full", + // Size wrapped rows at the resolved pane width instead of using an intrinsic grid track. + responsive + ? "flex flex-col min-h-0 [&>.tab-content]:flex-1 [&>.tab-content]:min-h-0" + : "grid", layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]", - layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1", + !responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1", )} > {reorderable ? ( @@ -409,6 +570,11 @@ interface TabButtonProps { isDragging: boolean; onChangeValue?: (value: string) => void; overlay?: boolean; + width?: number; + spacingCompression?: number; + wrap?: boolean; + dropBefore?: boolean; + dropAfter?: boolean; } function TabButton({ @@ -420,35 +586,26 @@ function TabButton({ isDragging, onChangeValue, overlay = false, + width, + spacingCompression = 0, + wrap = false, + dropBefore = false, + dropAfter = false, }: TabButtonProps) { - const { - attributes, - listeners, - setNodeRef: setDraggableRef, - } = useDraggable({ - id: tab.value, - disabled: !reorderable, - // The button inside handles focus - attributes: { tabIndex: -1 }, - }); - const { setNodeRef: setDroppableRef } = useDroppable({ - id: tab.value, - disabled: !reorderable, - }); - - const handleSetWrapperRef = useCallback( - (n: HTMLDivElement | null) => { - if (reorderable) { - setDraggableRef(n); - setDroppableRef(n); - } - }, - [reorderable, setDraggableRef, setDroppableRef], - ); - + const constrained = width !== undefined; const btnProps: Partial = { color: "custom", justify: layout === "horizontal" ? "start" : "center", + innerClassName: classNames( + "tab-button-label", + constrained && "w-auto! shrink-0 overflow-visible! text-clip!", + wrap && "whitespace-normal! overflow-visible! text-clip! [overflow-wrap:anywhere]", + ), + style: constrained + ? { + paddingInline: `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_PADDING_SCALE))})`, + } + : undefined, onClick: isActive ? undefined : (e: React.MouseEvent) => { @@ -457,7 +614,8 @@ function TabButton({ }, className: classNames( "flex items-center rounded-sm whitespace-nowrap", - "px-2! ml-px", + "ml-px", + !constrained && "px-2!", "outline-hidden", "ring-none", "focus-visible-or-class:outline-2", @@ -471,6 +629,9 @@ function TabButton({ layout === "horizontal" && "min-w-40", isDragging && "opacity-50", overlay && "opacity-80", + constrained && "w-[calc(100%_-_1px)]", + wrap && "max-w-[calc(100%_-_1px)]! h-auto! min-h-md", + (constrained || wrap) && "[&>div:not(.tab-button-label)]:shrink-0", ), }; @@ -487,6 +648,9 @@ function TabButton({ onChange={tab.options.onChange} > ); })(); - // Apply drag handlers to wrapper, not button - const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; + const wrapperClassName = classNames( + "relative shrink-0", + layout === "vertical" && (constrained ? "min-w-0" : "mr-2"), + wrap && "min-w-0 max-w-[calc(100%_-_0.5rem)]", + ); + const wrapperStyle: CSSProperties = { + width, + paddingRight: constrained + ? `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_GAP_SCALE))})` + : undefined, + }; + // Anchor markers to their tabs so they follow the tab when it wraps onto another row. + const content = ( + <> + {dropBefore && ( + + )} + {buttonContent} + {dropAfter && ( + + )} + + ); + return reorderable && !overlay ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +} + +// Measurement copies and drag overlays must not register duplicate draggable IDs. +function DraggableTab({ + value, + className, + style, + children, +}: { + value: string; + className: string; + style?: CSSProperties; + children: ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + } = useDraggable({ + id: value, + attributes: { tabIndex: -1 }, + }); + const { setNodeRef: setDroppableRef } = useDroppable({ id: value }); + const setRef = useCallback( + (node: HTMLDivElement | null) => { + setDraggableRef(node); + setDroppableRef(node); + }, + [setDraggableRef, setDroppableRef], + ); return ( -
- {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, 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"; @@ -312,269 +77,79 @@ export type HttpResponseState = "initialized" | "connected" | "closed"; * (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 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 ImportSourceResource = { - model: "import_source_resource"; - createdAt: string; - updatedAt: string; - importSourceId: string; - sourceKey: string; - modelType: string; - /** - * `None` once the user has decided not to import this key - */ - modelId?: string; - /** - * Hash of the resource as last applied or decided from the source, if one was recorded - */ - contentHash?: string; -}; +export type ImportSourceResource = { model: "import_source_resource", createdAt: string, updatedAt: string, importSourceId: string, sourceKey: string, modelType: string, +/** + * `None` once the user has decided not to import this key + */ +modelId?: string, +/** + * Hash of the resource as last applied or decided from the source, if one was recorded + */ +contentHash?: 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 ModelPayload = { - model: AnyModel; - updateSource: UpdateSource; - change: ModelChangeEvent; -}; +export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; -export type ParentAuthentication = { - authentication: Record; - authenticationType: string | null; -}; +export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; -export type ParentHeaders = { headers: Array }; +export type ParentHeaders = { headers: Array, }; -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 PluginKeyValue = { - model: "plugin_key_value"; - createdAt: string; - updatedAt: string; - pluginName: string; - key: string; - value: string; -}; +export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; 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 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 UpdateSource = - | { type: "background" } - | { type: "import" } - | { type: "plugin" } - | { type: "sync" } - | { type: "window"; label: string }; +export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: 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 WebsocketMessageType = "text" | "binary"; -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/yaak-models/bindings/serde_json/JsonValue.ts b/crates/yaak-models/bindings/serde_json/JsonValue.ts new file mode 100644 index 000000000..3ad5da8dc --- /dev/null +++ b/crates/yaak-models/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-models/guest-js/index.ts b/crates/yaak-models/guest-js/index.ts index 7ceff15cd..01dc815df 100644 --- a/crates/yaak-models/guest-js/index.ts +++ b/crates/yaak-models/guest-js/index.ts @@ -1,6 +1,7 @@ import { AnyModel } from "../bindings/gen_models"; export * from "../bindings/gen_models"; +export * from "../bindings/gen_assertions"; export * from "../bindings/gen_util"; export * from "./store"; export * from "./atoms"; diff --git a/crates/yaak-models/migrations/20260923000000_assertions.sql b/crates/yaak-models/migrations/20260923000000_assertions.sql new file mode 100644 index 000000000..a91963687 --- /dev/null +++ b/crates/yaak-models/migrations/20260923000000_assertions.sql @@ -0,0 +1,2 @@ +ALTER TABLE http_requests ADD COLUMN assertions TEXT NOT NULL DEFAULT '{"version":1,"checks":[]}'; +ALTER TABLE http_responses ADD COLUMN assertion_results TEXT; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 875012286..f8accfc39 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::str::FromStr; use ts_rs::TS; +pub use yaak_assertions::{AssertionReport, AssertionResult, HttpAssertion, HttpAssertions}; use yaak_database::{Result as DbResult, UpdateSource}; pub use yaak_database::{UpsertModelInfo, upsert_date}; @@ -1334,6 +1335,7 @@ impl Default for HttpRequest { body_type: None, description: String::new(), headers: Vec::new(), + assertions: HttpAssertions::default(), method: "GET".to_string(), name: String::new(), sort_priority: 0.0, @@ -1370,6 +1372,8 @@ pub struct HttpRequest { pub body_type: Option, pub description: String, pub headers: Vec, + #[serde(skip_serializing_if = "HttpAssertions::is_empty")] + pub assertions: HttpAssertions, pub method: String, pub name: String, pub sort_priority: f64, @@ -1424,6 +1428,7 @@ impl UpsertModelInfo for HttpRequest { (Authentication, serde_json::to_string(&self.authentication)?.into()), (AuthenticationType, self.authentication_type.into()), (Headers, serde_json::to_string(&self.headers)?.into()), + (HttpRequestIden::Assertions, serde_json::to_string(&self.assertions)?.into()), (SortPriority, self.sort_priority.into()), (SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()), (SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()), @@ -1446,6 +1451,7 @@ impl UpsertModelInfo for HttpRequest { FolderId, Method, Headers, + HttpRequestIden::Assertions, Body, BodyType, Authentication, @@ -1486,6 +1492,15 @@ impl UpsertModelInfo for HttpRequest { description: row.get("description")?, folder_id: row.get("folder_id")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), + assertions: serde_json::from_str(&row.get::<_, String>("assertions")?).map_err( + |e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + }, + )?, method: row.get("method")?, name: row.get("name")?, sort_priority: row.get("sort_priority")?, @@ -1956,6 +1971,7 @@ pub struct HttpResponse { pub elapsed_headers: i32, pub elapsed_dns: i32, pub error: Option, + pub assertion_results: Option, pub headers: Vec, pub remote_addr: Option, pub request_content_length: Option, @@ -2005,6 +2021,10 @@ impl UpsertModelInfo for HttpResponse { (ElapsedHeaders, self.elapsed_headers.into()), (ElapsedDns, self.elapsed_dns.into()), (Error, self.error.into()), + ( + AssertionResults, + self.assertion_results.map(|r| serde_json::to_string(&r)).transpose()?.into(), + ), (Headers, serde_json::to_string(&self.headers)?.into()), (RemoteAddr, self.remote_addr.into()), (RequestHeaders, serde_json::to_string(&self.request_headers)?.into()), @@ -2027,6 +2047,7 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::ElapsedHeaders, HttpResponseIden::ElapsedDns, HttpResponseIden::Error, + HttpResponseIden::AssertionResults, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, HttpResponseIden::RequestContentLength, @@ -2053,6 +2074,17 @@ impl UpsertModelInfo for HttpResponse { created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, error: r.get("error")?, + assertion_results: r + .get::<_, Option>("assertion_results")? + .map(|v| serde_json::from_str(&v)) + .transpose() + .map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, url: r.get("url")?, content_length: r.get("content_length")?, content_length_compressed: r.get("content_length_compressed").unwrap_or_default(), diff --git a/crates/yaak-plugins/bindings/gen_assertions.ts b/crates/yaak-plugins/bindings/gen_assertions.ts new file mode 100644 index 000000000..a30548a77 --- /dev/null +++ b/crates/yaak-plugins/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-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index c03e2595a..cbfe6fb0e 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -1,514 +1,121 @@ // 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 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/yaak-sync/bindings/gen_assertions.ts b/crates/yaak-sync/bindings/gen_assertions.ts new file mode 100644 index 000000000..e42b56f57 --- /dev/null +++ b/crates/yaak-sync/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-sync/bindings/gen_models.ts b/crates/yaak-sync/bindings/gen_models.ts index 093651610..b01372d87 100644 --- a/crates/yaak-sync/bindings/gen_models.ts +++ b/crates/yaak-sync/bindings/gen_models.ts @@ -1,192 +1,56 @@ // 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 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 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-sync/src/models.rs b/crates/yaak-sync/src/models.rs index c90e3036a..94a4bb839 100644 --- a/crates/yaak-sync/src/models.rs +++ b/crates/yaak-sync/src/models.rs @@ -309,3 +309,54 @@ color: null Ok(()) } } + +#[cfg(test)] +mod assertion_tests { + use super::*; + use yaak_models::models::{HttpAssertion, HttpAssertions}; + + #[test] + fn assertions_round_trip_through_yaml_and_old_requests_default_to_no_checks() { + let path = Path::new("request.yaml"); + let request = HttpRequest { + id: "rq_test".into(), + workspace_id: "wk_test".into(), + assertions: HttpAssertions { + version: 1, + checks: vec![HttpAssertion { + id: "check".into(), + selector: "$.id".into(), + expected: "123".into(), + expected_type: "number".into(), + ..Default::default() + }], + }, + ..Default::default() + }; + let (bytes, _) = SyncModel::HttpRequest(request.clone()).to_file_contents(path).unwrap(); + let (roundtrip, _) = SyncModel::from_bytes(bytes, path).unwrap().unwrap(); + let SyncModel::HttpRequest(roundtrip) = roundtrip else { + panic!("wrong model") + }; + assert_eq!(roundtrip.assertions, request.assertions); + let old = + b"model: http_request\nid: rq_old\nworkspaceId: wk_test\nurl: https://example.com\n" + .to_vec(); + let (old, _) = SyncModel::from_bytes(old, path).unwrap().unwrap(); + let SyncModel::HttpRequest(old) = old else { + panic!("wrong model") + }; + assert!(old.assertions.is_empty()); + let (bytes, _) = SyncModel::HttpRequest(old).to_file_contents(path).unwrap(); + assert!(!String::from_utf8(bytes).unwrap().contains("assertions:")); + // Unknown versions survive sync so the runner can reject them instead of silently passing. + let mut future = request; + future.assertions.version = 99; + let (bytes, _) = SyncModel::HttpRequest(future).to_file_contents(path).unwrap(); + let (future, _) = SyncModel::from_bytes(bytes, path).unwrap().unwrap(); + let SyncModel::HttpRequest(future) = future else { + panic!("wrong model") + }; + assert_eq!(future.assertions.version, 99); + } +} diff --git a/crates/yaak-wasm/Cargo.toml b/crates/yaak-wasm/Cargo.toml index 32f561376..6c7d30867 100644 --- a/crates/yaak-wasm/Cargo.toml +++ b/crates/yaak-wasm/Cargo.toml @@ -22,6 +22,8 @@ crate-type = ["cdylib", "rlib"] # only exists for wasm is a target-scoped dependency for the same reason. [dependencies] +yaak-jsonpath = { workspace = true } +yaak-assertions = { workspace = true } log = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.js b/crates/yaak-wasm/pkg/yaak_wasm_bg.js index 8403e5128..68d067ac8 100644 --- a/crates/yaak-wasm/pkg/yaak_wasm_bg.js +++ b/crates/yaak-wasm/pkg/yaak_wasm_bg.js @@ -120,6 +120,10 @@ export function __wbg_Error_bce6d499ff0a4aff(arg0, arg1) { const ret = Error(getStringFromWasm0(arg0, arg1)); return ret; } +export function __wbg_Number_b7972a139bfbfdf0(arg0) { + const ret = Number(arg0); + return ret; +} export function __wbg_String_8564e559799eccda(arg0, arg1) { const ret = String(arg1); const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -512,7 +516,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) { const a = state0.a; state0.a = 0; try { - return wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(a, state0.b, arg0, arg1); + return wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -697,23 +701,23 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) { console.warn(arg0, arg1); } export function __wbindgen_cast_0000000000000001(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1131, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1911, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_); return ret; } export function __wbindgen_cast_0000000000000002(arg0, arg1) { // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 214, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5); + const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_); return ret; } export function __wbindgen_cast_0000000000000003(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 107, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_); return ret; } export function __wbindgen_cast_0000000000000004(arg0, arg1) { // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 216, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8); + const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_); return ret; } export function __wbindgen_cast_0000000000000005(arg0) { @@ -750,30 +754,30 @@ export function __wbindgen_init_externref_table() { table.set(offset + 2, true); table.set(offset + 3, false); } -function wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1); +function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1) { + wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2); +function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2) { + wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2); +function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2); +function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3); +function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3); } diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm index 6e4a4b999..579f65db0 100644 Binary files a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm and b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm differ diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts index a68858dfa..c480672ad 100644 --- a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts +++ b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts @@ -17,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number; export const rust_sqlite_wasm_realloc: (a: number, b: number) => number; export const sqlite3_os_end: () => number; export const sqlite3_os_init: () => number; -export const wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81: (a: number, b: number, c: any, d: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8: (a: number, b: number) => void; +export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_: (a: number, b: number, c: any) => void; +export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_: (a: number, b: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_exn_store: (a: number) => void; diff --git a/crates/yaak-wasm/src/lib.rs b/crates/yaak-wasm/src/lib.rs index aeae781c1..01d00e81d 100644 --- a/crates/yaak-wasm/src/lib.rs +++ b/crates/yaak-wasm/src/lib.rs @@ -252,6 +252,47 @@ struct InsertResponseEventsReq { events: Vec, } +fn evaluate_http_assertions( + host: &Host, + definition: &yaak_assertions::HttpAssertions, + response: &yaak_models::models::HttpResponse, + canceled: bool, +) -> Result { + let db = host.blobs.connect(); + let read = || -> std::result::Result>, yaak_models::error::Error> { + if db.get_body_size(&response.id)? > yaak_assertions::MAX_BODY_BYTES { + return Ok(None); + } + Ok(Some(db.get_chunks(&response.id)?.into_iter().flat_map(|c| c.data).collect())) + }; + let bytes = + if definition.needs_body() && response.error.is_none() { read().ok() } else { None }; + let body = match &bytes { + Some(None) => yaak_assertions::Body::TooLarge, + Some(Some(bytes)) if !bytes.is_empty() || response.content_length.unwrap_or(0) == 0 => { + yaak_assertions::Body::Bytes(bytes) + } + _ => yaak_assertions::Body::Unavailable, + }; + let headers = + response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::>(); + to_json(yaak_assertions::evaluate( + definition, + yaak_assertions::Response { + status: response.status, + headers: &headers, + body, + completion: if canceled { + yaak_assertions::Completion::Canceled + } else if response.error.is_some() { + yaak_assertions::Completion::Error + } else { + yaak_assertions::Completion::Complete + }, + }, + )) +} + fn dispatch( host: &Host, cmd: &str, @@ -308,6 +349,69 @@ fn dispatch( to_json(serde_json::to_string(&list).map_err(js_error)?) } + "cmd_validate_http_assertions" => { + #[derive(Deserialize)] + struct Req { + assertions: yaak_assertions::HttpAssertions, + } + let req: Req = from_js(payload)?; + to_json(yaak_assertions::validation_errors(&req.assertions)) + } + "cmd_http_response_json_children" => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Req { + response_id: String, + parent: String, + } + let req: Req = from_js(payload)?; + yaak_jsonpath::parse(&req.parent).map_err(js_error)?; + let response = + host.queries.connect().get_http_response(&req.response_id).map_err(js_error)?; + if !matches!(response.state, yaak_models::models::HttpResponseState::Closed) { + return Err(js_error("Wait for the response to finish")); + } + let db = host.blobs.connect(); + if db.get_body_size(&req.response_id).map_err(js_error)? > yaak_jsonpath::MAX_BODY_BYTES + { + return Err(js_error("Suggestions are limited to JSON responses up to 10 MiB")); + } + let bytes: Vec = db + .get_chunks(&req.response_id) + .map_err(js_error)? + .into_iter() + .flat_map(|c| c.data) + .collect(); + let document = yaak_jsonpath::parse_body(&bytes).map_err(js_error)?; + to_json(yaak_jsonpath::children(&document, &req.parent).map_err(js_error)?) + } + "cmd_preview_http_assertions" => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Req { + response_id: String, + assertions: yaak_assertions::HttpAssertions, + } + let req: Req = from_js(payload)?; + let response = + host.queries.connect().get_http_response(&req.response_id).map_err(js_error)?; + if !matches!(response.state, yaak_models::models::HttpResponseState::Closed) { + return Err(js_error("Wait for the response to finish")); + } + // Return a preview only; never patch the saved response or send anything. + evaluate_http_assertions(host, &req.assertions, &response, false) + } + "evaluate_http_assertions" => { + #[derive(Deserialize)] + struct Req { + definition: yaak_assertions::HttpAssertions, + response: yaak_models::models::HttpResponse, + canceled: bool, + } + let req: Req = from_js(payload)?; + evaluate_http_assertions(host, &req.definition, &req.response, req.canceled) + } + "models_upsert" => { let req: ModelReq = from_js(payload)?; let id = host diff --git a/crates/yaak/Cargo.toml b/crates/yaak/Cargo.toml index dadade2ac..775cb7637 100644 --- a/crates/yaak/Cargo.toml +++ b/crates/yaak/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" publish = false [dependencies] +yaak-jsonpath = { workspace = true } +yaak-assertions = { workspace = true } async-trait = "0.1" base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport log = { workspace = true } diff --git a/crates/yaak/src/lib.rs b/crates/yaak/src/lib.rs index 6d8aedfab..4fd37eda1 100644 --- a/crates/yaak/src/lib.rs +++ b/crates/yaak/src/lib.rs @@ -6,3 +6,6 @@ pub mod send; pub use error::Error; pub type Result = error::Result; +pub use yaak_assertions as assertions; + +pub use yaak_jsonpath as jsonpath; diff --git a/crates/yaak/src/response_body.rs b/crates/yaak/src/response_body.rs index 387620386..1d24dd14b 100644 --- a/crates/yaak/src/response_body.rs +++ b/crates/yaak/src/response_body.rs @@ -109,6 +109,109 @@ impl ResponseBodyStore for FileResponseBodyStore<'_> { } } +/// Discover the next JSONPath level without sending body contents to the UI. +/// Hosts can expose this as RPC/plugin APIs while sharing selection with assertions. +pub fn json_path_children( + store: &dyn ResponseBodyStore, + response_id: &str, + parent: &str, +) -> std::result::Result { + yaak_jsonpath::parse(parent)?; + let info = store.info(response_id).map_err(|e| e.to_string())?; + if !info.complete { + return Err("Wait for the response to finish".into()); + } + if info.content_length > yaak_jsonpath::MAX_BODY_BYTES as u64 { + return Err("Suggestions are limited to JSON responses up to 10 MiB".into()); + } + let mut bytes = Vec::new(); + loop { + let chunk = store + .read_chunk( + response_id, + bytes.len() as u64, + (yaak_jsonpath::MAX_BODY_BYTES + 1 - bytes.len()) as u64, + ) + .map_err(|e| e.to_string())?; + if chunk.is_empty() { + break; + } + bytes.extend_from_slice(&chunk); + if bytes.len() > yaak_jsonpath::MAX_BODY_BYTES { + return Err("Suggestions are limited to JSON responses up to 10 MiB".into()); + } + } + let document = yaak_jsonpath::parse_body(&bytes)?; + Ok(yaak_jsonpath::children(&document, parent)?) +} + +/// Evaluate the caller's draft against a saved response, without rendering, +/// sending, or writing either the request or its historical assertion results. +pub fn preview_assertions( + queries: &QueryManager, + response_id: &str, + definition: &yaak_assertions::HttpAssertions, +) -> std::result::Result { + use yaak_assertions::{Body, Completion, MAX_BODY_BYTES, Response}; + + let response = queries.connect().get_http_response(response_id).map_err(|e| e.to_string())?; + if !matches!(response.state, HttpResponseState::Closed) { + return Err("Wait for the response to finish".into()); + } + let store = FileResponseBodyStore::new(queries); + let bytes; + let mut body = Body::Unavailable; + if definition.needs_body() && response.error.is_none() { + // File sizes are only a hint: also cap actual reads, including the + // overflow byte. A missing/removed body must not prevent status checks. + let read = || -> Result>> { + if store.info(response_id)?.content_length > MAX_BODY_BYTES as u64 { + return Ok(None); + } + let mut bytes = Vec::new(); + loop { + let chunk = store.read_chunk( + response_id, + bytes.len() as u64, + (MAX_BODY_BYTES + 1 - bytes.len()) as u64, + )?; + if chunk.is_empty() { + return Ok(Some(bytes)); + } + bytes.extend_from_slice(&chunk); + if bytes.len() > MAX_BODY_BYTES { + return Ok(None); + } + } + }; + match read() { + Ok(None) => body = Body::TooLarge, + Ok(Some(value)) => { + bytes = value; + if !bytes.is_empty() || response.content_length.unwrap_or(0) == 0 { + body = Body::Bytes(&bytes); + } + } + Err(_) => {} // The evaluator reports unavailable body checks individually. + } + } + let headers = + response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::>(); + Ok(yaak_assertions::evaluate( + definition, + Response { + status: response.status, + headers: &headers, + body, + completion: if response.error.is_some() { + Completion::Error + } else { + Completion::Complete + }, + }, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -177,6 +280,143 @@ mod tests { (query_manager, temp_dir, id) } + #[test] + fn json_discovery_waits_for_completion_and_only_returns_structure() { + let (qm, _tmp, id) = seed(Some(br#"{"user":{"name":"SECRET","active":true}}"#)); + let store = FileResponseBodyStore::new(&qm); + assert!(json_path_children(&store, &id, "$").unwrap_err().contains("finish")); + let mut response = qm.connect().get_http_response(&id).unwrap(); + response.state = HttpResponseState::Closed; + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + let children = json_path_children(&store, &id, "$.user").unwrap(); + assert_eq!(children.children.len(), 2); + assert!(!serde_json::to_string(&children).unwrap().contains("SECRET")); + assert!(json_path_children(&store, "rs_missing", "$").is_err()); + let path = response.body_path.unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_len((yaak_jsonpath::MAX_BODY_BYTES + 1) as u64) + .unwrap(); + assert!(json_path_children(&store, &id, "$").unwrap_err().contains("10 MiB")); + } + + #[test] + fn json_discovery_reads_beyond_the_single_chunk_limit() { + let bytes = serde_json::to_vec(&serde_json::json!({"padding": "a".repeat(MAX_CHUNK_BYTES as usize), "user": {"id": 123}})).unwrap(); + let (qm, _tmp, id) = seed(Some(&bytes)); + let mut response = qm.connect().get_http_response(&id).unwrap(); + response.state = HttpResponseState::Closed; + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + let result = json_path_children(&FileResponseBodyStore::new(&qm), &id, "$.user").unwrap(); + assert_eq!(result.children[0].label, "id"); + assert_eq!(result.children[0].kind, "number"); + let mut definition = preview_definition(); + definition.checks.remove(0); + definition.checks[0].expected = "123".into(); + assert_eq!(preview_assertions(&qm, &id, &definition).unwrap().results[0].outcome, "passed"); + } + + fn preview_definition() -> yaak_assertions::HttpAssertions { + use yaak_assertions::HttpAssertion; + yaak_assertions::HttpAssertions { + checks: vec![ + HttpAssertion { + id: "status".into(), + target: "status".into(), + expected: "200".into(), + expected_type: "number".into(), + ..Default::default() + }, + HttpAssertion { + id: "body".into(), + selector: "$.user.id".into(), + expected: "7".into(), + expected_type: "number".into(), + ..Default::default() + }, + ], + ..Default::default() + } + } + + #[test] + fn preview_uses_draft_checks_without_changing_saved_models_or_results() { + let (qm, _tmp, id) = seed(Some(br#"{"user":{"id":7}}"#)); + let mut response = qm.connect().get_http_response(&id).unwrap(); + response.state = HttpResponseState::Closed; + response.status = 200; + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + let mut original = preview_definition(); + original.checks[1].expected = "99".into(); + response.assertion_results = Some(preview_assertions(&qm, &id, &original).unwrap()); + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + let before_response = + serde_json::to_value(qm.connect().get_http_response(&id).unwrap()).unwrap(); + let before_request = + serde_json::to_value(qm.connect().get_http_request("rq_test").unwrap()).unwrap(); + + let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap(); + assert!(preview.results.iter().all(|r| r.outcome == "passed")); + assert_eq!(preview.definition, preview_definition()); + assert_eq!(before_response["assertionResults"]["results"][1]["outcome"], "failed"); + assert_eq!( + serde_json::to_value(qm.connect().get_http_response(&id).unwrap()).unwrap(), + before_response + ); + assert_eq!( + serde_json::to_value(qm.connect().get_http_request("rq_test").unwrap()).unwrap(), + before_request + ); + assert_eq!(std::fs::read(response.body_path.unwrap()).unwrap(), br#"{"user":{"id":7}}"#); + } + + #[test] + fn preview_rejects_pending_or_missing_responses_and_never_passes_failed_sends() { + let (qm, _tmp, id) = seed(Some(br#"{"user":{"id":7}}"#)); + let definition = preview_definition(); + assert!(preview_assertions(&qm, &id, &definition).unwrap_err().contains("finish")); + assert!(preview_assertions(&qm, "rs_missing", &definition).is_err()); + let mut response = qm.connect().get_http_response(&id).unwrap(); + response.state = HttpResponseState::Closed; + response.status = 200; + response.error = Some("Request canceled".into()); + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + assert!( + preview_assertions(&qm, &id, &definition) + .unwrap() + .results + .iter() + .all(|r| r.outcome == "error") + ); + } + + #[test] + fn preview_evaluates_status_when_the_saved_body_is_too_large_or_missing() { + let (qm, _tmp, id) = seed(Some(b"")); + let mut response = qm.connect().get_http_response(&id).unwrap(); + response.state = HttpResponseState::Closed; + response.status = 200; + qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap(); + let path = response.body_path.unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_len((yaak_assertions::MAX_BODY_BYTES + 1) as u64) + .unwrap(); + let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap(); + assert_eq!(preview.results[0].outcome, "passed"); + assert_eq!(preview.results[1].outcome, "error"); + assert!(preview.results[1].reason.contains("10 MiB")); + std::fs::remove_file(path).unwrap(); + let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap(); + assert_eq!(preview.results[0].outcome, "passed"); + assert_eq!(preview.results[1].outcome, "error"); + assert!(preview.results[1].reason.contains("unavailable")); + } + #[test] fn info_reports_stored_size_and_content_type() { let (qm, _tmp, id) = seed(Some(b"hello world")); diff --git a/crates/yaak/src/send.rs b/crates/yaak/src/send.rs index 026c60d4e..abd7163bb 100644 --- a/crates/yaak/src/send.rs +++ b/crates/yaak/src/send.rs @@ -45,6 +45,8 @@ const MAX_AUTH_BODY_BYTES: usize = 10 * 1024 * 1024; #[derive(Debug, Error)] pub enum SendHttpRequestError { + #[error("Request canceled")] + Canceled, #[error("Failed to load request: {0}")] LoadRequest(#[source] yaak_models::error::Error), @@ -628,6 +630,12 @@ pub async fn send_http_request( response.url = sendable_request.url.clone(); response.state = HttpResponseState::Initialized; response.error = None; + response.assertion_results = + (!request.assertions.is_empty()).then(|| yaak_assertions::AssertionReport { + definition: request.assertions.clone(), + results: Vec::new(), + error: None, + }); response.content_length = None; response.content_length_compressed = None; response.body_path = None; @@ -835,6 +843,8 @@ pub async fn send_http_request( ResponseBody::Returned(Vec::new()) }; let mut body_read_error = None; + let mut assertion_body = request.assertions.needs_body().then(Vec::new); + let mut assertion_body_too_large = false; let mut written_bytes: usize = 0; let mut last_progress_update = started_at; let mut cancelled_rx = params.cancelled_rx.clone(); @@ -842,6 +852,7 @@ pub async fn send_http_request( loop { let read_result = if let Some(cancelled_rx) = cancelled_rx.as_mut() { if *cancelled_rx.borrow() { + body_read_error = Some(SendHttpRequestError::Canceled); break; } @@ -859,7 +870,12 @@ pub async fn send_http_request( }; let Some(read_result) = read_result else { - break; + if cancelled_rx.as_ref().is_some_and(|rx| *rx.borrow()) { + body_read_error = Some(SendHttpRequestError::Canceled); + break; + } + cancelled_rx = None; + continue; }; match read_result { @@ -872,6 +888,14 @@ pub async fn send_http_request( SendHttpRequestError::WriteResponseBody { path: path.clone(), source } })?; } + if let Some(body) = assertion_body.as_mut() { + if body.len() + chunk.len() > yaak_assertions::MAX_BODY_BYTES { + assertion_body = None; + assertion_body_too_large = true; + } else { + body.extend_from_slice(chunk); + } + } if let Some(tx) = params.emit_response_body_chunks_to.as_ref() { let _ = tx.send(chunk.to_vec()); } else if let ResponseBody::Returned(body) = &mut response_body { @@ -1019,6 +1043,36 @@ pub async fn send_http_request( warn!("Failed to join response event task: {}", join_err); } + if !request.assertions.is_empty() { + let headers = + response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::>(); + response.assertion_results = Some(yaak_assertions::evaluate( + &request.assertions, + yaak_assertions::Response { + status: response.status, + headers: &headers, + body: if assertion_body_too_large { + yaak_assertions::Body::TooLarge + } else { + yaak_assertions::Body::Bytes(assertion_body.as_deref().unwrap_or_default()) + }, + completion: if response.error.is_some() { + yaak_assertions::Completion::Error + } else { + yaak_assertions::Completion::Complete + }, + }, + )); + if let Some(store) = store { + response = store + .query_manager + .with_tx(|tx| { + tx.upsert_http_response(&response, &store.update_source, store.blob_manager) + }) + .map_err(SendHttpRequestError::PersistResponse)?; + } + } + Ok(SendHttpRequestResult { rendered_request, response, @@ -1212,11 +1266,23 @@ fn persist_response_error( fallback_url: String, ) -> Result { let elapsed = duration_to_i32(started_at.elapsed()); + let assertion_results = response.assertion_results.as_ref().map(|report| { + yaak_assertions::evaluate( + &report.definition, + yaak_assertions::Response { + status: response.status, + headers: &[], + body: yaak_assertions::Body::Unavailable, + completion: yaak_assertions::Completion::Error, + }, + ) + }); store .query_manager .with_tx(|tx| { tx.upsert_http_response( &HttpResponse { + assertion_results, state: HttpResponseState::Closed, elapsed, elapsed_headers: if response.elapsed_headers == 0 { @@ -1325,6 +1391,151 @@ mod tests { } } + fn assertion_inputs() -> HttpSendInputs { + HttpSendInputs { + request: ResolvedHttpRequest::assume_resolved( + HttpRequest { + id: "rq_assertions".into(), + workspace_id: "wk_assertions".into(), + url: "http://localhost/test".into(), + assertions: yaak_assertions::HttpAssertions { + version: 1, + checks: vec![yaak_assertions::HttpAssertion { + id: "check".into(), + selector: "$.id".into(), + expected: "123".into(), + expected_type: "number".into(), + ..Default::default() + }], + }, + ..Default::default() + }, + String::new(), + ), + environment_chain: Vec::new(), + runtime_config: HttpSendRuntimeConfig { + settings: ResolvedHttpRequestSettings::default(), + proxy: HttpConnectionProxySetting::System, + dns_overrides: Vec::new(), + client_certificates: Vec::new(), + }, + cookie_store: None, + } + } + + #[tokio::test] + async fn assertions_use_the_same_body_for_stored_and_streamed_responses() { + for stream in [false, true] { + let dir = TempDir::new().unwrap(); + let (queries, blobs, _rx) = + yaak_models::init_standalone(&dir.path().join("db"), &dir.path().join("blobs")) + .unwrap(); + let inputs = assertion_inputs(); + queries + .with_tx(|tx| { + tx.upsert_workspace( + &Workspace { id: "wk_assertions".into(), ..Default::default() }, + &UpdateSource::Sync, + )?; + tx.upsert_http_request(inputs.request.request(), &UpdateSource::Sync) + }) + .unwrap(); + let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel(); + // A closed, false cancellation channel must not truncate the body. + let (cancel_tx, cancel_rx) = watch::channel(false); + drop(cancel_tx); + let result = send_http_request(SendHttpRequestParams { + inputs, + template_callback: &NoopTemplateCallback, + storage: Some(ResponseStorage { + query_manager: &queries, + blob_manager: &blobs, + update_source: UpdateSource::Sync, + response_dir: dir.path(), + }), + emit_events_to: None, + emit_response_body_chunks_to: stream.then_some(chunk_tx), + cancelled_rx: Some(cancel_rx), + existing_response: None, + prepare_sendable_request: None, + executor: &StubExecutor { body: br#"{"id":123}"# }, + }) + .await + .unwrap(); + let report = result.response.assertion_results.as_ref().unwrap(); + assert_eq!(report.results[0].outcome, "passed"); + assert_eq!( + queries + .connect() + .get_http_response(&result.response.id) + .unwrap() + .assertion_results + .unwrap() + .results[0] + .outcome, + "passed" + ); + if stream { + assert_eq!(chunk_rx.recv().await.unwrap(), br#"{"id":123}"#); + } + } + } + + struct CancelAfterHeaders(watch::Sender); + #[async_trait] + impl SendRequestExecutor for CancelAfterHeaders { + async fn send( + &self, + request: SendableHttpRequest, + events: mpsc::Sender, + cookies: CookieBehavior, + ) -> yaak_http::error::Result { + let response = + StubExecutor { body: br#"{"id":123}"# }.send(request, events, cookies).await?; + self.0.send(true).unwrap(); + Ok(response) + } + } + #[tokio::test] + async fn canceled_body_never_leaves_passing_assertions_in_history() { + let dir = TempDir::new().unwrap(); + let (queries, blobs, _rx) = + yaak_models::init_standalone(&dir.path().join("db"), &dir.path().join("blobs")) + .unwrap(); + let inputs = assertion_inputs(); + queries + .with_tx(|tx| { + tx.upsert_workspace( + &Workspace { id: "wk_assertions".into(), ..Default::default() }, + &UpdateSource::Sync, + )?; + tx.upsert_http_request(inputs.request.request(), &UpdateSource::Sync) + }) + .unwrap(); + let (cancel_tx, cancel_rx) = watch::channel(false); + let result = send_http_request(SendHttpRequestParams { + inputs, + template_callback: &NoopTemplateCallback, + storage: Some(ResponseStorage { + query_manager: &queries, + blob_manager: &blobs, + update_source: UpdateSource::Sync, + response_dir: dir.path(), + }), + emit_events_to: None, + emit_response_body_chunks_to: None, + cancelled_rx: Some(cancel_rx), + existing_response: None, + prepare_sendable_request: None, + executor: &CancelAfterHeaders(cancel_tx), + }) + .await; + assert!(matches!(result, Err(SendHttpRequestError::Canceled))); + let responses = queries.connect().list_http_responses("wk_assertions", None).unwrap(); + assert!(responses[0].error.is_some()); + assert_eq!(responses[0].assertion_results.as_ref().unwrap().results[0].outcome, "error"); + } + /// The hosted sender runs with no query manager, blob manager, or response directory. Nothing /// here touches storage, so the caller's only view of the response is what gets streamed out. #[tokio::test] diff --git a/packages/platform/src/web/commands.test.ts b/packages/platform/src/web/commands.test.ts new file mode 100644 index 000000000..2f0eb6586 --- /dev/null +++ b/packages/platform/src/web/commands.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { commandSupport, runCommand } from "./commands"; +import type { WorkerConnection } from "./connection"; +import { sendHttpRequest } from "./send"; + +vi.mock("./send", () => ({ sendHttpRequest: vi.fn() })); +afterEach(() => vi.clearAllMocks()); + +describe("browser assertion commands", () => { + it.each([ + ["cmd_validate_http_assertions", { assertions: { version: 1, checks: [] } }], + [ + "cmd_preview_http_assertions", + { responseId: "rs_pinned_history", assertions: { version: 1, checks: [] } }, + ], + ["cmd_http_response_json_children", { responseId: "rs_pinned_history", parent: "$.user" }], + ])("routes %s to the local worker without a send or model write", async (command, payload) => { + const report = { from: "local worker" }; + const rpc = vi.fn().mockResolvedValue(report); + const db = { rpc } as unknown as WorkerConnection; + expect(commandSupport().implemented).toContain(command); + expect(await runCommand(command, payload, db)).toBe(report); + expect(rpc).toHaveBeenCalledExactlyOnceWith(command, payload); + expect(sendHttpRequest).not.toHaveBeenCalled(); + }); + + it("does not resend when a saved response is no longer available", async () => { + const rpc = vi.fn().mockRejectedValue(new Error("Response not found")); + const db = { rpc } as unknown as WorkerConnection; + await expect( + runCommand( + "cmd_preview_http_assertions", + { responseId: "rs_removed", assertions: { version: 1, checks: [] } }, + db, + ), + ).rejects.toThrow("Response not found"); + expect(rpc).toHaveBeenCalledTimes(1); + expect(sendHttpRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index 8218faf45..6b4c5cbec 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -68,6 +68,10 @@ const HANDLERS: Partial> = { cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload), cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload), cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload), + cmd_validate_http_assertions: (payload, db) => db.rpc("cmd_validate_http_assertions", payload), + cmd_preview_http_assertions: (payload, db) => db.rpc("cmd_preview_http_assertions", payload), + cmd_http_response_json_children: (payload, db) => + db.rpc("cmd_http_response_json_children", payload), /* ------------------------------- sending ------------------------------- */ diff --git a/packages/platform/src/web/send.test.ts b/packages/platform/src/web/send.test.ts new file mode 100644 index 000000000..feb8a1b59 --- /dev/null +++ b/packages/platform/src/web/send.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HttpAssertions, HttpResponse } from "@yaakapp-internal/models"; +import type { WorkerConnection } from "./connection"; +import { sendHttpRequest } from "./send"; + +vi.mock("./server", () => ({ + serverIdentity: async () => "test server", + serverSendUrl: () => "https://test.invalid/send", + readFrames: async function* () { + yield { + type: "response", + status: 200, + headers: [], + requestHeaders: [], + url: "https://example.com", + }; + yield { type: "body", data: btoa('{"id":123}') }; + yield { type: "done", contentLength: 10, contentLengthCompressed: 10, elapsed: 1 }; + }, +})); + +const definition: HttpAssertions = { + version: 1, + checks: [ + { + id: "check", + enabled: true, + target: "json", + selector: "$.id", + operator: "equals", + expected: "SECRET_EXPECTED", + expectedType: "text", + }, + ], +}; + +function worker() { + let cancel: (() => void) | undefined; + let storedBody: Uint8Array | undefined; + const evaluations: { + definition: HttpAssertions; + response: Partial; + canceled: boolean; + }[] = []; + const writes: Partial[] = []; + const request = { + id: "rq_test", + model: "http_request", + workspaceId: "wk_test", + assertions: structuredClone(definition), + }; + const db = { + rpc: async (cmd: string, payload: Record) => { + if (cmd === "web_get_http_request") return structuredClone(request); + if (cmd === "models_upsert") { + writes.push(structuredClone(payload.model as Partial)); + return "rs_test"; + } + if (cmd === "evaluate_http_assertions") { + const input = payload as (typeof evaluations)[number]; + if (!input.response.error) expect(new TextDecoder().decode(storedBody)).toBe('{"id":123}'); + evaluations.push(structuredClone(input)); + return { + definition: input.definition, + results: [ + { + assertionId: "check", + outcome: input.response.error ? "error" : "passed", + reason: "test evaluator", + actual: null, + }, + ], + error: null, + }; + } + return null; + }, + prepareHttpSend: async () => ({ request, settings: {}, cookieJar: null, settingEvents: [] }), + blobPut: async (_id: string, bytes: Uint8Array) => { + storedBody = bytes; + }, + listen: (_event: string, callback: () => void) => { + cancel = callback; + return () => { + cancel = undefined; + }; + }, + } as unknown as WorkerConnection; + return { db, evaluations, writes, cancel: () => cancel?.() }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("browser assertion evaluation", () => { + it("evaluates locally after saving the body and keeps assertion values out of the proxy request", async () => { + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + expect(init.body).not.toContain("SECRET_EXPECTED"); + return new Response("stream"); + }); + vi.stubGlobal("fetch", fetch); + const { db, evaluations, writes } = worker(); + const response = await sendHttpRequest(db, "rq_test", null, null); + expect(evaluations).toHaveLength(1); + expect(evaluations[0]?.definition).toEqual(definition); + expect(evaluations[0]?.canceled).toBe(false); + expect(response.assertionResults?.results[0]?.outcome).toBe("passed"); + expect(writes.at(-1)?.state).toBe("closed"); + expect(writes.at(-1)?.assertionResults).toEqual(response.assertionResults); + }); + + it("marks a canceled response as incomplete before asking the shared evaluator", async () => { + const { db, cancel, evaluations } = worker(); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + cancel(); + throw new Error("aborted"); + }), + ); + const response = await sendHttpRequest(db, "rq_test", null, null); + expect(evaluations).toHaveLength(1); + expect(evaluations[0]?.canceled).toBe(true); + expect(evaluations[0]?.response.error).toBe("Request canceled"); + expect(response.assertionResults?.results[0]?.outcome).toBe("error"); + }); +}); diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index 061abc274..13ac72766 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -24,6 +24,8 @@ // Types only: the models package imports this one at runtime, and a type import // is erased, so there is no cycle. import type { + AssertionReport, + HttpAssertions, Cookie, CookieJar, HttpRequest, @@ -70,8 +72,16 @@ export async function sendHttpRequest( // The response row exists before anything can go wrong, as on the desktop, so // a failure to render or to reach the server lands in the response pane as // that response's error rather than as a toast that names no request. - const workspaceId = await workspaceIdOfRequest(db, requestId); - const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId }); + const request = await db.rpc("web_get_http_request", { requestId }); + const response = new ResponseWriter( + db, + { + model: "http_response", + requestId, + workspaceId: request.workspaceId, + }, + request.assertions ?? { version: 1, checks: [] }, + ); await response.create(); const cancel = new AbortController(); @@ -81,7 +91,7 @@ export async function sendHttpRequest( await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal); } catch (err) { const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err); - await response.finish({ error: message }); + await response.finish({ error: message }, cancel.signal.aborted); } finally { unlistenCancel(); } @@ -111,7 +121,8 @@ async function runSend( timeline.push(prepared.settingEvents); const body: SendRequest = { - request: prepared.request, + // Assertions run locally against the completed response. + request: { ...prepared.request, assertions: { version: 1, checks: [] } }, settings: prepared.settings, cookies: prepared.cookieJar?.cookies ?? null, }; @@ -234,6 +245,7 @@ class ResponseWriter { constructor( private readonly db: WorkerConnection, initial: ResponseRow, + private readonly assertions: HttpAssertions, ) { this.state = initial; } @@ -266,8 +278,17 @@ class ResponseWriter { await this.db.rpc("models_upsert", { model: this.state }); } - async finish(patch: ResponsePatch): Promise { - await this.patch({ ...patch, state: "closed" }); + async finish(patch: ResponsePatch, canceled = false): Promise { + const response = { ...this.state, ...patch, state: "closed" as const }; + const assertionResults = + this.assertions.version !== 1 || this.assertions.checks.length > 0 + ? await this.db.rpc("evaluate_http_assertions", { + definition: this.assertions, + response, + canceled, + }) + : null; + await this.patch({ ...patch, state: "closed", assertionResults }); } } @@ -315,17 +336,16 @@ class TimelineWriter { * yaak-models), so an edit made while the send was in flight survives rather * than being written over by the send's stale snapshot. */ -async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise { - await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies }); -} - -/** - * The request's workspace, needed to create the response row before the worker - * has resolved the request (which is where a render refusal would land). - */ -async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise { - const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId }); - return req.workspaceId; +async function persistCookies( + db: WorkerConnection, + jar: CookieJar, + cookies: Cookie[], +): Promise { + await db.rpc("web_persist_send_cookies", { + cookieJarId: jar.id, + before: jar.cookies, + after: cookies, + }); } function errorMessage(err: unknown): string {