mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5520fb3a8 | ||
|
|
c71256d800 | ||
|
|
6e7f23a778 | ||
|
|
b03c41b767 | ||
|
|
a0d71eb486 | ||
|
|
38c562f775 | ||
|
|
1249d15f2c | ||
|
|
1707c387ea | ||
|
|
a497d9f018 | ||
|
|
c13e26656b | ||
|
|
dfc974db95 | ||
|
|
0b86d3ca3f | ||
|
|
9e51e95f9e | ||
|
|
aa10c44a45 | ||
|
|
46464cfacf | ||
|
|
263a215f7a |
No files matched your search
+2
-1
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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'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>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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())}
|
||||
|
||||
@@ -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 || <> </>
|
||||
)}
|
||||
</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 || <> </>
|
||||
)}
|
||||
</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 }) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export function useExportData() {
|
||||
id: "export-data",
|
||||
title: "Export Data",
|
||||
size: "md",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<ExportDataDialog
|
||||
onHide={hide}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -10,3 +10,5 @@ pub mod response;
|
||||
pub mod send;
|
||||
pub mod template_function;
|
||||
pub mod workspace;
|
||||
|
||||
pub mod test;
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn junit(report: &RunReport) -> String {
|
||||
let mut cases = Vec::new();
|
||||
for request in &report.requests {
|
||||
for check in &request.checks {
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
check.assertion_id.as_str(),
|
||||
check.outcome.as_str(),
|
||||
check.reason.as_str(),
|
||||
));
|
||||
}
|
||||
if request.checks.is_empty()
|
||||
|| request.outcome == "error"
|
||||
&& !request.checks.iter().any(|c| c.outcome == "error" || c.outcome == "invalid")
|
||||
{
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
"request",
|
||||
if request.outcome == "unchecked" { "skipped" } else { &request.outcome },
|
||||
request.reason.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let count = |outcome| cases.iter().filter(|c| c.2 == outcome).count();
|
||||
let mut out = format!(
|
||||
"<?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_<&"\""));
|
||||
assert!(xml.contains("<skipped "));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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, };
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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>(
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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) -> (),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>, };
|
||||
Generated
+37
-162
@@ -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, };
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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, };
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, };
|
||||
Generated
+12
@@ -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,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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, };
|
||||
@@ -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
Reference in new issue
Block a user