Compare commits

..
Author SHA1 Message Date
Gregory Schier e5520fb3a8 Merge latest main into assertions POC 2026-09-25 08:36:51 -07:00
Gregory Schier c71256d800 POC: Explore request assertions and CLI testing 2026-09-25 08:32:24 -07:00
dependabot[bot]andGregory Schier 6e7f23a778 chore(deps-dev): bump @yaakapp/cli from 0.5.1 to 2026.8.0 (#698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:37 -07:00
dependabot[bot]andGregory Schier b03c41b767 chore(deps): bump git2 from 0.20.4 to 0.21.0 (#703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:33 -07:00
dependabot[bot]andGregory Schier a0d71eb486 chore(deps-dev): bump the npm-development group across 1 directory with 3 updates (#695)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:26 -07:00
dependabot[bot]andGregory Schier 38c562f775 chore(deps): bump whatwg-mimetype and @types/whatwg-mimetype (#697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:06:53 -07:00
dependabot[bot] 1249d15f2c chore(deps): bump cocoa from 0.26.1 to 0.27.0 (#702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:30 -07:00
dependabot[bot] 1707c387ea chore(deps): bump tokio-tungstenite from 0.26.2 to 0.30.0 (#701)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:23 -07:00
dependabot[bot] a497d9f018 chore(deps): bump brotli from 7.0.0 to 8.0.1 (#700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:16 -07:00
dependabot[bot] c13e26656b chore(deps): bump the cargo group with 4 updates (#699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:08 -07:00
dependabot[bot] dfc974db95 chore(deps): bump focus-trap-react from 11.0.6 to 12.0.3 (#696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:01 -07:00
dependabot[bot] 0b86d3ca3f chore(deps): bump the npm-production group across 1 directory with 10 updates (#707)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:41:54 -07:00
Gregory Schier 9e51e95f9e Polish the import source list
The URL link and menu item stay enabled and focus the field on repeat
clicks, waiting out the menu's focus return. Larger empty-state icon,
inline links inherit the text color with a translucent underline, and
card rows use a muted trash icon for removal.
2026-09-24 15:40:58 -07:00
Gregory Schier aa10c44a45 Add Bruno imports and a file-tree importer API (#706) 2026-09-24 14:50:44 -07:00
Gregory Schier 46464cfacf Switch to the imported workspace after a deep link import 2026-09-24 08:01:58 -07:00
Gregory Schier 263a215f7a Fix sidebar ordering for items with equal sort priorities (#705) 2026-09-23 08:00:08 -07:00
193 changed files with 9695 additions and 3784 deletions

No files matched your search

+2 -1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
]
}
Generated
+233 -198
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -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" }
@@ -1,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
</VStack>
);
}
@@ -1,10 +1,10 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react";
import { useId, useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
className="pb-3 flex flex-col gap-3"
id={formId}
className="flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
</form>
);
}
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -26,12 +26,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
</VStack>
);
}
@@ -1,6 +1,6 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -87,8 +87,8 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<>
<VStack space={3} className="pb-4">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -141,29 +141,22 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<DialogFooter
leftSlot={
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
);
}
@@ -1,3 +1,4 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -31,6 +32,16 @@ export function GlobalHooks() {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey(
"request.rename",
async () => {
@@ -239,6 +239,7 @@ export function GrpcEditor({
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
@@ -1,11 +1,11 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
}
return (
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
<>
<VStack space={5} className="pb-4">
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
</VStack>
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
);
}
@@ -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 <ResponsePreview key={`${props.response?.id}:${props.response?.updatedAt}`} {...props} />;
}
function ResponsePreview({ assertions, response }: Props) {
const preview = useMutation({
mutationFn: (definition: HttpAssertions) =>
rpc<AssertionReport>("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 (
<div className="space-y-2">
<Button
size="sm"
variant="border"
disabled={unavailable != null || preview.isPending}
onClick={() => preview.mutate(assertions)}
>
{preview.isPending ? "Trying assertions…" : "Try assertions"}
</Button>
<p className="text-xs text-text-subtle">
{unavailable ?? "Preview against the selected response without sending again."}
</p>
{preview.isError && (
<p className="text-sm text-danger" role="alert">
Could not preview assertions:{" "}
{preview.error instanceof Error ? preview.error.message : String(preview.error)}
</p>
)}
{preview.data && response && (
<section
aria-label="Assertion preview"
aria-live="polite"
className="border border-border-subtle rounded-md p-3 space-y-2"
>
<p className="text-sm font-medium">Preview</p>
<p className="text-xs text-text-subtle">
Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results
are unchanged.
</p>
{changed && (
<p className="text-xs text-warning" role="status">
Assertions changed. Try again to update this preview.
</p>
)}
<HttpAssertionReport report={preview.data} />
</section>
)}
</div>
);
}
@@ -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 <EmptyStateText>Assertions were not evaluated for this response.</EmptyStateText>;
if (!report.error && report.results.length === 0)
return <EmptyStateText>Waiting for the response to finish.</EmptyStateText>;
return (
<div className="h-full overflow-auto space-y-2 pb-3">
<p className="text-xs text-text-subtle">Checks saved with this response.</p>
<HttpAssertionReport report={report} />
</div>
);
}
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 (
<div className="space-y-2">
<p className="text-sm">
{passed} passed · {failed} failed · {errors} errors · {skipped} skipped
</p>
{report.error && <p className="text-sm text-danger">{report.error}</p>}
{report.results.map((result, index) => {
const check = report.definition.checks[index];
return (
<div
key={`${result.assertionId}:${index}`}
className="border border-border-subtle rounded-md p-2 text-sm space-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono break-all">
{check?.target === "status" ? "Status" : check?.selector}
</span>
<span
className={classNames(
"capitalize shrink-0",
result.outcome === "passed"
? "text-success"
: result.outcome === "skipped"
? "text-text-subtle"
: "text-danger",
)}
>
{result.outcome}
</span>
</div>
{check && (
<p className="text-xs break-all">
{check.operator.replaceAll("_", " ")}
{check.operator !== "exists" && (
<>
{" "}
{check.expectedType === "null"
? "null"
: check.expectedType === "text"
? JSON.stringify(check.expected)
: check.expected}{" "}
({check.expectedType})
</>
)}
</p>
)}
<p className="text-xs text-text-subtle">{result.reason}</p>
{result.actual != null && (
<p className="font-mono text-xs break-all">Actual: {JSON.stringify(result.actual)}</p>
)}
</div>
);
})}
</div>
);
}
@@ -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<Record<string, string>>("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<HttpAssertion>) =>
save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<div className="@container h-full overflow-auto pb-3 space-y-3">
<p className="text-sm text-text-subtle">
Check the response each time you send. Results appear in the response’s Assertions tab.
</p>
{definition.checks.map((check, index) => (
<div key={check.id} className="border border-border-subtle rounded-md p-2 space-y-2">
<div className="flex items-center gap-2">
<Checkbox
title={`Assertion ${index + 1}`}
checked={check.enabled}
onChange={(enabled) => update(check.id, { enabled })}
/>
<IconButton
icon="trash"
size="xs"
title={`Delete assertion ${index + 1}`}
onClick={() => save(definition.checks.filter((c) => c.id !== check.id))}
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`target-${check.id}`}
label="Response"
size="sm"
value={check.target}
options={[
{ value: "json", label: "JSON body" },
{ value: "status", label: "Status" },
{ value: "header", label: "Header" },
]}
onChange={(target) =>
update(check.id, {
target,
selector: "",
operator: "equals",
expected: target === "status" ? "200" : "",
expectedType: target === "status" ? "number" : "text",
})
}
/>
{check.target === "json" && (
<JsonPathInput
response={activeResponse}
stateKey={`assertion-path:${request.id}:${check.id}`}
defaultValue={check.selector}
forceUpdateKey={updateKey}
onChange={(selector) => update(check.id, { selector })}
/>
)}
{check.target === "header" && (
<PlainInput
label="Header name"
size="sm"
defaultValue={check.selector}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder="Content-Type"
onChange={(selector) => update(check.id, { selector })}
/>
)}
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`operator-${check.id}`}
label="Comparison"
size="sm"
value={check.operator}
options={operators.filter(
(o) =>
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" && (
<div className="flex items-start gap-2 min-w-0">
{check.expectedType !== "null" && (
<div className="flex-1 min-w-0">
<PlainInput
label="Expected value"
size="sm"
defaultValue={check.expected}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder={check.expectedType === "boolean" ? "true or false" : "Value"}
onChange={(expected) => update(check.id, { expected })}
/>
</div>
)}
{check.target === "json" &&
!["greater_than", "less_than", "contains"].includes(check.operator) && (
<div className="w-24 shrink-0">
<Select
name={`type-${check.id}`}
label="Type"
size="sm"
value={check.expectedType}
options={[
{ value: "text", label: "Text" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Boolean" },
{ value: "null", label: "Null" },
]}
onChange={(expectedType) => update(check.id, { expectedType })}
/>
</div>
)}
</div>
)}
</div>
{validation.data?.[check.id] && (
<p className="text-xs text-danger" role="status">
{validation.data[check.id]}
</p>
)}
</div>
))}
{validation.data?.[""] && (
<p className="text-sm text-danger" role="status">
{validation.data[""]}
</p>
)}
{validation.isError && (
<p className="text-sm text-danger" role="status">
Unable to validate assertions.
</p>
)}
<Button
size="sm"
variant="border"
disabled={definition.version !== 1 || definition.checks.length >= 100}
onClick={() =>
save([
...definition.checks,
{
id: generateId(),
enabled: true,
target: "json",
selector: "",
operator: "equals",
expected: "",
expectedType: "text",
},
])
}
>
Add assertion
</Button>
{definition.checks.some((c) => c.target === "json") && (
<p className="text-xs text-text-subtle">
Use a JSONPath such as $.user.id or user.id. Comparisons select one value; Exists also
accepts multiple matches. Suggestions use the selected response, one level at a time.
Values are literal and types must match.
</p>
)}
<HttpAssertionPreview assertions={definition} response={activeResponse} />
</div>
);
}
@@ -42,11 +42,12 @@ import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAssertionsEditor } from "./HttpAssertionsEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
@@ -72,6 +73,7 @@ const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TAB_ASSERTIONS = "assertions";
const TABS_STORAGE_KEY = "http_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
@@ -171,6 +173,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
() => [
{
value: TAB_BODY,
menuLabel: "Body",
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
value: activeRequest.bodyType,
@@ -264,6 +267,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={activeRequest.assertions?.checks.filter((c) => c.enabled).length ?? 0}
/>
),
hiddenByDefault: !activeRequest.assertions?.checks.length,
},
],
[
activeRequest,
@@ -310,11 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
tabsRef.current?.setActiveTab(TAB_PARAMS);
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -368,8 +377,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
visibilityStorageKey={[TABS_STORAGE_KEY, "visibility", activeRequest.workspaceId]}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_ASSERTIONS}>
<HttpAssertionsEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
@@ -2,7 +2,7 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { lazy, Suspense, useMemo, useRef } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
@@ -23,11 +23,12 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpAssertionResults } from "./HttpAssertionResults";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
@@ -56,6 +57,7 @@ const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_ASSERTIONS = "assertions";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -67,6 +69,7 @@ interface RedirectDropWarning {
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const tabsRef = useRef<TabsRef>(null);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
@@ -83,9 +86,53 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const assertionReport = activeResponse?.assertionResults;
// Native sends save the definition before evaluation. Wait for actual outcomes,
// and leave the tab hidden when every check was disabled.
const hasAssertionResults =
activeResponse?.state === "closed" &&
assertionReport != null &&
(assertionReport.error != null ||
assertionReport.results.some((result) => result.outcome !== "skipped"));
const failedAssertions =
assertionReport?.results.filter((result) => result.outcome === "failed").length ?? 0;
const assertionErrors =
assertionReport?.results.filter(
(result) => result.outcome === "error" || result.outcome === "invalid",
).length ?? 0;
const assertionFailures = failedAssertions + assertionErrors;
const assertionFailureLabel = [
failedAssertions > 0
? `${failedAssertions} assertion${failedAssertions === 1 ? "" : "s"} failed`
: null,
assertionErrors > 0
? `${assertionErrors} assertion error${assertionErrors === 1 ? "" : "s"}`
: assertionReport?.error != null
? "Assertion error"
: null,
]
.filter(Boolean)
.join(" · ");
const assertionPasses =
assertionReport?.results.filter((result) => result.outcome === "passed").length ?? 0;
const assertionsHaveErrors = assertionFailures > 0 || assertionReport?.error != null;
const tabs = useMemo<TabItem[]>(
() => [
...(hasAssertionResults
? [
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={assertionsHaveErrors ? assertionFailures || true : assertionPasses}
color={assertionsHaveErrors ? "danger" : "success"}
/>
),
},
]
: []),
{
value: TAB_BODY,
label: "Response",
@@ -162,6 +209,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
hasAssertionResults,
assertionFailures,
assertionPasses,
assertionsHaveErrors,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
@@ -210,6 +261,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
{hasAssertionResults && assertionsHaveErrors && (
<PillButton
color="danger"
className="font-sans"
title="View assertion results"
leftSlot={<Icon icon="alert_triangle" size="xs" />}
onClick={() => tabsRef.current?.setActiveTab(TAB_ASSERTIONS)}
>
{assertionFailureLabel}
</PillButton>
)}
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
@@ -282,13 +344,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
ref={tabsRef}
tabs={tabs}
defaultValue={TAB_BODY}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_ASSERTIONS}>
<HttpAssertionResults response={activeResponse} />
</TabContent>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
+307 -339
View File
@@ -1,33 +1,36 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
@@ -35,25 +38,6 @@ interface Props {
onError: (err: unknown) => void;
}
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
@@ -82,30 +66,19 @@ export function ImportDataDialog(props: Props) {
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
planSources,
detectSource,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
@@ -113,65 +86,82 @@ function LoadedImportDataDialog({
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
let cancelled = false;
if (sources.length === 0) {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
}, [sources, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
// The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
@@ -193,32 +183,6 @@ function LoadedImportDataDialog({
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
@@ -226,21 +190,18 @@ function LoadedImportDataDialog({
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
const nextPlan = await planSources(sources, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
setSourceError(err);
} finally {
setIsLoading(false);
}
@@ -258,8 +219,6 @@ function LoadedImportDataDialog({
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
@@ -313,196 +272,215 @@ function LoadedImportDataDialog({
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
}
const { workspaceId, folderId } = plan.destination;
const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
return ["Destination", name];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: {
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
<>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.level}:${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
</div>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<Select
name="import-destination-kind"
label="Import into"
labelPosition="left"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
{destinationKind === "other" && (
<Select
name="import-destination-kind"
label="Import location"
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
</HStack>
);
return (
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)}
{lastImported != null && destinationWorkspaceId != null ? (
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
@@ -513,36 +491,28 @@ function LoadedImportDataDialog({
}}
>
{linkedWorkspace.name}
</button>
</InlineButton>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
);
}
@@ -556,10 +526,12 @@ function ImportTreeRow({
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
<ActionChip label="new" help="Created by this import" color="success" />
)}
</>
);
@@ -569,11 +541,6 @@ function ImportTreeRow({
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
@@ -593,17 +560,7 @@ function ImportTreeRow({
</div>
) : (
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
)
)}
</>
@@ -613,26 +570,37 @@ function ImportTreeRow({
function ActionChip({
label,
help,
className,
color,
}: {
label: string;
help: string | null;
className?: string;
color: ComponentProps<typeof Chip>["color"];
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
<Chip color={color}>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
</Chip>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
@@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
@@ -0,0 +1,76 @@
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { useQueryClient } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
import { Input } from "./core/Input";
import type { InputProps } from "./core/Input";
import { jsonPathCompletion } from "./core/Editor/json/pathCompletion";
import type { JsonPathChildren, LoadJsonPathChildren } from "./core/Editor/json/pathCompletion";
type Props = Pick<InputProps, "defaultValue" | "onChange" | "forceUpdateKey" | "stateKey"> & {
response: HttpResponse | null;
};
export function JsonPathInput({ response, ...props }: Props) {
const client = useQueryClient();
const [sampled, setSampled] = useState(false);
const responseKey = response?.state === "closed" ? `${response.id}:${response.updatedAt}` : null;
const currentResponseKey = useRef(responseKey);
currentResponseKey.current = responseKey;
const load = useRef<LoadJsonPathChildren>(async () => null);
load.current = async (parent) => {
if (!response || !responseKey) return null;
try {
const result = await client.fetchQuery({
queryKey: ["json_path_children", response.id, response.updatedAt, parent],
queryFn: () =>
rpc<JsonPathChildren>("cmd_http_response_json_children", {
responseId: response.id,
parent,
}),
staleTime: Infinity,
gcTime: 60_000,
retry: false,
});
if (responseKey !== currentResponseKey.current) return null;
setSampled(result.truncated);
return result;
} catch {
return null;
} // Non-JSON bodies and incomplete expressions still allow normal typing.
};
// The editor installs extraExtensions once. A ref keeps the response current without rebuilding
// the input and losing focus/selection when a new response arrives.
const extensions = useMemo(
() => [
autocompletion({ override: [jsonPathCompletion((parent) => load.current(parent))] }),
EditorView.domEventHandlers({
focus: (_event, view) => {
startCompletion(view);
},
}),
],
[],
);
return (
<div className="min-w-0">
<Input
{...props}
label="JSONPath"
size="sm"
placeholder="$.user.id"
language="text"
autocompleteFunctions={false}
autocompleteVariables={false}
extraExtensions={extensions}
/>
{sampled && (
<p className="text-xs text-text-subtle mt-1">
Suggestions are sampled. You can enter any path.
</p>
)}
</div>
);
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function Onboarding() {
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, OpenAPI, or curl"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
+7 -3
View File
@@ -941,10 +941,14 @@ const sidebarTreeAtom = atom<
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
if (a.sortPriority !== b.sortPriority) {
return a.sortPriority - b.sortPriority;
}
return a.sortPriority - b.sortPriority;
// Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
});
for (const item of childItems) {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
<div className="overflow-y-auto h-full">
{name === "secure" ? (
<PlainInput
required
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form>
);
}
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
+32
View File
@@ -0,0 +1,32 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+17 -12
View File
@@ -1,9 +1,8 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useState } from "react";
import { useId, useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
import { PlainInput } from "./PlainInput";
export interface ConfirmProps {
@@ -22,6 +21,7 @@ export function Confirm({
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => {
onResult(false);
onHide();
@@ -38,7 +38,7 @@ export function Confirm({
};
return (
<form className="flex flex-col" onSubmit={handleSuccess}>
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && (
<PlainInput
autoFocus
@@ -61,14 +61,19 @@ export function Confirm({
}
/>
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleHide },
{
label: confirmText ?? "Confirm",
color,
form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
</form>
);
}
+73 -4
View File
@@ -1,9 +1,11 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { IconButton } from "./IconButton";
export interface DialogProps {
@@ -38,6 +40,7 @@ export function Dialog({
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
@@ -71,7 +74,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
@@ -108,9 +111,11 @@ export function Dialog({
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
{children}
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
@@ -129,3 +134,67 @@ export function Dialog({
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
@@ -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();
});
});
@@ -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<JsonPathChildren | null>;
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<CompletionResult | null> => {
// 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<typeof startCompletion>[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;
};
}
@@ -0,0 +1,124 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { forwardRef, type ReactNode } from "react";
import { Dropdown, type DropdownItem } from "./Dropdown";
import { IconButton } from "./IconButton";
export interface PathListItem {
path: string;
icon: IconProps["icon"];
/** Replaces the parent-path line under the name */
subtitle?: ReactNode;
failed?: boolean;
}
interface Props {
items: PathListItem[];
disabled?: boolean;
onRemove: (path: string) => void;
addItems: DropdownItem[];
addLabel: string;
addHint?: string;
hovering?: boolean;
/** Rows rendered between the items and the add row, such as an inline input */
children?: ReactNode;
}
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
ref,
) {
return (
<div
ref={ref}
className={classNames(
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
hovering && "bg-surface-highlight",
)}
>
{items.map((item) => {
const { name, parent } = splitPath(item.path);
return (
<div
key={item.path}
className={classNames(
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
)}
>
<Icon
icon={item.icon}
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
/>
<div className="min-w-0 flex-1" title={item.path}>
<div className="truncate text-sm font-semibold">{name}</div>
<div
className={classNames(
"flex items-center gap-1.5 truncate text-xs",
item.failed ? "text-danger" : "text-text-subtlest",
)}
>
{item.subtitle ??
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
</div>
</div>
<IconButton
size="xs"
icon="trash"
iconColor="secondary"
title={`Remove ${name}`}
disabled={disabled}
className="shrink-0"
onClick={() => onRemove(item.path)}
/>
</div>
);
})}
{children}
<Dropdown items={addItems}>
<button
type="button"
disabled={disabled}
className={classNames(
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
hovering ? "border-notice" : "border-border",
)}
>
<Icon icon="plus" size="sm" className="text-text-subtlest" />
{hovering ? (
"Drop to add"
) : (
<>
<span>{addLabel}</span>
{addHint != null && (
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
)}
</>
)}
</button>
</Dropdown>
</div>
);
});
/** The last segment as the name and everything before it, shortened, as the parent. */
export function splitPath(path: string): { name: string; parent: string } {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
try {
const url = new URL(path);
const segments = url.pathname.split("/").filter(Boolean);
const name = segments.at(-1) ?? url.host;
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
return { name, parent: parent.replace(/\/$/, "") };
} catch {
return { name: path, parent: "" };
}
}
const parts = path.split(/[/\\]/).filter(Boolean);
const name = parts.at(-1) ?? path;
const dirs = parts.slice(0, -1);
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
return { name, parent };
}
+9 -14
View File
@@ -1,10 +1,9 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
export interface PromptProps {
inputs: FormInput[];
@@ -48,19 +47,15 @@ export function Prompt({
const id = `prompt.form.${useRef(generateId()).current}`;
return (
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || "Done"}
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: cancelText || "Cancel", onClick: onCancel },
{ label: confirmText || "Done", color: "primary", form: id },
]}
/>
</form>
);
}
+300 -67
View File
@@ -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<RadioDropdownProps, "children">;
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<TabsRef, Props>(function Tabs(
@@ -87,6 +98,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
layout = "vertical",
storageKey,
activeTabKey,
visibilityStorageKey,
}: Props,
forwardedRef: Ref<TabsRef>,
) {
@@ -108,10 +120,33 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const savedOrder = storage.order;
const visibilityKey = visibilityStorageKey ?? ["tabs", "default_visibility"];
const { value: visibility, set: setVisibility } = useKeyValue<Record<string, boolean>>({
namespace: "no_sync",
key: visibilityKey,
fallback: {},
});
const { value: wrapPreference, set: setWrapPreference } = useKeyValue<boolean>({
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<string | undefined>(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<TabsRef, Props>(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<TabsRef, Props>(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<TabsRef, Props>(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<TabsRef, Props>(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(
<div
key={`marker-${t.value}`}
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>,
);
}
const next = tabs[i + 1];
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
items.push(
<TabButton
key={t.value}
tab={t}
isActive={isActive}
isActive={t.value === value}
addBorders={addBorders}
layout={layout}
reorderable={reorderable}
isDragging={isDragging?.value === t.value}
onChangeValue={onChangeValue}
width={fitted?.width}
spacingCompression={fitted?.spacingCompression}
wrap={wrapTabs}
dropBefore={hoveredIndex === i}
// A drop after the last fitted tab still inserts into the full saved order.
dropAfter={hoveredIndex === i + 1 && (!next || !fitsInStrip(next))}
/>,
);
});
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 = (
<div
ref={viewportRef}
role="tablist"
aria-label={label}
className={classNames(
tabListClassName,
!visibilityStorageKey && tabListClassName,
addBorders && layout === "horizontal" && "pl-3 -ml-1",
addBorders && layout === "vertical" && "ml-0 mb-2",
"flex items-center hide-scrollbars",
layout === "horizontal" && "h-full overflow-auto p-2",
layout === "vertical" && "overflow-x-auto overflow-y-visible ",
layout === "vertical" &&
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
// Give space for button focus states within overflow boundary.
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
!responsive && !addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
)}
>
<div
className={classNames(
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
layout === "vertical" && "flex flex-row shrink-0 w-full",
wrapTabs && "flex-wrap gap-y-1",
)}
>
{tabButtons}
{hoveredIndex === tabs.length && (
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>
</div>
{responsive && !wrapTabs && (
<div
ref={measurementsRef}
aria-hidden
inert
className="absolute invisible pointer-events-none flex w-max"
>
{tabs.filter(isVisible).map((tab) => (
<div key={tab.value} data-measure-tab={tab.value} className="flex shrink-0">
<TabButton
tab={tab}
isActive={tab.value === value}
addBorders={addBorders}
layout={layout}
reorderable={false}
isDragging={false}
/>
</div>
))}
</div>
)}
</div>
);
// Keep the menu outside the scroll viewport so tabs cannot paint behind or past it.
const tabList = visibilityStorageKey ? (
<div
className={classNames(
tabListClassName,
"flex shrink-0 min-w-0 min-h-0",
layout === "vertical" ? "items-start" : "flex-col",
)}
>
<div className="min-w-0 min-h-0 flex-1">{tabStrip}</div>
<div
className={classNames(
"shrink-0 pl-1",
layout === "vertical" && "flex items-center h-md my-1",
)}
>
<Dropdown
items={[
...(overflowTabs.length > 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),
},
]}
>
<IconButton icon="ellipsis" title="More tabs and visibility" size="sm" />
</Dropdown>
</div>
</div>
) : (
tabStrip
);
return (
@@ -361,9 +518,13 @@ export const Tabs = forwardRef<TabsRef, Props>(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<ButtonProps> = {
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}
>
<Button
title={
(constrained || wrap) && typeof option?.label === "string" ? option.label : undefined
}
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
@@ -511,22 +675,91 @@ function TabButton({
);
}
return (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
<Button
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
>
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
// 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 && (
<DropMarker
key="before"
orientation={layout === "vertical" ? "vertical" : "horizontal"}
className={layout === "vertical" ? "left-0" : "top-0"}
/>
)}
{buttonContent}
{dropAfter && (
<DropMarker
key="after"
orientation={layout === "vertical" ? "vertical" : "horizontal"}
className={layout === "vertical" ? "right-0" : "bottom-0"}
/>
)}
</>
);
return reorderable && !overlay ? (
<DraggableTab value={tab.value} className={wrapperClassName} style={wrapperStyle}>
{content}
</DraggableTab>
) : (
<div className={wrapperClassName} style={wrapperStyle}>
{content}
</div>
);
}
// 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 (
<div
ref={handleSetWrapperRef}
className={classNames("relative", layout === "vertical" && "mr-2")}
{...wrapperProps}
>
{buttonContent}
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}
@@ -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),
);
}
}
}
});
});
@@ -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),
}));
}
@@ -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<HTMLDivElement>(null);
const measurementsRef = useRef<HTMLDivElement>(null);
const [fittedTabs, setFittedTabs] = useState<ReturnType<typeof fitTabs> | 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 };
}
@@ -1,43 +0,0 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { Button } from "../core/Button";
import { Select } from "../core/Select";
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>("__NONE__");
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}
@@ -1,4 +1,4 @@
import type { GitStatusEntry } from "@yaakapp-internal/git";
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
import { useGit } from "@yaakapp-internal/git";
import type {
Environment,
@@ -9,8 +9,7 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { type ComponentProps, useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
@@ -19,6 +18,7 @@ import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Chip } from "../core/Chip";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
)}
</>
);
@@ -376,16 +367,9 @@ function ExternalTreeNode({
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger",
)}
>
<Chip className="ml-auto" color={statusColor(entry.status)}>
{entry.status}
</InlineCode>
</Chip>
</div>
}
/>
@@ -479,3 +463,20 @@ function DiffPanel({
</div>
);
}
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
switch (status) {
case "modified":
return "info";
case "untracked":
return "success";
case "removed":
return "danger";
case "conflict":
return "warning";
case "current":
case "renamed":
case "type_change":
return "default";
}
}
+15 -15
View File
@@ -1,8 +1,8 @@
import type { DivergedStrategy } from "@yaakapp-internal/git";
import { HStack, InlineCode } from "@yaakapp-internal/ui";
import { InlineCode } from "@yaakapp-internal/ui";
import { useState } from "react";
import { showDialog } from "../../lib/dialog";
import { Button } from "../core/Button";
import { DialogFooter } from "../core/Dialog";
import { RadioCards } from "../core/RadioCards";
type Resolution = "force_reset" | "merge";
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
};
return (
<div className="flex flex-col gap-4 mb-4">
<div className="flex flex-col gap-4">
<p className="text-text-subtle">
Your local branch has diverged from{" "}
<InlineCode>
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === "force_reset" ? "danger" : "primary"}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : "Select an option"}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleCancel },
{
label: selected != null ? resolutionLabel[selected] : "Select an option",
color: selected === "force_reset" ? "danger" : "primary",
disabled: selected == null,
onClick: handleSubmit,
},
]}
/>
</div>
);
}
-1
View File
@@ -23,7 +23,6 @@ export function useExportData() {
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
+4 -4
View File
@@ -1,4 +1,4 @@
import MimeType from "whatwg-mimetype";
import { MIMEType } from "whatwg-mimetype";
import type { EditorProps } from "../components/core/Editor/Editor";
export function languageFromContentType(
@@ -90,10 +90,10 @@ export function isProbablyTextContentType(contentType: string | null): boolean {
].some((textType) => normalized === textType || normalized.endsWith(textType));
}
export function getMimeTypeFromContentType(contentType: string): MimeType {
export function getMimeTypeFromContentType(contentType: string): MIMEType {
try {
return new MimeType(contentType);
return new MIMEType(contentType);
} catch {
return new MimeType("text/plain");
return new MIMEType("text/plain");
}
}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+1 -4
View File
@@ -1,4 +1,5 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { errorMessage } from "./errorMessage";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+15 -9
View File
@@ -8,7 +8,7 @@ import {
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import type { ImportSourcePath } from "../components/ImportSourceList";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
@@ -20,10 +20,17 @@ import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", {
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
destination,
});
const detectSource = (source: ImportSourcePath) =>
rpc<string>(
"cmd_detect_import_source",
source.kind === "url" ? { url: source.path } : { filePath: source.path },
);
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
className: "h-[36rem]",
disableClose: true,
render: ({ hide }) => {
const cancel = () => {
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
planSources={planSources}
detectSource={detectSource}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
+15 -15
View File
@@ -9,7 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
@@ -27,14 +27,14 @@
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.14.9",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-router": "^1.170.38",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-log": "^2.9.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
@@ -45,7 +45,7 @@
"date-fns": "^4.1.0",
"deep-equal": "^2.2.3",
"eventemitter3": "^5.0.1",
"focus-trap-react": "^11.0.4",
"focus-trap-react": "^12.0.3",
"fuzzbunny": "^1.0.1",
"graphql": "^17.0.2",
"hexy": "^0.4.0",
@@ -54,9 +54,9 @@
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.45.0",
"lucide-react": "^1.47.0",
"mime": "^4.0.4",
"motion": "^13.2.0",
"motion": "^13.4.0",
"nanoid": "^6.0.1",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
@@ -73,30 +73,30 @@
"slugify": "^1.6.6",
"uuid": "^14.0.0",
"vkbeautify": "^0.99.3",
"whatwg-mimetype": "^4.0.0",
"whatwg-mimetype": "^5.0.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.127.5",
"@tanstack/router-plugin": "^1.168.40",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.0.13",
"@types/node": "^22.20.4",
"@types/papaparse": "^5.3.16",
"@types/parse-color": "^1.0.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/whatwg-mimetype": "^5.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"rollup": "^4.60.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
"rollup": "^4.63.4",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"vite-plugin-static-copy": "^4.1.1",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.3.0"
"vite-plus": "^0.3.3"
}
}
+4 -4
View File
@@ -9,7 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-query": "^5.103.2",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -19,7 +19,7 @@
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^13.2.0",
"motion": "^13.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
"vite-plus": "^0.3.0"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"vite-plus": "^0.3.3"
}
}
+1
View File
@@ -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"] }
+4 -4
View File
@@ -4,7 +4,7 @@ description: >
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
@@ -98,9 +98,9 @@ re-run it after acting.
## Core workflows
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
by hand every time. Bruno collections can also be imported from a ZIP or directory.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
+23 -2
View File
@@ -45,7 +45,7 @@ pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
@@ -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<PathBuf>,
/// Write a JUnit XML report without request or assertion values
#[arg(long, value_name = "FILE")]
pub junit: Option<PathBuf>,
}
#[derive(Args)]
pub struct SendArgs {
/// Request, folder, or workspace ID
@@ -202,7 +223,7 @@ pub struct SendArgs {
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
/// Path to the file, ZIP archive, or directory to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak_models::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::export::{self, ExportDataParams};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,11 +50,12 @@ async fn import(
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let input = ImportRequest::from_path(&args.file)
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.import_input(&plugin_context, &input)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
@@ -212,19 +212,6 @@ fn resolve_export_workspace_ids(
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
+2
View File
@@ -10,3 +10,5 @@ pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
pub mod test;
+442
View File
@@ -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<CheckResult>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct RunReport {
version: u32,
exit_code: i32,
requests: Vec<RequestResult>,
}
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<RunReport, String> {
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<CheckResult> = 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<HttpRequest>), 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<String>,
) -> Result<Vec<HttpRequest>, 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::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuite name=\"Yaak\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\">\n",
cases.len(),
count("failed"),
count("error") + count("invalid"),
count("skipped")
);
for (request, check, outcome, reason) in cases {
out.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\">",
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("</testcase>\n");
}
out.push_str("</testsuite>\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<_>>(),
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_&lt;&amp;&quot;\""));
assert!(xml.contains("<skipped "));
}
}
+26
View File
@@ -129,6 +129,32 @@ async fn main() {
}
}
}
Commands::Test(args) => {
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);
@@ -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"));
}
+9
View File
@@ -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<HttpAssertion>, };
+26 -108
View File
@@ -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<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, 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<string>;
}
| {
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<string>;
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<string>, } | { "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<string>, 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, };
+1 -1
View File
@@ -69,7 +69,7 @@ tauri-plugin-updater = "2.10.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17"
tokio-tungstenite = { version = "0.26.2", default-features = false }
tokio-tungstenite = { version = "0.30.0", default-features = false }
url = "2"
tokio-util = { version = "0.7", features = ["codec"] }
ts-rs = { workspace = true }
+2
View File
@@ -1,5 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NavigateWorkspace = { workspaceId: string; environmentId: string | null };
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
@@ -127,6 +127,21 @@ pub async fn send_http_request_with_context<R: Runtime>(
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.
+91 -33
View File
@@ -1,46 +1,89 @@
use crate::PluginContextExt;
use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use std::path::Path;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
use yaak_plugins::MAX_IMPORT_BYTES;
use yaak_plugins::events::ImportRequest;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
let input = read_import_file(file_path)?;
let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
plan_import_contents(window, &input, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
}
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let plugin_context = window.plugin_context();
let mut seen = std::collections::BTreeSet::new();
let mut inputs = Vec::new();
for path in file_paths {
let origin = file_origin(path);
if !seen.insert(origin.origin.clone()) {
continue;
}
let input = read_import_file(path)?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {path}: {err}")))?;
inputs.push((response, origin));
}
for url in urls {
let url = normalize_import_url(url)?;
if !seen.insert(url.clone()) {
continue;
}
let input = fetch_import_url(window, &url).await?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {url}: {err}")))?;
inputs.push((response, url_origin(&url)));
}
Ok(import::plan_import_batch_resources(&window.db_manager(), destination, inputs)?)
}
pub(crate) async fn plan_import_url<R: Runtime>(
/// Which importer claims a source, without planning it. Backs the per-source status in the
/// import dialog so unsupported files are flagged before preview.
pub(crate) async fn detect_import_source<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
file_path: Option<String>,
url: Option<String>,
) -> Result<String> {
let input = match (file_path, url) {
(Some(path), _) => read_import_file(&path)?,
(None, Some(url)) => fetch_import_url(window, &normalize_import_url(&url)?).await?,
(None, None) => return Err(Error::GenericError("Nothing to detect".into())),
};
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let response = plugin_manager.import_input(&window.plugin_context(), &input).await.map_err(
|err| match err {
yaak_plugins::error::Error::PluginErr(msg) if msg.starts_with("No importers found") => {
Error::GenericError("Not a supported format".into())
}
err => Error::GenericError(err.to_string()),
},
)?;
Ok(response.importer)
}
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
input: &ImportRequest,
destination: ImportDestination,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
@@ -53,7 +96,7 @@ async fn plan_import_contents<R: Runtime>(
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
destination,
contents,
input,
origin,
})
.await?)
@@ -88,7 +131,10 @@ pub(crate) fn commit_import<R: Runtime>(
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
/// specs behind auth must still be downloaded manually and imported as a file.
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
async fn fetch_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<ImportRequest> {
let url = normalize_import_url(url)?;
let app_version = window.app_handle().package_info().version.to_string();
let response = yaak_api_client(ApiClientKind::App, &app_version)?
@@ -104,10 +150,27 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
}
response
.text()
// Apply the same input-size limit to downloads before allocating the whole body.
let mut response = response;
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))?
{
if (bytes.len() + chunk.len()) as u64 > MAX_IMPORT_BYTES {
return Err(Error::GenericError("Import file exceeds the 64 MiB limit".into()));
}
bytes.extend_from_slice(&chunk);
}
let name = reqwest::Url::parse(&url)
.ok()
.and_then(|url| {
url.path_segments().and_then(|mut segments| segments.next_back().map(str::to_owned))
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "download".into());
ImportRequest::from_bytes(&name, &bytes).map_err(|err| Error::GenericError(err.to_string()))
}
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
@@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
}
}
fn read_import_file(file_path: &str) -> Result<String> {
read_to_string(file_path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
Error::GenericError(format!(
"Import file must be UTF-8 text; binary files are not supported: {file_path}"
))
} else {
Error::GenericError(format!("Unable to read import file {file_path}: {err}"))
}
fn read_import_file(file_path: &str) -> Result<ImportRequest> {
ImportRequest::from_path(Path::new(file_path)).map_err(|err| {
Error::GenericError(format!("Unable to read import source {file_path}: {err}"))
})
}
@@ -147,7 +204,7 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn read_import_file_returns_error_for_binary_file() {
fn read_import_file_accepts_binary_file() {
let path = std::env::temp_dir().join(format!(
"yaak-import-binary-{}.pftrace",
SystemTime::now()
@@ -157,10 +214,11 @@ mod tests {
));
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
let err = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect_err("binary import should return an error");
let input = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect("binary import should preserve bytes");
assert!(err.to_string().contains("binary files are not supported"));
assert!(input.source.is_some());
assert!(input.content.is_empty());
remove_file(path).expect("remove binary fixture");
}
+4 -11
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::import::{commit_import, plan_import_data};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -1040,18 +1040,11 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
plan_import_data(&window, file_paths, urls, destination).await
}
async fn cmd_commit_import<R: Runtime>(
+43 -5
View File
@@ -517,11 +517,8 @@ async fn cmd_import_data<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportDataReq,
) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_paths, &req.urls, req.destination)
.await?)
}
async fn cmd_commit_import<R: Runtime>(
@@ -539,6 +536,13 @@ async fn cmd_list_import_sources<R: Runtime>(
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_detect_import_source<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDetectImportSourceReq,
) -> Result<String> {
Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportSourcesForOriginReq,
@@ -834,6 +838,40 @@ async fn cmd_default_headers<R: Runtime>(
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
}
async fn cmd_validate_http_assertions<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdValidateHttpAssertionsReq,
) -> Result<HashMap<String, String>> {
Ok(yaak::assertions::validation_errors(&req.assertions))
}
async fn cmd_http_response_json_children<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdHttpResponseJsonChildrenReq,
) -> Result<yaak::jsonpath::JsonPathChildren> {
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<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPreviewHttpAssertionsReq,
) -> Result<yaak::assertions::AssertionReport> {
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<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
}
@@ -3,16 +3,26 @@ use crate::error::Result;
use crate::import::{file_origin, import_data, url_origin};
use crate::models_ext::QueryManagerExt;
use log::{info, warn};
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use ts_rs::TS;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct NavigateWorkspace {
workspace_id: String,
environment_id: Option<String>,
}
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,
url: &Url,
@@ -129,6 +139,15 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
timeout: Some(5000),
},
)?;
if let Some(workspace) = results.workspaces.first() {
window.emit(
"navigate_workspace",
NavigateWorkspace {
workspace_id: workspace.id.clone(),
environment_id: results.environments.first().map(|e| e.id.clone()),
},
)?;
}
}
_ => {
warn!("Unknown deep link command: {command}");
+1 -1
View File
@@ -12,7 +12,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-cli
tauri-plugin = { workspace = true, features = ["build"] }
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26.0"
cocoa = "0.27.0"
log = { workspace = true }
objc = "0.2.7"
rand = "0.9.0"
+1
View File
@@ -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 }
+14
View File
@@ -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<AssertionResult>, 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<HttpAssertion>, };
+13
View File
@@ -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<JsonPathChild>,
/**
* Discovery is sampled/bounded, never an assertion that other keys don't exist.
*/
truncated: boolean, };
+63 -462
View File
@@ -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<Cookie>;
name: string;
};
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, 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<EnvironmentVariable>;
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<EnvironmentVariable>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, 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<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, 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<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
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<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, 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<string>;
}
| {
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<string>;
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<string>, } | { "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<string>, 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<ClientCertificate>;
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<string> };
};
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, 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<string> }, };
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<HttpResponseHeader>;
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<HttpResponseHeader>, 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<number>;
messageType: WebsocketEventType;
};
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, 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, };
+12 -4
View File
@@ -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";
@@ -45,6 +47,8 @@ export type CmdDeleteAllHttpResponsesReq = { requestId: string, };
export type CmdDeleteSendHistoryReq = { workspaceId: string, };
export type CmdDetectImportSourceReq = { filePath?: string, url?: string, };
export type CmdDisableEncryptionReq = { workspaceId: string, };
export type CmdDismissNotificationReq = { notificationId: string, };
@@ -145,12 +149,12 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdHttpResponseJsonChildrenReq = { responseId: string, parent: string, };
export type CmdImportDataReq = { filePaths: Array<string>, urls: Array<string>, destination: ImportDestination, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
export type CmdImportUrlReq = { url: string, destination: ImportDestination, };
export type CmdListImportSourcesReq = { workspaceId: string, };
export type CmdMetadataReq = Record<string, never>;
@@ -175,6 +179,8 @@ export type CmdPluginsUpdateAllReq = Record<string, never>;
export type CmdPluginsUpdatesReq = Record<string, never>;
export type CmdPreviewHttpAssertionsReq = { responseId: string, assertions: HttpAssertions, };
export type CmdReloadPluginsReq = Record<string, never>;
export type CmdRenderTemplateReq = { template: string, workspaceId: string, environmentId: string | null, purpose: RenderPurpose | null, ignoreError: boolean | null, };
@@ -211,6 +217,8 @@ export type CmdTemplateFunctionSummariesReq = Record<string, never>;
export type CmdTemplateTokensToStringReq = { tokens: Tokens, };
export type CmdValidateHttpAssertionsReq = { assertions: HttpAssertions, };
export type CmdWebsocketRequestActionsReq = Record<string, never>;
export type CmdWorkspaceActionsReq = Record<string, never>;
@@ -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<ServiceDefinition>], 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<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], 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<GetThemesResponse>], 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<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], 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_workLine truncated
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<ServiceDefinition>], 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<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], 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<GetThemesResponse>], 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<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], 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_branchLine truncated
export type WatchResult = { unlistenEvent: string, };
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
@@ -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<JsonValue> | { [key in string]?: JsonValue } | null;
+39 -10
View File
@@ -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")]
@@ -228,15 +251,8 @@ pub struct CmdGetHttpResponseEventsReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub file_paths: Vec<String>,
pub urls: Vec<String>,
pub destination: ImportDestination,
}
@@ -264,6 +280,16 @@ pub struct CmdImportSourcesForOriginReq {
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdDetectImportSourceReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdHttpRequestActionsReq {}
@@ -923,6 +949,9 @@ macro_rules! with_commands {
($callback:ident) => {
$callback! {
cmd_metadata(CmdMetadataReq) -> AppMetaData,
cmd_validate_http_assertions(CmdValidateHttpAssertionsReq) -> HashMap<String, String>,
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) -> (),
@@ -939,10 +968,10 @@ macro_rules! with_commands {
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_detect_import_source(CmdDetectImportSourceReq) -> String,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+12
View File
@@ -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"] }
+14
View File
@@ -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<AssertionResult>, 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<HttpAssertion>, };
@@ -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<JsonValue> | { [key in string]?: JsonValue } | null;
+540
View File
@@ -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<HttpAssertion>,
}
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<Value>,
}
#[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<AssertionResult>,
pub error: Option<String>,
}
#[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<Value, &'static str> {
match check.expected_type.as_str() {
"text" => Ok(Value::String(check.expected.clone())),
"number" => match serde_json::from_str::<Value>(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<String, String> {
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<Ordering> {
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<bool, &'static str> {
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::<Value>(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<Value>| 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<Value> = 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<Vec<&Value>, &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"
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ publish = false
[dependencies]
chrono = { workspace = true, features = ["serde"] }
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
git2 = { version = "0.21.0", features = ["vendored-libgit2", "vendored-openssl"] }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+9
View File
@@ -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<HttpAssertion>, };
+37 -162
View File
@@ -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<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, 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<EnvironmentVariable>;
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<EnvironmentVariable>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, 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<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
+3 -3
View File
@@ -74,11 +74,11 @@ fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<V
Some(GitCommit {
oid: oid.to_string(),
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
name: author.name().ok().map(|s| s.to_string()),
email: author.email().ok().map(|s| s.to_string()),
},
when: convert_git_time_to_date(author.when()),
message: commit.message().map(|m| m.to_string()),
message: commit.message().ok().map(|m| m.to_string()),
})
})
.collect();
+5 -4
View File
@@ -40,10 +40,11 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
let repo = open_repo(dir)?;
let branch_name = get_current_branch_name(&repo)?;
let remote = get_default_remote_in_repo(&repo)?;
let remote_name =
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
let remote_url =
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
let remote_name = remote
.name()?
.ok_or(GenericError("Failed to get remote name".to_string()))?
.to_string();
let remote_url = remote.url()?.to_string();
(branch_name, remote_name, remote_url)
};
+5 -4
View File
@@ -23,10 +23,11 @@ pub async fn git_push(dir: &Path) -> Result<PushResult> {
let repo = open_repo(dir)?;
let branch_name = get_current_branch_name(&repo)?;
let remote = get_default_remote_for_push_in_repo(&repo)?;
let remote_name =
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
let remote_url =
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
let remote_name = remote
.name()?
.ok_or(GenericError("Failed to get remote name".to_string()))?
.to_string();
let remote_url = remote.url()?.to_string();
(branch_name, remote_name, remote_url)
};
+4 -3
View File
@@ -18,8 +18,8 @@ pub fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
for remote in repo.remotes()?.into_iter() {
let name = match remote {
None => continue,
Some(name) => name,
Ok(Some(name)) => name,
_ => continue,
};
let r = match repo.find_remote(name) {
Ok(r) => r,
@@ -28,7 +28,8 @@ pub fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
continue;
}
};
remotes.push(GitRemote { name: name.to_string(), url: r.url().map(|u| u.to_string()) });
remotes
.push(GitRemote { name: name.to_string(), url: r.url().ok().map(|u| u.to_string()) });
}
Ok(remotes)
+5 -5
View File
@@ -87,7 +87,7 @@ pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus
let mut entries = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let Some(rela_path) = entry.path() else {
let Ok(rela_path) = entry.path() else {
continue;
};
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
@@ -174,7 +174,7 @@ fn git_branch_info_for_repo(
dir: &Path,
) -> crate::error::Result<GitBranchInfo> {
let (head_ref, head_ref_shorthand) = git_head_refs(repo);
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o.ok()??.to_string())).collect();
let local_branches = local_branch_names(repo)?;
let remote_branches = remote_branch_names(repo)?;
@@ -182,7 +182,7 @@ fn git_branch_info_for_repo(
let (ahead, behind) = (|| -> Option<(usize, usize)> {
let head = repo.head().ok()?;
let local_oid = head.target()?;
let branch_name = head.shorthand()?;
let branch_name = head.shorthand().ok()?;
let upstream_ref =
repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?;
let upstream_oid = upstream_ref.get().target()?;
@@ -205,8 +205,8 @@ fn git_branch_info_for_repo(
fn git_head_refs(repo: &git2::Repository) -> (Option<String>, Option<String>) {
match repo.head() {
Ok(head) => {
let head_ref = head.name().map(|s| s.to_string());
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
let head_ref = head.name().ok().map(|s| s.to_string());
let head_ref_shorthand = head.shorthand().ok().map(|s| s.to_string());
(head_ref, head_ref_shorthand)
}
Err(_) => {
+2 -1
View File
@@ -99,7 +99,7 @@ pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<Strin
}
// if `origin` exists return that
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
let found_origin = remotes.iter().any(|r| matches!(r, Ok(Some(r)) if r == DEFAULT_REMOTE_NAME));
if found_origin {
return Ok(DEFAULT_REMOTE_NAME.into());
}
@@ -109,6 +109,7 @@ pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<Strin
let first_remote = remotes
.iter()
.next()
.and_then(|r| r.ok())
.flatten()
.map(String::from)
.ok_or_else(|| GenericError("no remote found".into()))?;
+1 -1
View File
@@ -7,7 +7,7 @@ publish = false
[dependencies]
async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] }
async-trait = "0.1"
brotli = "7"
brotli = "8"
bytes = "1.11.1"
cookie = "0.18.1"
flate2 = "1"
+11
View File
@@ -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 }
+13
View File
@@ -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<JsonPathChild>,
/**
* Discovery is sampled/bounded, never an assertion that other keys don't exist.
*/
truncated: boolean, };
+226
View File
@@ -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<JsonPath, &'static str> {
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<Value, &'static str> {
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<JsonPathChild>,
/// 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<JsonPathChildren, &'static str> {
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::<String, usize>::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::<Vec<_>>(),
["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::<Vec<_>>(),
["[*]", "[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::<Vec<_>>());
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());
}
}
+1
View File
@@ -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 }
+14
View File
@@ -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<AssertionResult>, 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<HttpAssertion>, };
+81 -506
View File
@@ -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<Cookie>;
name: string;
};
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, 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<EnvironmentVariable>;
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<EnvironmentVariable>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, 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<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, 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<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
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<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, 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<string>;
}
| {
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<string>;
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<string>, } | { "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<string>, 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<string, any>;
authenticationType: string | null;
};
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
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<ClientCertificate>;
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<string> };
};
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, 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<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 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<HttpResponseHeader>;
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<HttpResponseHeader>, 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<number>;
messageType: WebsocketEventType;
};
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, 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, };
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
@@ -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<JsonValue> | { [key in string]?: JsonValue } | null;
+1
View File
@@ -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";
@@ -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;
+32
View File
@@ -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<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
#[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<String>,
pub assertion_results: Option<AssertionReport>,
pub headers: Vec<HttpResponseHeader>,
pub remote_addr: Option<String>,
pub request_content_length: Option<i32>,
@@ -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<String>>("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(),
+18 -1
View File
@@ -103,7 +103,7 @@ pub enum ImportDestination {
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
@@ -232,6 +232,19 @@ pub struct ImportPlanItem {
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanSource {
pub importer: String,
pub origin: ImportOrigin,
pub workspace_id: String,
pub source_keys: BTreeMap<String, String>,
#[serde(default)]
#[ts(optional)]
pub linked_source_id: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
@@ -251,6 +264,10 @@ pub struct ImportPlan {
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
/// Individual origins in a combined import, each retaining its own reimport history.
#[serde(default)]
pub sources: Vec<ImportPlanSource>,
}
pub fn get_workspace_export_resources(
+1 -1
View File
@@ -21,7 +21,7 @@ serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "fs"] }
tokio-tungstenite = "0.26.1"
tokio-tungstenite = "0.30.0"
ts-rs = { workspace = true }
yaak-common = { workspace = true }
yaak-crypto = { workspace = true }
+14
View File
@@ -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<AssertionResult>, 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<HttpAssertion>, };
+10 -1
View File
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
/**
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
*/
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
export type ImportRequest = {
/**
* Kept for text-only importers and older runtimes.
*/
content: string, source?: ImportFileSource, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+62 -455
View File
@@ -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<Cookie>;
name: string;
};
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, 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<EnvironmentVariable>;
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<EnvironmentVariable>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, 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<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
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<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, 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<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, 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<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
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<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, 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<string>;
}
| {
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<string>;
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<string>, } | { "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<string>, 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<ClientCertificate>;
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<string> };
};
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, 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<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 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<HttpResponseHeader>;
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<HttpResponseHeader>, 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<number>;
messageType: WebsocketEventType;
};
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, 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<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
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<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, 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, };
+13
View File
@@ -240,7 +240,20 @@ pub struct ReloadResponse {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportRequest {
/// Kept for text-only importers and older runtimes.
pub content: String,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<ImportFileSource>,
}
/// Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_events.ts")]
pub enum ImportFileSource {
File { name: String, base64: String },
Directory { path: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more