mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 17:12:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4d9e3f784 | ||
|
|
c14f6ff3bb | ||
|
|
7d83b5cf3a | ||
|
|
2d2a390bfd | ||
|
|
bc27e5eee7 | ||
|
|
395959f84c | ||
|
|
5cce23566a | ||
|
|
b5b1ab4de8 | ||
|
|
e471d73c34 | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da |
@@ -103,6 +103,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-web compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
|
||||
Generated
+30
-30
@@ -249,7 +249,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
@@ -265,7 +265,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -595,14 +595,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4463,7 +4464,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5023,15 +5024,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -5055,18 +5055,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.0+3.5.0"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -5880,7 +5880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5890,7 +5890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6455,9 +6455,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -6466,9 +6466,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -7348,7 +7348,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9509,7 +9509,7 @@ dependencies = [
|
||||
"indexmap 1.9.3",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -9726,7 +9726,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log 0.4.29",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
@@ -9996,7 +9996,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"js-sys",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -11199,6 +11199,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
@@ -11241,7 +11242,7 @@ dependencies = [
|
||||
"pretty_graphql",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"rlimit",
|
||||
"serde",
|
||||
@@ -11326,7 +11327,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"oxc_resolver",
|
||||
"predicates",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.20",
|
||||
"rolldown",
|
||||
"schemars 0.8.22",
|
||||
@@ -11352,7 +11353,6 @@ dependencies = [
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
@@ -11535,7 +11535,7 @@ dependencies = [
|
||||
"csscolorparser",
|
||||
"log 0.4.29",
|
||||
"objc",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
@@ -11577,7 +11577,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.7.0",
|
||||
"path-slash",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11755,7 +11755,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import type { Folder, ImportDestination, ImportPlan, Workspace } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { RadioCards } from "./core/RadioCards";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
type DestinationChoice = "new_workspace" | "current_workspace";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -31,8 +42,21 @@ function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
export function ImportDataDialog({
|
||||
currentWorkspace,
|
||||
selectedFolder,
|
||||
planFile,
|
||||
planUrl,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [destinationChoice, setDestinationChoice] = useState<DestinationChoice>(
|
||||
currentWorkspace == null ? "new_workspace" : "current_workspace",
|
||||
);
|
||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
@@ -71,19 +95,110 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationChoice === "current_workspace" && currentWorkspace != null) {
|
||||
return {
|
||||
type: "current_workspace",
|
||||
workspaceId: currentWorkspace.id,
|
||||
folderId: targetSelectedFolder ? selectedFolder?.id : undefined,
|
||||
};
|
||||
}
|
||||
return { type: "new_workspace" };
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit(plan);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (plan != null) {
|
||||
const counts = [
|
||||
["Workspace", plan.resources.workspaces.length],
|
||||
["Environment", plan.resources.environments.length],
|
||||
["Folder", plan.resources.folders.length],
|
||||
["HTTP Request", plan.resources.httpRequests.length],
|
||||
["gRPC Request", plan.resources.grpcRequests.length],
|
||||
["WebSocket Request", plan.resources.websocketRequests.length],
|
||||
] as const;
|
||||
const destinationLabel =
|
||||
plan.destination.type === "new_workspace"
|
||||
? "New workspace"
|
||||
: selectedFolder != null && plan.destination.folderId === selectedFolder.id
|
||||
? `${currentWorkspace?.name ?? "Current workspace"} / ${selectedFolder.name}`
|
||||
: (currentWorkspace?.name ?? "Current workspace");
|
||||
|
||||
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="Destination" value={destinationLabel} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Resources</div>
|
||||
<ul className="list-disc pl-6 text-sm text-text-subtle">
|
||||
{counts
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([label, count]) => (
|
||||
<li key={label}>{pluralizeCount(label, count)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
||||
{isLoading ? "Importing" : "Confirm Import"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -115,25 +230,69 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
<div className="text-sm font-semibold">Import destination</div>
|
||||
<RadioCards
|
||||
name="import-destination"
|
||||
value={destinationChoice}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{
|
||||
value: "new_workspace",
|
||||
label: "New workspace",
|
||||
description: "Create imported resources in a separate workspace.",
|
||||
},
|
||||
...(currentWorkspace == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: "current_workspace" as const,
|
||||
label: currentWorkspace.name,
|
||||
description: "Add resources without changing this workspace's settings.",
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
{destinationChoice === "current_workspace" && selectedFolder != null && (
|
||||
<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}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
@@ -27,7 +29,7 @@ export function Dialog({
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
@@ -42,7 +44,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
@@ -58,7 +60,7 @@ export function Dialog({
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
onClose?.();
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function Dialog({
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
InvalidSymbol: "Invalid symbol",
|
||||
InvalidNumberFormat: "Invalid number format",
|
||||
PropertyNameExpected: "Property name expected",
|
||||
ValueExpected: "Value expected",
|
||||
ColonExpected: "Colon expected",
|
||||
CommaExpected: "Comma expected",
|
||||
CloseBraceExpected: "Closing brace expected",
|
||||
CloseBracketExpected: "Closing bracket expected",
|
||||
EndOfFileExpected: "End of file expected",
|
||||
InvalidCommentToken: "Comments are not allowed",
|
||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||
UnexpectedEndOfString: "Unexpected end of string",
|
||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||
InvalidUnicode: "Invalid unicode sequence",
|
||||
InvalidEscapeCharacter: "Invalid escape character",
|
||||
InvalidCharacter: "Invalid character",
|
||||
};
|
||||
|
||||
interface JsonLintOptions {
|
||||
allowComments?: boolean;
|
||||
allowTrailingCommas?: boolean;
|
||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
||||
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
try {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
jsonLintParse(escapedDoc, {
|
||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (!("location" in err)) {
|
||||
return [];
|
||||
}
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
|
||||
// const line = location?.start?.line;
|
||||
// const column = location?.start?.column;
|
||||
if (err.location.start.offset) {
|
||||
return [
|
||||
{
|
||||
from: err.location.start.offset,
|
||||
to: err.location.start.offset,
|
||||
severity: "error",
|
||||
message: err.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||
disallowComments: !(options?.allowComments ?? true),
|
||||
});
|
||||
|
||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||
const error = errors[0];
|
||||
if (error == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.offset,
|
||||
to: error.offset + error.length,
|
||||
severity: "error",
|
||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -333,7 +333,9 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
labelParts.push("↩");
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
@@ -21,29 +24,41 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
currentWorkspace={currentWorkspace}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={(filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
planUrl={(url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination })
|
||||
}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
@@ -54,6 +53,7 @@
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
@@ -93,14 +93,12 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/workspaces': typeof WorkspacesIndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/workspaces'
|
||||
| '/workspaces/'
|
||||
| '/workspaces/$workspaceId/settings'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/'
|
||||
| '/workspaces/$workspaceId/requests/$requestId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
|
||||
'/workspaces/': {
|
||||
id: '/workspaces/'
|
||||
path: '/workspaces'
|
||||
fullPath: '/workspaces'
|
||||
fullPath: '/workspaces/'
|
||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces/$workspaceId/': {
|
||||
id: '/workspaces/$workspaceId/'
|
||||
path: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId/'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -43,10 +42,11 @@ export default defineConfig(async () => {
|
||||
: {},
|
||||
},
|
||||
// The browser host runs the model layer in a worker; that bundle needs the
|
||||
// same wasm and top-level-await handling as the main one.
|
||||
// same wasm handling as the main one. Top-level await needs no transform
|
||||
// because the build targets esnext.
|
||||
worker: {
|
||||
format: "es" as const,
|
||||
plugins: () => [wasm(), topLevelAwait()],
|
||||
plugins: () => [wasm()],
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
@@ -58,7 +58,6 @@ export default defineConfig(async () => {
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
topLevelAwait(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
|
||||
@@ -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.2.1",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::CurrentWorkspace { workspace_id, folder_id: None },
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
|
||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||
match generate_plugin_metadata(&watch_root) {
|
||||
// Assets are staged on every rebuild, so a changed asset or
|
||||
// declaration is picked up without restarting.
|
||||
let result = copy_build_assets(&watch_root)
|
||||
.and_then(|()| generate_plugin_metadata(&watch_root));
|
||||
match result {
|
||||
Ok(()) => ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
watch_root.join("build/metadata.json").display()
|
||||
@@ -408,6 +412,7 @@ struct PublishResponse {
|
||||
|
||||
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||
prepare_build_output_dir(plugin_dir)?;
|
||||
copy_build_assets(plugin_dir)?;
|
||||
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
||||
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
||||
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
||||
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifest {
|
||||
#[serde(default)]
|
||||
yaak: PluginManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifestConfig {
|
||||
/// Files to place beside the bundle, as paths relative to the plugin
|
||||
/// directory. Publishing ships everything in `build/`, so these travel with
|
||||
/// the plugin.
|
||||
#[serde(default, rename = "buildAssets")]
|
||||
build_assets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Copy the plugin's declared assets into `build/`.
|
||||
///
|
||||
/// This runs after the directory is cleared and before the bundle is written,
|
||||
/// because a bundle may read an asset from its own directory at import time and
|
||||
/// metadata generation imports the bundle.
|
||||
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
|
||||
let manifest_path = plugin_dir.join("package.json");
|
||||
let manifest: PluginManifest = serde_json::from_str(
|
||||
&fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
|
||||
|
||||
let build_dir = plugin_dir.join("build");
|
||||
let mut names = HashSet::new();
|
||||
for asset in manifest.yaak.build_assets {
|
||||
let src = plugin_dir.join(&asset);
|
||||
let name = src
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
|
||||
// A copy that later gets overwritten would pass the build and fail on
|
||||
// load, so anything the build itself writes, or a second asset with
|
||||
// the same name, is rejected up front. Names are compared without
|
||||
// case, because a plugin is installed on case-insensitive filesystems
|
||||
// wherever it was built.
|
||||
let key = name.to_string_lossy().to_lowercase();
|
||||
if key == "index.js" || key == "metadata.json" {
|
||||
return Err(format!("Build asset {asset} would be overwritten by the build output"));
|
||||
}
|
||||
if !names.insert(key) {
|
||||
return Err(format!("Two build assets share the name {}", name.display()));
|
||||
}
|
||||
if !src.is_file() {
|
||||
return Err(format!("Build asset does not exist: {}", src.display()));
|
||||
}
|
||||
fs::copy(&src, build_dir.join(name))
|
||||
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
|
||||
BundlerOptions {
|
||||
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
||||
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_publish_archive, generate_plugin_metadata};
|
||||
use super::{
|
||||
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
||||
prepare_build_output_dir,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -795,6 +860,100 @@ mod tests {
|
||||
assert!(!names.contains("ignored/secret.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_build_output_dir_clears_stale_output() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
let build = root.join("build");
|
||||
fs::create_dir_all(&build).expect("create build");
|
||||
fs::write(build.join("index.js"), "stale").expect("write index.js");
|
||||
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
|
||||
|
||||
prepare_build_output_dir(root).expect("prepare build dir");
|
||||
|
||||
// Publishing ships everything under build/, so nothing may survive.
|
||||
assert!(build.is_dir());
|
||||
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_places_declared_files_beside_the_bundle() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
|
||||
"asset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_is_a_noop_without_declarations() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_names_the_build_writes() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("index.js"), "asset").expect("write asset");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("reserved name should fail");
|
||||
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_duplicate_names() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("a")).expect("create a");
|
||||
fs::create_dir_all(root.join("b")).expect("create b");
|
||||
// Differ only by case: one file on macOS and Windows.
|
||||
fs::write(root.join("a/core.wasm"), "one").expect("write a");
|
||||
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("duplicate name should fail");
|
||||
assert!(err.contains("share the name"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_fails_on_a_missing_asset() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("missing asset should fail");
|
||||
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_plugin_metadata_detects_api_types() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
||||
let workspace = workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.name == "Imported Workspace")
|
||||
.expect("workspace imported");
|
||||
assert_ne!(workspace.id, "wrk_import");
|
||||
|
||||
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.name == "Imported Request")
|
||||
.expect("request imported");
|
||||
assert_ne!(request.id, "req_import");
|
||||
assert_eq!(request.workspace_id, workspace.id);
|
||||
assert_eq!(request.url, "https://example.com");
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use tauri::{Runtime, State, WebviewWindow};
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
@@ -21,22 +20,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_grpc_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
|
||||
new_request.metadata = metadata;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
|
||||
@@ -179,19 +179,3 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
pub fn resolve_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak::import::{self, PlanImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
import_contents(window, &contents).await
|
||||
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
|
||||
commit_import(window, plan)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_url<R: Runtime>(
|
||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
plan_import_contents(window, &contents, destination).await
|
||||
}
|
||||
|
||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
plan_import_contents(window, &contents, destination).await
|
||||
}
|
||||
|
||||
async fn import_contents<R: Runtime>(
|
||||
async fn plan_import_contents<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let query_manager = window.db_manager();
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
Ok(import::plan_import_data(PlanImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
destination,
|
||||
contents,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(crate) fn commit_import<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
|
||||
}
|
||||
|
||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||
/// pipeline as a file on disk.
|
||||
///
|
||||
|
||||
@@ -2,18 +2,17 @@ extern crate core;
|
||||
use crate::encoding::read_response_body;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
||||
use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::import::{import_data, import_url};
|
||||
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::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -31,26 +30,20 @@ use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
|
||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
@@ -58,10 +51,9 @@ use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
@@ -220,56 +212,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
|
||||
Some(parts.next().unwrap_or(line).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
tokens: Tokens,
|
||||
) -> YaakResult<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
let new_tokens = transform_args(tokens, &cb)?;
|
||||
Ok(new_tokens.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
template: &str,
|
||||
workspace_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
purpose: Option<RenderPurpose>,
|
||||
ignore_error: Option<bool>,
|
||||
) -> YaakResult<String> {
|
||||
let environment_chain =
|
||||
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let result = render_template(
|
||||
template,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
purpose.unwrap_or(RenderPurpose::Preview),
|
||||
),
|
||||
&RenderOptions {
|
||||
error_behavior: match ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
@@ -296,7 +238,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<Vec<ServiceDefinition>> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
@@ -356,7 +299,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<String> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
@@ -1069,294 +1013,39 @@ async fn cmd_get_sse_events<R: Runtime>(
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_data(&window, file_path).await
|
||||
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
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_url(&window, url).await
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_websocket_request_action(
|
||||
&window.plugin_context(),
|
||||
CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWorkspaceActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_workspace_action(
|
||||
&window.plugin_context(),
|
||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetFolderActionsResponse>> {
|
||||
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallFolderActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let folder = window.db().get_folder(&req.args.folder.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_folder_action(
|
||||
&window.plugin_context(),
|
||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
) -> YaakResult<GetTemplateFunctionConfigResponse> {
|
||||
Ok(plugin_manager
|
||||
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results =
|
||||
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values for token lookup
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
|
||||
.await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.get_http_authentication_config(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
rendered_values,
|
||||
model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallHttpRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_http_request_action(
|
||||
&window.plugin_context(),
|
||||
CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs {
|
||||
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_grpc_request_action(
|
||||
&window.plugin_context(),
|
||||
CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs {
|
||||
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<()> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.call_http_authentication_action(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
rendered_values,
|
||||
&model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
command: &str,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<HttpRequest> {
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
|
||||
|
||||
Ok(import_result
|
||||
.resources
|
||||
.http_requests
|
||||
.get(0)
|
||||
.ok_or(GenericError("No curl command found".to_string()))
|
||||
.map(|r| {
|
||||
let mut request = r.clone();
|
||||
request.workspace_id = workspace_id.into();
|
||||
request.id = "".to_string();
|
||||
request
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
@@ -1452,17 +1141,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<(String, String)>> {
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
let plugin_context =
|
||||
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
|
||||
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
@@ -1782,6 +1460,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
|
||||
let ev = match ev {
|
||||
Ok(Some(ev)) => ev,
|
||||
// Nothing to say, or the reply comes later from somewhere else.
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!("Failed to handle plugin event: {e:?}");
|
||||
@@ -1794,7 +1473,10 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
timeout: Some(30000),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
// Tell the plugin as well as the user. It is awaiting a
|
||||
// reply, and a toast it cannot see would leave it
|
||||
// waiting for one that never comes.
|
||||
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
use serde_json::Value;
|
||||
//! One import path for rendering, wherever the pieces actually live.
|
||||
//!
|
||||
//! The request renderers are engine code; the template renderers moved to
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, &opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
@@ -39,9 +40,11 @@ use yaak_models::models::{
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::events::{
|
||||
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
@@ -50,11 +53,13 @@ use yaak_plugins::events::{
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
@@ -103,27 +108,177 @@ impl<R: Runtime> Host for ClientCtx<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> ClientCtx<R> {
|
||||
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||
/// below uses it; everything else goes through the trait.
|
||||
fn pm(&self) -> State<'_, PluginManager> {
|
||||
self.window.state::<PluginManager>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop answers all of these out of the `PluginManager` it already
|
||||
/// runs — the Node sidecar. Each is a delegation, which is the point: the
|
||||
/// operations are what the handlers need, and this is one host's way of
|
||||
/// providing them.
|
||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
||||
let manager = self.window.state::<PluginManager>();
|
||||
let handle = manager.get_plugin_by_dir(directory).await?;
|
||||
let handle = self.pm().get_plugin_by_dir(directory).await?;
|
||||
Some(handle.info())
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
self.window.state::<PluginManager>().take_init_errors().await
|
||||
self.pm().take_init_errors().await
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
self.window.state::<PluginManager>().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
}
|
||||
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
PluginTemplateCallback::new(
|
||||
Arc::new((*self.pm()).clone()),
|
||||
Arc::new(self.encryption_manager().clone()),
|
||||
&self.plugin_context(),
|
||||
purpose,
|
||||
)
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_summaries(&self.plugin_context())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(self.pm().get_themes(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.call_http_authentication_action(
|
||||
&self.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
values,
|
||||
model_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
||||
let plugin_manager = Arc::new((*self.window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new((*self.pm()).clone());
|
||||
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
@@ -227,11 +382,11 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?)
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
|
||||
@@ -286,76 +441,80 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
|
||||
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<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).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?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(crate::cmd_websocket_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(crate::cmd_workspace_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(crate::cmd_folder_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(crate::cmd_get_http_authentication_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(crate::cmd_get_http_authentication_config(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_http_authentication_action(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.action_index, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
|
||||
@@ -374,8 +533,8 @@ async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRe
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(crate::cmd_reload_plugins(ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
@@ -418,8 +577,8 @@ async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTempla
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
@@ -688,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType, WebsocketRequest,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
@@ -27,6 +27,7 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
@@ -75,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
environment_id,
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -154,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -454,23 +455,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Resolve inherited authentication and headers for a websocket request
|
||||
fn resolve_websocket_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting
|
||||
|
||||
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, bodyPath: string | null, 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, 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, };
|
||||
|
||||
|
||||
+6
-4
File diff suppressed because one or more lines are too long
+10
@@ -2,3 +2,13 @@
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
|
||||
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
|
||||
|
||||
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
|
||||
|
||||
@@ -23,7 +23,7 @@ use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportDataReq {
|
||||
pub file_path: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportUrlReq {
|
||||
pub url: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdCommitImportReq {
|
||||
pub plan: ImportPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -909,8 +918,9 @@ macro_rules! with_commands {
|
||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
|
||||
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
|
||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||
|
||||
@@ -6,10 +6,8 @@ authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
yaak = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The actions plugins contribute to the UI, and the calls that run them.
|
||||
//!
|
||||
//! Listing is a plain question for the plugin runtime. Calling is not: the
|
||||
//! frontend sends back the model it was showing, and a plugin must act on what
|
||||
//! that model *actually is* — re-read from the database, with inheritance
|
||||
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
|
||||
//! work these handlers do.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::resolve::{resolve_grpc_request, resolve_http_request};
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
// -- Listing --
|
||||
|
||||
pub async fn cmd_http_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
host.http_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_websocket_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
host.websocket_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_grpc_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
host.grpc_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_workspace_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
host.workspace_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_folder_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
host.folder_actions().await
|
||||
}
|
||||
|
||||
// -- Calling --
|
||||
|
||||
pub async fn cmd_call_http_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
|
||||
host.call_http_request_action(CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs { http_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
|
||||
host.call_grpc_request_action(CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
|
||||
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_workspace_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
|
||||
host.call_workspace_action(CallWorkspaceActionRequest {
|
||||
args: CallWorkspaceActionArgs { workspace },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_folder_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let folder = host.db().get_folder(&inner.args.folder.id)?;
|
||||
host.call_folder_action(CallFolderActionRequest {
|
||||
args: CallFolderActionArgs { folder },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Other things the plugin runtime does --
|
||||
|
||||
/// Turn a `curl` command line into an unsaved request, by handing it to the
|
||||
/// same importer plugins that read files.
|
||||
pub async fn cmd_curl_to_request<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
let imported = host.import_data(&req.command).await?;
|
||||
|
||||
let request = imported
|
||||
.resources
|
||||
.http_requests
|
||||
.first()
|
||||
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
|
||||
|
||||
// Belongs to the workspace the user is importing into, and is not saved
|
||||
// until they say so — hence the blank id.
|
||||
let mut request = request.clone();
|
||||
request.workspace_id = req.workspace_id;
|
||||
request.id = String::new();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Restart every plugin, returning whatever failed to come back up.
|
||||
pub async fn cmd_reload_plugins<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let plugins = host.db().list_plugins()?;
|
||||
Ok(host.reload_plugins(plugins).await)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Authentication config forms and their actions.
|
||||
//!
|
||||
//! Both commands here do the same preparation: the frontend sends the model
|
||||
//! whose auth is being edited plus the values currently in the form, and those
|
||||
//! values may contain templates. They have to be rendered against the model's
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
|
||||
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
host.http_authentication_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Send,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! is anything only a desktop can do — open a native window, run the updater,
|
||||
//! show a native dialog — those handlers stay with the desktop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -21,8 +22,17 @@ use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
PluginContext, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
/// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately
|
||||
/// *not*: a browser host is single-threaded and its connection pool is an
|
||||
@@ -106,6 +116,101 @@ pub trait PluginHost: Host {
|
||||
/// loaded. A host without a runtime can return them untouched.
|
||||
fn resolve_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<Plugin>>;
|
||||
|
||||
/// The template functions this host can run, as a callback the renderer
|
||||
/// drives. This is the *only* thing the plugin runtime uniquely provides to
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// rendering shared instead of pushing whole commands behind this trait.
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||
|
||||
/// Every template function the installed plugins expose, for the
|
||||
/// autocomplete menu.
|
||||
fn template_function_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetTemplateFunctionConfigResponse>>;
|
||||
|
||||
/// Themes contributed by plugins.
|
||||
fn themes(&self) -> impl Future<Output = crate::Result<Vec<GetThemesResponse>>>;
|
||||
|
||||
// -- Actions plugins contribute to the UI --
|
||||
|
||||
fn http_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpRequestActionsResponse>>>;
|
||||
fn websocket_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWebsocketRequestActionsResponse>>>;
|
||||
fn grpc_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetGrpcRequestActionsResponse>>>;
|
||||
fn workspace_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWorkspaceActionsResponse>>>;
|
||||
fn folder_actions(&self) -> impl Future<Output = crate::Result<Vec<GetFolderActionsResponse>>>;
|
||||
|
||||
/// Running an action. The request in each of these has already been
|
||||
/// re-read and had its inheritance resolved by the handler; a host must
|
||||
/// pass it through untouched.
|
||||
fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- Authentication --
|
||||
|
||||
fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpAuthenticationSummaryResponse>>>;
|
||||
|
||||
/// The form an auth plugin wants to show. `values` arrive already rendered.
|
||||
fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetHttpAuthenticationConfigResponse>>;
|
||||
|
||||
fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- The importers, and the runtime itself --
|
||||
|
||||
/// Hand arbitrary text to the importer plugins and take what they make of
|
||||
/// it. Used for files, URLs and pasted `curl` commands alike.
|
||||
fn import_data(&self, content: &str) -> impl Future<Output = crate::Result<ImportResponse>>;
|
||||
|
||||
/// Restart every plugin, returning `(plugin, error)` for those that failed.
|
||||
fn reload_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// Re-encrypt the `secure(...)` values in a template.
|
||||
///
|
||||
/// Whole operation rather than its pieces because the encryption is only
|
||||
|
||||
@@ -11,13 +11,18 @@
|
||||
//! host-specific types; the ones that stay behind are the ones only a desktop
|
||||
//! can serve (native windows, the updater, dialogs) or that still lean on it.
|
||||
|
||||
pub mod actions;
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod encryption;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod models;
|
||||
pub mod plugins;
|
||||
pub mod render;
|
||||
pub mod resolve;
|
||||
pub mod responses;
|
||||
pub mod templates;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use host::{Host, PluginHost};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Filling in what a request inherits from its folders and workspace.
|
||||
//!
|
||||
//! A request stored in the database records only what is set *on it*;
|
||||
//! authentication and headers can come from any ancestor. Anything that acts on
|
||||
//! a request as the user sees it — sending it, handing it to a plugin — has to
|
||||
//! resolve that chain first, which is why this is shared rather than living
|
||||
//! next to any one caller.
|
||||
|
||||
use crate::error::Result;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
|
||||
/// The request with inherited auth and headers filled in, plus the id of the
|
||||
/// model the authentication was inherited *from* — plugins key their token
|
||||
/// caches on it, so it must be the ancestor's id and not the request's.
|
||||
pub fn resolve_http_request(db: &ClientDb, request: &HttpRequest) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_http_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_grpc_request(db: &ClientDb, request: &GrpcRequest) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.metadata = db.resolve_metadata_for_grpc_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_websocket_request(
|
||||
db: &ClientDb,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_websocket_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Templates, the functions plugins put in them, and themes.
|
||||
//!
|
||||
//! Everything here needs the plugin runtime, but only for the one thing it
|
||||
//! uniquely provides: running a template function. Resolving the environment
|
||||
//! chain and deciding what a render should do about errors are ordinary work
|
||||
//! and stay here, where every host gets them the same.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, transform_args};
|
||||
|
||||
pub async fn cmd_render_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
// empties instead.
|
||||
error_behavior: match req.ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
};
|
||||
Ok(render_template(&req.template, environment_chain, &cb, &options).await?)
|
||||
}
|
||||
|
||||
/// Render only the *arguments* of a template's function calls, leaving the
|
||||
/// calls themselves intact. This is what turns a parsed template back into
|
||||
/// something displayable without evaluating it.
|
||||
pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
host.template_function_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
host.themes().await
|
||||
}
|
||||
@@ -8,25 +8,39 @@
|
||||
//! `PluginHost` too, without one, which is only possible because that trait
|
||||
//! names operations rather than handing back a manager.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use yaak_commands::auth::cmd_get_http_authentication_config;
|
||||
use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{AnyModel, Plugin, Workspace};
|
||||
use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc_schema::{
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq,
|
||||
ModelsWorkspaceModelsReq,
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq,
|
||||
ModelsUpsertReq, ModelsWorkspaceModelsReq,
|
||||
};
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestHost {
|
||||
@@ -155,6 +169,9 @@ async fn host_free_handlers_need_no_state() {
|
||||
#[derive(Clone)]
|
||||
struct SingleThreadedHost {
|
||||
inner: Rc<Inner>,
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
@@ -183,6 +200,32 @@ impl Host for SingleThreadedHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// A template callback with no plugins behind it: variables still resolve,
|
||||
/// function calls have nothing to run them. A browser host would put a Worker
|
||||
/// round-trip where this returns an error.
|
||||
struct NoTemplateFunctions;
|
||||
|
||||
impl TemplateCallback for NoTemplateFunctions {
|
||||
async fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Err(yaak_templates::error::Error::RenderError(format!(
|
||||
"no plugin runtime to run {fn_name}()"
|
||||
)))
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
&self,
|
||||
_fn_name: &str,
|
||||
_arg_name: &str,
|
||||
arg_value: &str,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Ok(arg_value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Answering plugin questions with no plugin runtime behind them. A browser
|
||||
/// host would put a `postMessage` round-trip to its Worker where these return
|
||||
/// constants; the shape of the trait is what makes either possible.
|
||||
@@ -204,12 +247,136 @@ impl PluginHost for SingleThreadedHost {
|
||||
async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result<String> {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
// No plugins, so nothing contributes actions and nothing can run one.
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
_req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
_req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
_req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
_req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_folder_action(&self, _req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
*self.auth_values.borrow_mut() = Some(values);
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
_action_index: i32,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn import_data(&self, _content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, _plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn no_plugins() -> yaak_commands::Error {
|
||||
yaak_commands::Error::Generic("no plugin runtime on this host".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")) };
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
@@ -227,6 +394,42 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
.expect("workspace models");
|
||||
assert!(json.contains(&id), "the workspace should be in its own bootstrap payload");
|
||||
|
||||
// Rendering, on a host whose template callback has no plugins behind it.
|
||||
// Resolving the environment chain is a database read and the render is
|
||||
// shared code; only the callback came from the host. Rendering a real
|
||||
// variable is what proves the chain was resolved rather than skipped.
|
||||
let environment = host
|
||||
.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("seed environment");
|
||||
|
||||
let rendered = cmd_render_template(
|
||||
host.clone(),
|
||||
CmdRenderTemplateReq {
|
||||
template: "${[ greeting ]} world".to_string(),
|
||||
workspace_id: id.clone(),
|
||||
environment_id: Some(environment.id.clone()),
|
||||
purpose: None,
|
||||
ignore_error: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("render");
|
||||
assert_eq!(rendered, "hello world", "the environment chain should have been resolved");
|
||||
|
||||
// The delete path too, since it is the one that used to reach for a
|
||||
// blocking thread this host does not have.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
@@ -235,3 +438,64 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
}
|
||||
|
||||
/// Auth form values may contain templates, and a plugin must never see one
|
||||
/// unrendered. The rendering happens in the shared handler, so this checks the
|
||||
/// host received a resolved value rather than `${[ ... ]}`.
|
||||
#[tokio::test]
|
||||
async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_get_http_authentication_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdGetHttpAuthenticationConfigReq {
|
||||
auth_name: "basic".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.auth_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("password"), Some(JsonPrimitive::String(v)) if v == "s3cret"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -225,7 +225,6 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
Generated
+10
@@ -2,3 +2,13 @@
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
|
||||
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
|
||||
|
||||
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
|
||||
|
||||
@@ -1677,6 +1677,13 @@ pub struct HttpResponse {
|
||||
pub workspace_id: String,
|
||||
pub request_id: String,
|
||||
|
||||
/// Where the engine put the body, when it puts it in a file.
|
||||
///
|
||||
/// Not exported to TypeScript: a path is only meaningful to a host that
|
||||
/// has the filesystem it names, and bodies are moving off it. Read a body
|
||||
/// by response id instead — the frontend through
|
||||
/// `cmd_http_response_body_path`, plugins through `ctx.httpResponse.body`.
|
||||
#[ts(skip)]
|
||||
pub body_path: Option<String>,
|
||||
pub content_length: Option<i32>,
|
||||
pub content_length_compressed: Option<i32>,
|
||||
|
||||
@@ -85,6 +85,86 @@ pub struct BatchUpsertResult {
|
||||
pub websocket_requests: Vec<WebsocketRequest>,
|
||||
}
|
||||
|
||||
/// Where a staged import will be committed.
|
||||
///
|
||||
/// The current workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
/// the exact destination that confirmation will use.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportDestination {
|
||||
NewWorkspace,
|
||||
CurrentWorkspace {
|
||||
#[serde(rename = "workspaceId")]
|
||||
workspace_id: String,
|
||||
#[serde(rename = "folderId")]
|
||||
#[ts(optional)]
|
||||
folder_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A model staged for import.
|
||||
///
|
||||
/// `source_key` is intentionally part of the plan boundary even though the first import slice does
|
||||
/// not persist it. Future linked imports can populate it without changing how plans contain models.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct PlannedImportResource<T> {
|
||||
#[ts(optional)]
|
||||
pub source_key: Option<String>,
|
||||
pub resource: T,
|
||||
}
|
||||
|
||||
impl<T> PlannedImportResource<T> {
|
||||
pub fn new(resource: T) -> Self {
|
||||
Self { source_key: None, resource }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlanResources {
|
||||
pub workspaces: Vec<PlannedImportResource<Workspace>>,
|
||||
pub environments: Vec<PlannedImportResource<Environment>>,
|
||||
pub folders: Vec<PlannedImportResource<Folder>>,
|
||||
pub http_requests: Vec<PlannedImportResource<HttpRequest>>,
|
||||
pub grpc_requests: Vec<PlannedImportResource<GrpcRequest>>,
|
||||
pub websocket_requests: Vec<PlannedImportResource<WebsocketRequest>>,
|
||||
}
|
||||
|
||||
impl ImportPlanResources {
|
||||
pub fn into_batch(self) -> BatchUpsertResult {
|
||||
BatchUpsertResult {
|
||||
workspaces: self.workspaces.into_iter().map(|v| v.resource).collect(),
|
||||
environments: self.environments.into_iter().map(|v| v.resource).collect(),
|
||||
folders: self.folders.into_iter().map(|v| v.resource).collect(),
|
||||
http_requests: self.http_requests.into_iter().map(|v| v.resource).collect(),
|
||||
grpc_requests: self.grpc_requests.into_iter().map(|v| v.resource).collect(),
|
||||
websocket_requests: self.websocket_requests.into_iter().map(|v| v.resource).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlan {
|
||||
pub importer: String,
|
||||
pub destination: ImportDestination,
|
||||
pub resources: ImportPlanResources,
|
||||
pub warnings: Vec<ImportPlanWarning>,
|
||||
}
|
||||
|
||||
pub fn get_workspace_export_resources(
|
||||
db: &ClientDb,
|
||||
yaak_version: &str,
|
||||
|
||||
+8
-2
@@ -426,10 +426,16 @@ export type GetHttpResponseBodyInfoRequest = { responseId: string, };
|
||||
|
||||
export type GetHttpResponseBodyInfoResponse = {
|
||||
/**
|
||||
* How many bytes are actually stored, which is not necessarily what the
|
||||
* How many bytes are stored right now, which is not necessarily what the
|
||||
* `Content-Length` header claimed. Zero when the response has no body.
|
||||
*/
|
||||
contentLength: number,
|
||||
/**
|
||||
* Whether the response has finished arriving. While it has not, the body
|
||||
* keeps growing past `content_length`, and a reader that wants all of it
|
||||
* asks again.
|
||||
*/
|
||||
complete: boolean,
|
||||
/**
|
||||
* The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||
* charset.
|
||||
@@ -468,7 +474,7 @@ export type ImportRequest = { content: string, };
|
||||
|
||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportResponse = { resources: ImportResources, };
|
||||
export type ImportResponse = { importer: string, resources: ImportResources, };
|
||||
|
||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||
|
||||
|
||||
-1
@@ -224,7 +224,6 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
@@ -247,6 +247,8 @@ pub struct ImportRequest {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ImportResponse {
|
||||
/// Display name of the importer that recognized the input.
|
||||
pub importer: String,
|
||||
pub resources: ImportResources,
|
||||
}
|
||||
|
||||
@@ -1443,11 +1445,16 @@ pub struct GetHttpResponseBodyInfoRequest {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct GetHttpResponseBodyInfoResponse {
|
||||
/// How many bytes are actually stored, which is not necessarily what the
|
||||
/// How many bytes are stored right now, which is not necessarily what the
|
||||
/// `Content-Length` header claimed. Zero when the response has no body.
|
||||
#[ts(type = "number")]
|
||||
pub content_length: u64,
|
||||
|
||||
/// Whether the response has finished arriving. While it has not, the body
|
||||
/// keeps growing past `content_length`, and a reader that wants all of it
|
||||
/// asks again.
|
||||
pub complete: bool,
|
||||
|
||||
/// The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||
/// charset.
|
||||
#[ts(optional = nullable)]
|
||||
|
||||
@@ -1104,8 +1104,19 @@ impl PluginManager {
|
||||
.await?;
|
||||
|
||||
// TODO: Don't just return the first valid response
|
||||
let result = reply_events.into_iter().find_map(|e| match e.payload {
|
||||
InternalEventPayload::ImportResponse(resp) => Some(resp),
|
||||
let result = reply_events.into_iter().find_map(|e| match e {
|
||||
InternalEvent {
|
||||
plugin_name,
|
||||
payload: InternalEventPayload::ImportResponse(mut resp),
|
||||
..
|
||||
} => {
|
||||
// Older plugin runtimes do not include the importer's display name. The plugin
|
||||
// package name is still enough to identify the detected format in that case.
|
||||
if resp.importer.is_empty() {
|
||||
resp.importer = plugin_name;
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
|
||||
Vendored
+18
-3
@@ -29,8 +29,23 @@ export function boot(): Promise<void>;
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema` — that
|
||||
* crate itself pulls the git, gRPC and plugin crates for their response types
|
||||
* and cannot come to wasm, so the handful needed here are declared locally.
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
*/
|
||||
export function rpc(cmd: string, payload: any, label: string): any;
|
||||
|
||||
@@ -66,9 +66,24 @@ export function boot() {
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema` — that
|
||||
* crate itself pulls the git, gRPC and plugin crates for their response types
|
||||
* and cannot come to wasm, so the handful needed here are declared locally.
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
* @param {string} cmd
|
||||
* @param {any} payload
|
||||
* @param {string} label
|
||||
@@ -674,7 +689,7 @@ export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha579407f9663b071);
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
@@ -731,8 +746,8 @@ function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -17,7 +17,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha579407f9663b071: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
|
||||
|
||||
@@ -21,5 +21,6 @@ yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
+770
-73
@@ -1,129 +1,826 @@
|
||||
use crate::Result;
|
||||
use log::info;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
|
||||
WebsocketRequest, Workspace,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanResources, ImportPlanWarning,
|
||||
PlannedImportResource, UpdateSource,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub struct ImportDataParams<'a> {
|
||||
pub struct PlanImportDataParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub plugin_manager: &'a PluginManager,
|
||||
pub plugin_context: &'a PluginContext,
|
||||
pub workspace_context: WorkspaceContext,
|
||||
pub destination: ImportDestination,
|
||||
pub contents: &'a str,
|
||||
}
|
||||
|
||||
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
|
||||
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
|
||||
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
|
||||
let import_result =
|
||||
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
||||
|
||||
import_resources(params.query_manager, params.workspace_context, import_result.resources)
|
||||
plan_import_resources(
|
||||
params.query_manager,
|
||||
import_result.importer,
|
||||
params.destination,
|
||||
import_result.resources,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn import_resources(
|
||||
/// Remap parsed importer resources into their selected destination.
|
||||
///
|
||||
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
|
||||
/// existing model and also makes the plan safe to inspect before it is committed.
|
||||
pub fn plan_import_resources(
|
||||
query_manager: &QueryManager,
|
||||
workspace_context: WorkspaceContext,
|
||||
importer: String,
|
||||
destination: ImportDestination,
|
||||
resources: ImportResources,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
) -> Result<ImportPlan> {
|
||||
let mut warnings = Vec::new();
|
||||
validate_destination(query_manager, &destination)?;
|
||||
|
||||
let workspaces: Vec<Workspace> = resources
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
|
||||
let mut folder_ids = BTreeMap::new();
|
||||
for folder in &resources.folders {
|
||||
folder_ids.insert(folder.id.clone(), Folder::generate_id());
|
||||
}
|
||||
|
||||
let environments: Vec<Environment> = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||
("folder", Some(parent_id)) => {
|
||||
v.parent_id =
|
||||
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
|
||||
}
|
||||
("", _) => {
|
||||
v.parent_model = "workspace".to_string();
|
||||
}
|
||||
_ => {
|
||||
v.parent_id = None;
|
||||
}
|
||||
};
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let mut workspace_ids = BTreeMap::new();
|
||||
let mut workspaces = Vec::new();
|
||||
let (default_workspace_id, target_folder_id) = match &destination {
|
||||
ImportDestination::NewWorkspace => {
|
||||
for source in &resources.workspaces {
|
||||
let mut workspace = source.clone();
|
||||
workspace.id = Workspace::generate_id();
|
||||
workspace_ids.insert(source.id.clone(), workspace.id.clone());
|
||||
workspaces.push(PlannedImportResource::new(workspace));
|
||||
}
|
||||
|
||||
let folders: Vec<Folder> = resources
|
||||
if workspaces.is_empty() {
|
||||
let workspace = Workspace {
|
||||
id: Workspace::generate_id(),
|
||||
model: "workspace".to_string(),
|
||||
name: format!("{} Import", display_importer_name(&importer)),
|
||||
setting_follow_redirects: true,
|
||||
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_validate_certificates: true,
|
||||
setting_send_cookies: true,
|
||||
setting_store_cookies: true,
|
||||
..Default::default()
|
||||
};
|
||||
workspaces.push(PlannedImportResource::new(workspace));
|
||||
}
|
||||
|
||||
(workspaces[0].resource.id.clone(), None)
|
||||
}
|
||||
ImportDestination::CurrentWorkspace { workspace_id, folder_id } => {
|
||||
for source in &resources.workspaces {
|
||||
workspace_ids.insert(source.id.clone(), workspace_id.clone());
|
||||
}
|
||||
if !resources.workspaces.is_empty() {
|
||||
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
|
||||
let skipped_fields = resources
|
||||
.workspaces
|
||||
.iter()
|
||||
.flat_map(|source| {
|
||||
workspace_fields_not_imported(source, &destination_workspace)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !skipped_fields.is_empty() {
|
||||
let source = if resources.workspaces.len() == 1 {
|
||||
resources.workspaces[0].name.clone()
|
||||
} else {
|
||||
format!("{} imported workspaces", resources.workspaces.len())
|
||||
};
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Workspace settings skipped".to_string(),
|
||||
detail: format!("{source} · {}", display_list(&skipped_fields)),
|
||||
});
|
||||
}
|
||||
}
|
||||
(workspace_id.clone(), folder_id.clone())
|
||||
}
|
||||
};
|
||||
|
||||
let resolve_workspace_id = |source_id: &str| {
|
||||
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
|
||||
};
|
||||
|
||||
let resolve_folder_id = |source_id: Option<String>| match source_id {
|
||||
Some(source_id) if source_folder_ids.contains(&source_id) => {
|
||||
folder_ids.get(&source_id).cloned()
|
||||
}
|
||||
_ => target_folder_id.clone(),
|
||||
};
|
||||
|
||||
let folders = resources
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut folder| {
|
||||
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
|
||||
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
|
||||
folder.folder_id = resolve_folder_id(folder.folder_id);
|
||||
PlannedImportResource::new(folder)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let http_requests: Vec<HttpRequest> = resources
|
||||
let http_requests = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = HttpRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
PlannedImportResource::new(request)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grpc_requests: Vec<GrpcRequest> = resources
|
||||
let grpc_requests = resources
|
||||
.grpc_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = GrpcRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
PlannedImportResource::new(request)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||
let websocket_requests = resources
|
||||
.websocket_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = WebsocketRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
PlannedImportResource::new(request)
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Importing data");
|
||||
let importing_into_current = matches!(destination, ImportDestination::CurrentWorkspace { .. });
|
||||
let mut separated_base_environments = Vec::new();
|
||||
let mut converted_duplicate_base_environment = false;
|
||||
let mut converted_duplicate_folder_environment = false;
|
||||
let mut base_environment_workspaces = BTreeSet::new();
|
||||
let mut folder_environment_ids = BTreeSet::new();
|
||||
let environments = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut environment| {
|
||||
environment.id = Environment::generate_id();
|
||||
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
|
||||
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
|
||||
("workspace", _) if importing_into_current => {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
let source_name = environment.name.clone();
|
||||
environment.name = format!("{} (Imported)", environment.name);
|
||||
separated_base_environments.push((
|
||||
source_name,
|
||||
environment.name.clone(),
|
||||
environment.variables.len(),
|
||||
));
|
||||
}
|
||||
("workspace", _) => {
|
||||
environment.parent_id = None;
|
||||
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.name = format!("{} (Imported)", environment.name);
|
||||
converted_duplicate_base_environment = true;
|
||||
}
|
||||
}
|
||||
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
|
||||
environment.parent_id = folder_ids.get(&parent_id).cloned();
|
||||
if let Some(parent_id) = &environment.parent_id
|
||||
&& !folder_environment_ids.insert(parent_id.clone())
|
||||
{
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
converted_duplicate_folder_environment = true;
|
||||
}
|
||||
}
|
||||
("folder", _) => {
|
||||
// Never attach an imported folder environment to an existing folder: the model
|
||||
// layer permits only one and would otherwise delete the destination's value.
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
}
|
||||
("environment", _) => {
|
||||
environment.parent_id = None;
|
||||
}
|
||||
_ => {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
PlannedImportResource::new(environment)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (source_name, imported_name, variable_count) in separated_base_environments {
|
||||
let variables = if variable_count == 1 { "variable" } else { "variables" };
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environment kept separate".to_string(),
|
||||
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
|
||||
});
|
||||
}
|
||||
if converted_duplicate_base_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environments separated".to_string(),
|
||||
detail: "Only the first remains the base environment".to_string(),
|
||||
});
|
||||
}
|
||||
if converted_duplicate_folder_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Folder environments separated".to_string(),
|
||||
detail: "Only the first remains attached to each folder".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ImportPlan {
|
||||
importer,
|
||||
destination,
|
||||
resources: ImportPlanResources {
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
http_requests,
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Commit a previously prepared plan in one transaction.
|
||||
pub fn commit_import_plan(
|
||||
query_manager: &QueryManager,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
validate_plan(&plan)?;
|
||||
let resources = plan.resources.into_batch();
|
||||
|
||||
info!("Committing staged import from {}", plan.importer);
|
||||
query_manager.with_tx(|tx| {
|
||||
validate_destination_db(tx, &plan.destination)?;
|
||||
tx.batch_upsert(
|
||||
resources.workspaces,
|
||||
resources.environments,
|
||||
resources.folders,
|
||||
resources.http_requests,
|
||||
resources.grpc_requests,
|
||||
resources.websocket_requests,
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.map_err(crate::Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_destination(
|
||||
query_manager: &QueryManager,
|
||||
destination: &ImportDestination,
|
||||
) -> Result<()> {
|
||||
let db = query_manager.connect();
|
||||
validate_destination_db(&db, destination)
|
||||
}
|
||||
|
||||
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
|
||||
let ImportDestination::CurrentWorkspace { workspace_id, folder_id } = destination else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
db.get_workspace(workspace_id)?;
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = db.get_folder(folder_id)?;
|
||||
if folder.workspace_id != *workspace_id {
|
||||
return Err(yaak_models::error::Error::GenericError(format!(
|
||||
"Folder {folder_id} does not belong to workspace {workspace_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_plan(plan: &ImportPlan) -> Result<()> {
|
||||
let invalid = |message: String| -> Result<()> {
|
||||
Err(yaak_models::error::Error::GenericError(message).into())
|
||||
};
|
||||
|
||||
match &plan.destination {
|
||||
ImportDestination::CurrentWorkspace { workspace_id, .. } => {
|
||||
if !plan.resources.workspaces.is_empty() {
|
||||
return invalid(
|
||||
"A current-workspace import plan must not contain workspace updates"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let all_workspace_ids = plan
|
||||
.resources
|
||||
.environments
|
||||
.iter()
|
||||
.map(|v| &v.resource.workspace_id)
|
||||
.chain(plan.resources.folders.iter().map(|v| &v.resource.workspace_id))
|
||||
.chain(plan.resources.http_requests.iter().map(|v| &v.resource.workspace_id))
|
||||
.chain(plan.resources.grpc_requests.iter().map(|v| &v.resource.workspace_id))
|
||||
.chain(plan.resources.websocket_requests.iter().map(|v| &v.resource.workspace_id));
|
||||
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
|
||||
return invalid(
|
||||
"A current-workspace import plan contains resources for another workspace"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if plan.resources.environments.iter().any(|v| v.resource.parent_model == "workspace") {
|
||||
return invalid(
|
||||
"A current-workspace import plan must not replace the base environment"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ImportDestination::NewWorkspace => {
|
||||
let workspace_ids = plan
|
||||
.resources
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|v| v.resource.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if workspace_ids.is_empty() {
|
||||
return invalid("A new-workspace import plan has no workspace".to_string());
|
||||
}
|
||||
let all_workspace_ids = plan
|
||||
.resources
|
||||
.environments
|
||||
.iter()
|
||||
.map(|v| v.resource.workspace_id.as_str())
|
||||
.chain(plan.resources.folders.iter().map(|v| v.resource.workspace_id.as_str()))
|
||||
.chain(
|
||||
plan.resources.http_requests.iter().map(|v| v.resource.workspace_id.as_str()),
|
||||
)
|
||||
.chain(
|
||||
plan.resources.grpc_requests.iter().map(|v| v.resource.workspace_id.as_str()),
|
||||
)
|
||||
.chain(
|
||||
plan.resources
|
||||
.websocket_requests
|
||||
.iter()
|
||||
.map(|v| v.resource.workspace_id.as_str()),
|
||||
);
|
||||
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
|
||||
return invalid(
|
||||
"A new-workspace import plan contains resources outside its workspaces"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut base_environment_workspaces = BTreeSet::new();
|
||||
if plan.resources.environments.iter().any(|v| {
|
||||
v.resource.parent_model == "workspace"
|
||||
&& !base_environment_workspaces.insert(v.resource.workspace_id.as_str())
|
||||
}) {
|
||||
return invalid(
|
||||
"A new-workspace import plan contains multiple base environments for one workspace"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let planned_folder_ids =
|
||||
plan.resources.folders.iter().map(|v| v.resource.id.as_str()).collect::<BTreeSet<_>>();
|
||||
if plan.resources.environments.iter().any(|v| {
|
||||
v.resource.parent_model == "folder"
|
||||
&& v.resource.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
|
||||
}) {
|
||||
return invalid(
|
||||
"An import plan must not replace an existing folder environment".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_importer_name(importer: &str) -> &str {
|
||||
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
|
||||
}
|
||||
|
||||
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
|
||||
let mut fields = Vec::new();
|
||||
if source.name != destination.name {
|
||||
fields.push("workspace name");
|
||||
}
|
||||
if source.description != destination.description {
|
||||
fields.push("description");
|
||||
}
|
||||
if source.authentication != destination.authentication
|
||||
|| source.authentication_type != destination.authentication_type
|
||||
{
|
||||
fields.push("authentication");
|
||||
}
|
||||
if source.headers != destination.headers {
|
||||
fields.push("default headers");
|
||||
}
|
||||
if source.encryption_key_challenge != destination.encryption_key_challenge {
|
||||
fields.push("encryption configuration");
|
||||
}
|
||||
if source.setting_validate_certificates != destination.setting_validate_certificates {
|
||||
fields.push("certificate validation");
|
||||
}
|
||||
if source.setting_follow_redirects != destination.setting_follow_redirects {
|
||||
fields.push("redirect behavior");
|
||||
}
|
||||
if source.setting_request_timeout != destination.setting_request_timeout {
|
||||
fields.push("request timeout");
|
||||
}
|
||||
if source.setting_request_message_size != destination.setting_request_message_size {
|
||||
fields.push("request message size");
|
||||
}
|
||||
if source.setting_dns_overrides != destination.setting_dns_overrides {
|
||||
fields.push("DNS overrides");
|
||||
}
|
||||
if source.setting_send_cookies != destination.setting_send_cookies
|
||||
|| source.setting_store_cookies != destination.setting_store_cookies
|
||||
{
|
||||
fields.push("cookie behavior");
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn display_list(items: &BTreeSet<&str>) -> String {
|
||||
let items = items.iter().copied().collect::<Vec<_>>();
|
||||
match items.as_slice() {
|
||||
[] => String::new(),
|
||||
[item] => (*item).to_string(),
|
||||
[first, second] => format!("{first} and {second}"),
|
||||
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
|
||||
|
||||
fn destination_workspace() -> Workspace {
|
||||
Workspace {
|
||||
id: "wk_destination".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Destination".to_string(),
|
||||
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
|
||||
authentication_type: Some("bearer".to_string()),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Destination".to_string(),
|
||||
value: "preserved".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
setting_validate_certificates: false,
|
||||
setting_follow_redirects: false,
|
||||
setting_request_timeout: 1234,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_resources() -> ImportResources {
|
||||
ImportResources {
|
||||
workspaces: vec![Workspace {
|
||||
id: "wk_source".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Imported".to_string(),
|
||||
authentication_type: Some("basic".to_string()),
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
}],
|
||||
environments: vec![Environment {
|
||||
id: "ev_source_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "imported".to_string(),
|
||||
value: "yes".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
folders: vec![Folder {
|
||||
id: "fl_source".to_string(),
|
||||
model: "folder".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Imported Folder".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
http_requests: vec![
|
||||
HttpRequest {
|
||||
id: "rq_root".to_string(),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Root Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/root".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequest {
|
||||
id: "rq_nested".to_string(),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
folder_id: Some("fl_source".to_string()),
|
||||
name: "Nested Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/nested".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let mut destination = destination_workspace();
|
||||
let selected_folder = Folder {
|
||||
id: "fl_selected".to_string(),
|
||||
model: "folder".to_string(),
|
||||
workspace_id: destination.id.clone(),
|
||||
name: "Selected Folder".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
destination = db
|
||||
.upsert_workspace(&destination, &UpdateSource::Import)
|
||||
.expect("create destination");
|
||||
db.upsert_folder(&selected_folder, &UpdateSource::Import)
|
||||
.expect("create selected folder");
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
id: "ev_destination_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: destination.id.clone(),
|
||||
name: "Destination Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "destination".to_string(),
|
||||
value: "keep".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.expect("create base environment");
|
||||
}
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::CurrentWorkspace {
|
||||
workspace_id: destination.id.clone(),
|
||||
folder_id: Some(selected_folder.id.clone()),
|
||||
},
|
||||
imported_resources(),
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
// Planning performed only reads.
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
|
||||
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
|
||||
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
|
||||
assert_eq!(
|
||||
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
|
||||
}
|
||||
|
||||
assert!(plan.resources.workspaces.is_empty());
|
||||
assert_eq!(plan.resources.folders[0].resource.workspace_id, destination.id);
|
||||
assert_eq!(
|
||||
plan.resources.folders[0].resource.folder_id.as_deref(),
|
||||
Some(selected_folder.id.as_str())
|
||||
);
|
||||
let root_request = plan
|
||||
.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|v| v.resource.name == "Root Request")
|
||||
.expect("root request");
|
||||
assert_eq!(root_request.resource.folder_id.as_deref(), Some(selected_folder.id.as_str()));
|
||||
let nested_request = plan
|
||||
.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|v| v.resource.name == "Nested Request")
|
||||
.expect("nested request");
|
||||
assert_eq!(
|
||||
nested_request.resource.folder_id,
|
||||
Some(plan.resources.folders[0].resource.id.clone())
|
||||
);
|
||||
assert_eq!(plan.resources.environments[0].resource.parent_model, "environment");
|
||||
assert!(plan.resources.environments[0].resource.name.ends_with("(Imported)"));
|
||||
assert_eq!(plan.warnings.len(), 2);
|
||||
assert!(plan.warnings.iter().any(|warning| {
|
||||
warning.title == "Workspace settings skipped"
|
||||
&& warning.detail.starts_with("Imported ·")
|
||||
&& warning.detail.contains("authentication")
|
||||
&& warning.detail.contains("default headers")
|
||||
}));
|
||||
assert!(plan.warnings.iter().any(|warning| {
|
||||
warning.title == "Base environment kept separate"
|
||||
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
|
||||
}));
|
||||
|
||||
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
|
||||
assert!(committed.workspaces.is_empty());
|
||||
assert_eq!(committed.http_requests.len(), 2);
|
||||
assert_eq!(
|
||||
query_manager
|
||||
.connect()
|
||||
.get_workspace(&destination.id)
|
||||
.expect("get destination after commit"),
|
||||
destination
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let mut resources = imported_resources();
|
||||
resources.environments.extend([
|
||||
Environment {
|
||||
id: "ev_second_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Second Base".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
Environment {
|
||||
id: "ev_folder_one".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Folder One".to_string(),
|
||||
parent_model: "folder".to_string(),
|
||||
parent_id: Some("fl_source".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Environment {
|
||||
id: "ev_folder_two".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Folder Two".to_string(),
|
||||
parent_model: "folder".to_string(),
|
||||
parent_id: Some("fl_source".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
]);
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"Yaak".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
resources,
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
assert_eq!(
|
||||
plan.resources
|
||||
.environments
|
||||
.iter()
|
||||
.filter(|v| v.resource.parent_model == "workspace")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resources
|
||||
.environments
|
||||
.iter()
|
||||
.filter(|v| v.resource.parent_model == "folder")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(plan.warnings.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importer_id_conventions_all_flow_through_the_same_planner() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let destination = destination_workspace();
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(&destination, &UpdateSource::Import)
|
||||
.expect("create destination");
|
||||
let resources = ImportResources {
|
||||
workspaces: vec![
|
||||
Workspace {
|
||||
id: "GENERATE_ID::WORKSPACE_0".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Generated ID Importer".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
Workspace {
|
||||
id: "wk_exported".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Stable ID Importer".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
http_requests: [
|
||||
"GENERATE_ID::WORKSPACE_0",
|
||||
"wk_exported",
|
||||
"CURRENT_WORKSPACE",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, workspace_id)| HttpRequest {
|
||||
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: format!("Request {index}"),
|
||||
method: "GET".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"Compatibility".to_string(),
|
||||
ImportDestination::CurrentWorkspace {
|
||||
workspace_id: destination.id.clone(),
|
||||
folder_id: None,
|
||||
},
|
||||
resources,
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
assert!(plan.resources.workspaces.is_empty());
|
||||
assert!(
|
||||
plan.resources.http_requests.iter().all(|v| v.resource.workspace_id == destination.id)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.map(|v| v.resource.id.as_str())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
|
||||
let dir = tempfile::tempdir().expect("create temp directory");
|
||||
let db_path = dir.path().join("models.sqlite");
|
||||
let blob_path = dir.path().join("blobs.sqlite");
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported_resources(),
|
||||
)
|
||||
.expect("plan import");
|
||||
let workspace_id = plan.resources.workspaces[0].resource.id.clone();
|
||||
let environment_id = plan.resources.environments[0].resource.id.clone();
|
||||
|
||||
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
|
||||
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
|
||||
))
|
||||
.expect("install failure trigger");
|
||||
drop(connection);
|
||||
|
||||
assert!(commit_import_plan(&query_manager, plan).is_err());
|
||||
let db = query_manager.connect();
|
||||
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
|
||||
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ fn build_shared_reply(
|
||||
GetHttpResponseBodyInfoResponse {
|
||||
content_length: info.content_length,
|
||||
content_type: info.content_type,
|
||||
complete: info.complete,
|
||||
},
|
||||
),
|
||||
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
@@ -645,6 +646,7 @@ mod tests {
|
||||
Ok(ResponseBodyInfo {
|
||||
content_length: self.body.len() as u64,
|
||||
content_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use crate::error::Result;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use yaak_models::models::HttpResponseState;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
|
||||
/// The most bytes one read will hand back, however much was asked for.
|
||||
@@ -28,6 +29,9 @@ pub struct ResponseBodyInfo {
|
||||
pub content_length: u64,
|
||||
/// The response's `Content-Type` header, verbatim.
|
||||
pub content_type: Option<String>,
|
||||
/// Whether the response has finished arriving, so `content_length` is
|
||||
/// final. A body still being written grows past it.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
/// Somewhere response bodies can be read from, a window at a time.
|
||||
@@ -78,7 +82,12 @@ impl ResponseBodyStore for FileResponseBodyStore<'_> {
|
||||
None => 0,
|
||||
};
|
||||
|
||||
Ok(ResponseBodyInfo { content_length, content_type })
|
||||
Ok(ResponseBodyInfo {
|
||||
content_length,
|
||||
content_type,
|
||||
// Closed is the one terminal state: success, error, and cancel all end there.
|
||||
complete: matches!(response.state, HttpResponseState::Closed),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
@@ -192,6 +201,19 @@ mod tests {
|
||||
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_tracks_whether_the_response_has_closed() {
|
||||
let (qm, _tmp, id) = seed(Some(b"partial"));
|
||||
// Seeded responses default to Initialized: still arriving.
|
||||
assert!(!FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
|
||||
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
qm.connect().update_http_response_if_id(&response, &UpdateSource::Sync).unwrap();
|
||||
|
||||
assert!(FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_response_fails() {
|
||||
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
||||
|
||||
Generated
+2659
-4093
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -119,21 +119,23 @@
|
||||
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@yaakapp/cli": "latest",
|
||||
"@yaakapp/cli": "0.5.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"nodejs-file-downloader": "^4.13.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.5.25",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tar": "^7.5.22",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite-plus": "^0.2.1",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9",
|
||||
"vitest": "^4.1.10",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1"
|
||||
"js-yaml": "^4.3.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
|
||||
},
|
||||
"packageManager": "npm@11.11.1"
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
localFiles: true,
|
||||
timeline: true,
|
||||
multiWindow: true,
|
||||
windowChrome: true,
|
||||
plugins: true,
|
||||
encryption: true,
|
||||
updater: true,
|
||||
|
||||
@@ -255,6 +255,13 @@ export interface PlatformCapabilities {
|
||||
timeline: boolean;
|
||||
/** More than one window or tab on the same data. */
|
||||
multiWindow: boolean;
|
||||
/**
|
||||
* The page is the window's titlebar: it draws the drag region and window
|
||||
* controls, and leaves room for macOS traffic lights. False when something
|
||||
* else owns the frame around the page (a browser tab), so none of that
|
||||
* chrome should be reserved or drawn.
|
||||
*/
|
||||
windowChrome: boolean;
|
||||
/** The plugin runtime. */
|
||||
plugins: boolean;
|
||||
/** Workspace encryption backed by a key the host keeps. */
|
||||
|
||||
@@ -129,13 +129,16 @@ Reported honestly, so callers gate on the question rather than on the host:
|
||||
|
||||
| True | False |
|
||||
| --- | --- |
|
||||
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
|
||||
`multiWindow: false` means the host cannot open a *second window* on demand —
|
||||
what `cmd_new_child_window` does for Settings and workspace switching. It is not
|
||||
a claim that nothing else is looking: other tabs may well be open on the same
|
||||
worker, and it pushes every write to all of them regardless.
|
||||
|
||||
`windowChrome: false` means the browser owns the frame around the page, so the
|
||||
header draws no window controls and reserves no room for macOS traffic lights.
|
||||
|
||||
## Multiple tabs
|
||||
|
||||
Each tab mints a label at load (`tab_xxxxxxxx`) and sends it with every command;
|
||||
|
||||
@@ -261,6 +261,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
|
||||
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
|
||||
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||
|
||||
@@ -49,6 +49,9 @@ function capabilitiesFor(): PlatformCapabilities {
|
||||
// else is looking: other tabs may well be open on the same worker, and it
|
||||
// pushes every write to all of them regardless of this flag.
|
||||
multiWindow: false,
|
||||
// The browser draws the frame around the page. There are no traffic lights
|
||||
// to leave room for and no window controls to draw.
|
||||
windowChrome: false,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
|
||||
+8
-2
@@ -426,10 +426,16 @@ export type GetHttpResponseBodyInfoRequest = { responseId: string, };
|
||||
|
||||
export type GetHttpResponseBodyInfoResponse = {
|
||||
/**
|
||||
* How many bytes are actually stored, which is not necessarily what the
|
||||
* How many bytes are stored right now, which is not necessarily what the
|
||||
* `Content-Length` header claimed. Zero when the response has no body.
|
||||
*/
|
||||
contentLength: number,
|
||||
/**
|
||||
* Whether the response has finished arriving. While it has not, the body
|
||||
* keeps growing past `content_length`, and a reader that wants all of it
|
||||
* asks again.
|
||||
*/
|
||||
complete: boolean,
|
||||
/**
|
||||
* The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||
* charset.
|
||||
@@ -468,7 +474,7 @@ export type ImportRequest = { content: string, };
|
||||
|
||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportResponse = { resources: ImportResources, };
|
||||
export type ImportResponse = { importer: string, resources: ImportResources, };
|
||||
|
||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
@@ -79,17 +79,21 @@ export interface ReadHttpResponseBodyOptions {
|
||||
/**
|
||||
* A response body, read back from wherever the host stored it.
|
||||
*
|
||||
* The accessors are named after `fetch`'s, but unlike `fetch` the body is not
|
||||
* used up by reading it: these bytes are in durable storage, so every accessor
|
||||
* can be called as many times as you like, in any order.
|
||||
* The accessors are named after `fetch`'s and behave the same way against a
|
||||
* response that is still arriving: they wait for the rest, and one that never
|
||||
* finishes is never finished reading — `chunks()` is the way to consume that.
|
||||
* Unlike `fetch`, the body is not used up by reading it: the bytes are in
|
||||
* durable storage, so every accessor can be called as many times as you like,
|
||||
* in any order.
|
||||
*/
|
||||
export interface HttpResponseBody {
|
||||
/** The response these bytes belong to. */
|
||||
readonly responseId: string;
|
||||
|
||||
/**
|
||||
* How many bytes are stored, which is not necessarily what the
|
||||
* `Content-Length` header claimed. Zero when the response has no body.
|
||||
* How many bytes were stored when this body was opened, which is not
|
||||
* necessarily what the `Content-Length` header claimed. Zero when the
|
||||
* response has no body. Final only if `complete`.
|
||||
*/
|
||||
readonly contentLength: number;
|
||||
|
||||
@@ -97,20 +101,30 @@ export interface HttpResponseBody {
|
||||
readonly contentType: string | null;
|
||||
|
||||
/**
|
||||
* The body decoded to a string, using the charset from `contentType` and
|
||||
* falling back to UTF-8. Throws if the body is over `maxBytes`.
|
||||
* Whether the response had finished arriving when this body was opened.
|
||||
* When false, the accessors below will wait for the rest of it.
|
||||
*/
|
||||
readonly complete: boolean;
|
||||
|
||||
/**
|
||||
* The whole body decoded to a string, using the charset from `contentType`
|
||||
* and falling back to UTF-8. Waits for a response still arriving. Throws
|
||||
* once more than `maxBytes` has been read.
|
||||
*/
|
||||
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
|
||||
|
||||
/** `text()`, parsed as JSON. */
|
||||
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
|
||||
|
||||
/** The raw bytes. Throws if the body is over `maxBytes`. */
|
||||
/** The whole body as raw bytes. Waits and throws as `text()` does. */
|
||||
arrayBuffer(options?: ReadHttpResponseBodyOptions): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* The raw bytes, a chunk at a time, so a body of any size can be read
|
||||
* without holding all of it at once. Not subject to `maxBytes`.
|
||||
*
|
||||
* Follows a response that is still arriving, yielding as it comes, and ends
|
||||
* when the response does. Break out of the loop to stop early.
|
||||
*/
|
||||
chunks(options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">): AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/ws": "^8.5.13"
|
||||
"@types/ws": "^8.5.13",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
HttpAuthenticationAction,
|
||||
HttpRequest,
|
||||
HttpRequestAction,
|
||||
HttpResponse,
|
||||
ImportResources,
|
||||
InternalEvent,
|
||||
InternalEventPayload,
|
||||
@@ -53,6 +54,21 @@ import { EventChannel } from "./EventChannel";
|
||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
|
||||
/**
|
||||
* A response as a plugin should see it.
|
||||
*
|
||||
* The host still puts `bodyPath` on the wire for its own callers, but it names
|
||||
* a file on the host's disk — meaningless to a plugin, absent once bodies move
|
||||
* off the filesystem, and impossible in a browser. Plugins address bodies by
|
||||
* response id, so drop it here rather than let one grow a dependency on it.
|
||||
*/
|
||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||
bodyPath?: string | null;
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export interface PluginWorkerData {
|
||||
bootRequest: BootRequest;
|
||||
pluginRefId: string;
|
||||
@@ -151,6 +167,7 @@ export class PluginInstance {
|
||||
if (reply != null) {
|
||||
const replyPayload: InternalEventPayload = {
|
||||
type: "import_response",
|
||||
importer: this.#mod.importer.name,
|
||||
resources: reply.resources as ImportResources,
|
||||
};
|
||||
this.#sendPayload(context, replyPayload, replyId);
|
||||
@@ -555,13 +572,17 @@ export class PluginInstance {
|
||||
return this.#sendPayload(context, { type: "empty_response" }, replyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the host and wait for its reply.
|
||||
*
|
||||
* A host that cannot answer replies with an error, which becomes a thrown
|
||||
* error here. The alternative is handing back a reply-shaped object with
|
||||
* none of the fields the caller destructures, and letting it fail somewhere
|
||||
* further along with no idea why.
|
||||
*/
|
||||
#sendForReply<T extends Omit<InternalEventPayload, "type">>(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
// Off by default because a reply-shaped object with none of the expected
|
||||
// fields is what every existing caller already copes with; turning it on
|
||||
// for a new call is how that stops spreading.
|
||||
{ throwOnError = false }: { throwOnError?: boolean } = {},
|
||||
): Promise<T> {
|
||||
// 1. Build event to send
|
||||
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||
@@ -572,8 +593,9 @@ export class PluginInstance {
|
||||
if (event.replyId === eventToSend.id) {
|
||||
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
|
||||
const { type: _, ...payload } = event.payload;
|
||||
if (throwOnError && event.payload.type === "error_response") {
|
||||
reject(new Error(String((payload as { error?: string }).error ?? "Unknown error")));
|
||||
if (event.payload.type === "error_response") {
|
||||
const { error } = payload as { error?: string };
|
||||
reject(new Error(error || `Host failed to handle ${eventToSend.payload.type}`));
|
||||
return;
|
||||
}
|
||||
resolve(payload as T);
|
||||
@@ -609,24 +631,30 @@ export class PluginInstance {
|
||||
}
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
/** Read a body the host has stored, a chunk at a time. */
|
||||
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
||||
const storedBody = async (responseId: string) => {
|
||||
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
|
||||
context,
|
||||
{ type: "get_http_response_body_info_request", responseId },
|
||||
{ throwOnError: true },
|
||||
);
|
||||
const bodyInfo = () =>
|
||||
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
|
||||
type: "get_http_response_body_info_request",
|
||||
responseId,
|
||||
});
|
||||
const info = await bodyInfo();
|
||||
|
||||
return createResponseBody(
|
||||
{ responseId, contentLength: info.contentLength, contentType: info.contentType ?? null },
|
||||
{
|
||||
responseId,
|
||||
contentLength: info.contentLength,
|
||||
contentType: info.contentType ?? null,
|
||||
complete: info.complete,
|
||||
},
|
||||
async (offset, length) => {
|
||||
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
||||
context,
|
||||
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
||||
{ throwOnError: true },
|
||||
);
|
||||
return decodeBase64Chunk(chunk.data);
|
||||
},
|
||||
{ refresh: bodyInfo },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -781,7 +809,7 @@ export class PluginInstance {
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpResponses;
|
||||
return httpResponses.map(forPlugin);
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
@@ -818,21 +846,18 @@ export class PluginInstance {
|
||||
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
// A failed send has no response to hand back, and reading `.body`
|
||||
// off nothing would bury the host's reason for failing.
|
||||
{ throwOnError: true },
|
||||
);
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply
|
||||
// carries the only copy of its body. A saved one is read back from
|
||||
// the host like any other. Callers get the same thing either way.
|
||||
if (body == null) {
|
||||
return { httpResponse, body: await storedBody(httpResponse.id) };
|
||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse,
|
||||
httpResponse: forPlugin(httpResponse),
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
@@ -840,6 +865,8 @@ export class PluginInstance {
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
|
||||
?.value ?? null,
|
||||
// The host waited for the whole send before replying.
|
||||
complete: true,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,9 @@ const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
||||
*/
|
||||
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** How long to wait, having caught up with a body still arriving, before looking again. */
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
/** Fetch one window of body bytes from the host. */
|
||||
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
@@ -20,26 +23,65 @@ export interface ResponseBodyInfo {
|
||||
responseId: string;
|
||||
contentLength: number;
|
||||
contentType: string | null;
|
||||
/** Whether the response has finished arriving, so `contentLength` is final. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** What can change while a body is still arriving. */
|
||||
export type ResponseBodyProgress = Pick<ResponseBodyInfo, "contentLength" | "complete">;
|
||||
|
||||
export interface CreateResponseBodyOptions {
|
||||
/**
|
||||
* Ask the host where the body has got to. Needed only for a body that was
|
||||
* not complete when opened; a reader that has caught up calls this to learn
|
||||
* whether to wait for more or stop.
|
||||
*/
|
||||
refresh?: () => Promise<ResponseBodyProgress>;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function createResponseBody(
|
||||
info: ResponseBodyInfo,
|
||||
readChunk: ReadResponseBodyChunk,
|
||||
{ refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS }: CreateResponseBodyOptions = {},
|
||||
): HttpResponseBody {
|
||||
const { responseId, contentLength, contentType } = info;
|
||||
const { responseId, contentLength, contentType, complete } = info;
|
||||
|
||||
/**
|
||||
* Yield the body from the start until it has all arrived.
|
||||
*
|
||||
* A complete body is read up to its known length and no further. One still
|
||||
* arriving is followed: on catching up, ask the host whether it has finished,
|
||||
* and if not, wait and look again. So this ends when the response does —
|
||||
* which for a stream that never closes means it doesn't, exactly as
|
||||
* iterating `fetch`'s body would not.
|
||||
*/
|
||||
async function* chunks(
|
||||
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
|
||||
let known = contentLength;
|
||||
let done = complete;
|
||||
let offset = 0;
|
||||
// Bounded by the length the host reported, but a short read still ends it:
|
||||
// the body may have been rewritten between the two calls.
|
||||
while (offset < contentLength) {
|
||||
const chunk = await readChunk(offset, Math.min(chunkSize, contentLength - offset));
|
||||
if (chunk.byteLength === 0) return;
|
||||
yield chunk;
|
||||
offset += chunk.byteLength;
|
||||
|
||||
while (true) {
|
||||
if (done && offset >= known) return;
|
||||
|
||||
const want = done ? Math.min(chunkSize, known - offset) : chunkSize;
|
||||
const chunk = await readChunk(offset, want);
|
||||
if (chunk.byteLength > 0) {
|
||||
yield chunk;
|
||||
offset += chunk.byteLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Caught up. A complete body that came up short simply ended sooner than
|
||||
// the host said; one still arriving needs asking about.
|
||||
if (done || refresh == null) return;
|
||||
({ contentLength: known, complete: done } = await refresh());
|
||||
if (offset < known) continue;
|
||||
if (done) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +112,7 @@ export function createResponseBody(
|
||||
responseId,
|
||||
contentLength,
|
||||
contentType,
|
||||
complete,
|
||||
chunks,
|
||||
async arrayBuffer(options) {
|
||||
const bytes = await readAll("arrayBuffer", options);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
|
||||
|
||||
/** A store of bytes that records every window it was asked for. */
|
||||
/** A finished body, in a store that records every window it was asked for. */
|
||||
function fakeBody(bytes: Uint8Array, contentType: string | null) {
|
||||
const reads: Array<[number, number]> = [];
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType },
|
||||
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType, complete: true },
|
||||
async (offset, length) => {
|
||||
reads.push([offset, length]);
|
||||
return bytes.slice(offset, offset + length);
|
||||
@@ -14,6 +14,39 @@ function fakeBody(bytes: Uint8Array, contentType: string | null) {
|
||||
return { body, reads };
|
||||
}
|
||||
|
||||
/**
|
||||
* A body still arriving: it grows by one script step every time the reader
|
||||
* asks the host where it has got to, and completes on the last step.
|
||||
*/
|
||||
function streamingBody(steps: string[], contentType = "text/plain") {
|
||||
let stored = new Uint8Array();
|
||||
let step = 0;
|
||||
let refreshes = 0;
|
||||
const advance = () => {
|
||||
if (step < steps.length) {
|
||||
const next = utf8(steps[step]!);
|
||||
const grown = new Uint8Array(stored.byteLength + next.byteLength);
|
||||
grown.set(stored);
|
||||
grown.set(next, stored.byteLength);
|
||||
stored = grown;
|
||||
step++;
|
||||
}
|
||||
return { contentLength: stored.byteLength, complete: step >= steps.length };
|
||||
};
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_live", contentLength: 0, contentType, complete: false },
|
||||
async (offset, length) => stored.slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return advance();
|
||||
},
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
);
|
||||
return { body, refreshCount: () => refreshes };
|
||||
}
|
||||
|
||||
function utf8(text: string) {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
@@ -86,7 +119,7 @@ describe("response body", () => {
|
||||
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
|
||||
// contentLength says 100; the store only ever hands back 10.
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain" },
|
||||
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain", complete: true },
|
||||
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
|
||||
);
|
||||
|
||||
@@ -109,6 +142,54 @@ describe("response body", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a response still arriving", () => {
|
||||
test("chunks() follows it until it finishes", async () => {
|
||||
const { body, refreshCount } = streamingBody(["data: 1\n", "data: 2\n", "data: 3\n"]);
|
||||
expect(body.complete).toBe(false);
|
||||
|
||||
const seen: string[] = [];
|
||||
for await (const chunk of body.chunks()) {
|
||||
seen.push(new TextDecoder().decode(chunk));
|
||||
}
|
||||
|
||||
expect(seen.join("")).toEqual("data: 1\ndata: 2\ndata: 3\n");
|
||||
// Asked once per catch-up, and stopped as soon as the host said it was done.
|
||||
expect(refreshCount()).toEqual(3);
|
||||
});
|
||||
|
||||
test("text() waits for the rest rather than returning a prefix", async () => {
|
||||
const { body } = streamingBody(['{"token":', '"abc"}']);
|
||||
expect(await body.json()).toEqual({ token: "abc" });
|
||||
});
|
||||
|
||||
test("keeps waiting through a stretch with nothing new", async () => {
|
||||
// Two refreshes report no growth before the body finally moves.
|
||||
const { body } = streamingBody(["", "", "late"]);
|
||||
expect(await body.text()).toEqual("late");
|
||||
});
|
||||
|
||||
test("still refuses to buffer past maxBytes as it streams", async () => {
|
||||
const { body } = streamingBody(["x".repeat(40), "x".repeat(40), "x".repeat(40)]);
|
||||
await expect(body.text({ maxBytes: 100 })).rejects.toThrow(/chunks\(\)/);
|
||||
});
|
||||
|
||||
test("a finished body never asks the host again", async () => {
|
||||
let refreshes = 0;
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_done", contentLength: 5, contentType: null, complete: true },
|
||||
async (offset, length) => utf8("hello").slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return { contentLength: 5, complete: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await body.text()).toEqual("hello");
|
||||
expect(refreshes).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64Chunk", () => {
|
||||
test("round-trips arbitrary bytes", () => {
|
||||
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCapability } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -31,6 +32,10 @@ export function HeaderSize({
|
||||
interfaceScale,
|
||||
}: HeaderSizeProps) {
|
||||
const isFullscreen = useIsFullscreen();
|
||||
// The header only doubles as the titlebar when the host hands the page the
|
||||
// window frame (Tauri) and the user hasn't opted for the native one. In a
|
||||
// browser tab the frame is the browser's: no controls, no traffic lights.
|
||||
const drawsWindowChrome = useCapability("windowChrome") && !useNativeTitlebar;
|
||||
const finalStyle = useMemo<CSSProperties>(() => {
|
||||
const s = { ...style };
|
||||
|
||||
@@ -38,8 +43,8 @@ export function HeaderSize({
|
||||
if (size === "md") s.minHeight = HEADER_SIZE_MD;
|
||||
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
|
||||
|
||||
if (useNativeTitlebar) {
|
||||
// No style updates when using native titlebar
|
||||
if (!drawsWindowChrome) {
|
||||
// No style updates when something else draws the titlebar
|
||||
} else if (osType === "macos") {
|
||||
if (!isFullscreen) {
|
||||
// Add large padding for window controls
|
||||
@@ -57,7 +62,7 @@ export function HeaderSize({
|
||||
interfaceScale,
|
||||
size,
|
||||
style,
|
||||
useNativeTitlebar,
|
||||
drawsWindowChrome,
|
||||
osType,
|
||||
]);
|
||||
|
||||
@@ -82,7 +87,7 @@ export function HeaderSize({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!hideControls && !useNativeTitlebar && (
|
||||
{!hideControls && drawsWindowChrome && (
|
||||
<WindowControls
|
||||
onlyX={onlyXWindowControl}
|
||||
osType={osType}
|
||||
|
||||
@@ -24,10 +24,12 @@ describe("auth-ntlm", () => {
|
||||
|
||||
test("uses NTLM challenge when Negotiate and NTLM headers are separate", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
headers: [
|
||||
{ name: "WWW-Authenticate", value: "Negotiate" },
|
||||
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
|
||||
],
|
||||
httpResponse: {
|
||||
headers: [
|
||||
{ name: "WWW-Authenticate", value: "Negotiate" },
|
||||
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const ctx = { httpRequest: { send } } as unknown as Context;
|
||||
|
||||
@@ -48,7 +50,9 @@ describe("auth-ntlm", () => {
|
||||
|
||||
test("uses NTLM challenge when auth schemes are comma-separated in one header", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
|
||||
httpResponse: {
|
||||
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
|
||||
},
|
||||
});
|
||||
const ctx = { httpRequest: { send } } as unknown as Context;
|
||||
|
||||
@@ -68,7 +72,7 @@ describe("auth-ntlm", () => {
|
||||
|
||||
test("throws a clear error when NTLM challenge is missing", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
headers: [{ name: "WWW-Authenticate", value: "Negotiate" }],
|
||||
httpResponse: { headers: [{ name: "WWW-Authenticate", value: "Negotiate" }] },
|
||||
});
|
||||
const ctx = { httpRequest: { send } } as unknown as Context;
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ export async function fetchAccessToken(
|
||||
throw new Error(`Failed to fetch access token: ${resp.error}`);
|
||||
}
|
||||
|
||||
// Empty when the response had no body, which parses to {} below.
|
||||
// A token request is sent ad-hoc, with no id, so nothing saves the response
|
||||
// and this body is the only copy of it. An empty one parses to {} below,
|
||||
// which is what reading a missing file used to give.
|
||||
const body = await responseBody.text();
|
||||
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
|
||||
@@ -84,7 +84,8 @@ export async function getOrRefreshAccessToken(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Empty when the response had no body, which parses to {} below.
|
||||
// Sent ad-hoc, so this body came back with the response rather than being
|
||||
// saved anywhere to read later.
|
||||
const body = await responseBody.text();
|
||||
|
||||
console.log("[oauth2] Got refresh token response", resp.status);
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"openapi-to-postmanv2": "^5.8.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/openapi-to-postmanv2": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
# Real-World OpenAPI Fixtures
|
||||
|
||||
These fixtures were copied from the public APIs.guru OpenAPI directory:
|
||||
|
||||
- `apis-guru.yaml`: https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml
|
||||
- `httpbin.yaml`: https://api.apis.guru/v2/specs/httpbin.org/0.9.2/openapi.yaml
|
||||
- `nasa-apod.yaml`: https://api.apis.guru/v2/specs/nasa.gov/apod/1.0.0/openapi.yaml
|
||||
- `xkcd.yaml`: https://api.apis.guru/v2/specs/xkcd.com/1.0.0/openapi.yaml
|
||||
@@ -0,0 +1,399 @@
|
||||
openapi: 3.0.0
|
||||
servers:
|
||||
- url: https://api.apis.guru/v2
|
||||
info:
|
||||
contact:
|
||||
email: mike.ralphson@gmail.com
|
||||
name: APIs.guru
|
||||
url: https://APIs.guru
|
||||
description: |
|
||||
Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
|
||||
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
|
||||
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
|
||||
license:
|
||||
name: CC0 1.0
|
||||
url: https://github.com/APIs-guru/openapi-directory#licenses
|
||||
title: APIs.guru
|
||||
version: 2.2.0
|
||||
x-apisguru-categories:
|
||||
- open_data
|
||||
- developer_tools
|
||||
x-logo:
|
||||
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_branding_logo_vertical.svg
|
||||
x-origin:
|
||||
- format: openapi
|
||||
url: https://api.apis.guru/v2/openapi.yaml
|
||||
version: "3.0"
|
||||
x-providerName: apis.guru
|
||||
x-tags:
|
||||
- API
|
||||
- Catalog
|
||||
- Directory
|
||||
- REST
|
||||
- Swagger
|
||||
- OpenAPI
|
||||
externalDocs:
|
||||
url: https://github.com/APIs-guru/openapi-directory/blob/master/API.md
|
||||
security: []
|
||||
tags:
|
||||
- description: Actions relating to APIs in the collection
|
||||
name: APIs
|
||||
paths:
|
||||
/list.json:
|
||||
get:
|
||||
description: |
|
||||
List all APIs in the directory.
|
||||
Returns links to the OpenAPI definitions for each API in the directory.
|
||||
If API exist in multiple versions `preferred` one is explicitly marked.
|
||||
Some basic info from the OpenAPI definition is cached inside each object.
|
||||
This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API.
|
||||
operationId: listAPIs
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIs"
|
||||
description: OK
|
||||
summary: List all APIs
|
||||
tags:
|
||||
- APIs
|
||||
/metrics.json:
|
||||
get:
|
||||
description: |
|
||||
Some basic metrics for the entire directory.
|
||||
Just stunning numbers to put on a front page and are intended purely for WoW effect :)
|
||||
operationId: getMetrics
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Metrics"
|
||||
description: OK
|
||||
summary: Get basic metrics
|
||||
tags:
|
||||
- APIs
|
||||
/providers.json:
|
||||
get:
|
||||
description: |
|
||||
List all the providers in the directory
|
||||
operationId: getProviders
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
minLength: 1
|
||||
type: string
|
||||
minItems: 1
|
||||
type: array
|
||||
type: object
|
||||
description: OK
|
||||
summary: List all providers
|
||||
tags:
|
||||
- APIs
|
||||
"/specs/{provider}/{api}.json":
|
||||
get:
|
||||
description: Returns the API entry for one specific version of an API where there is no serviceName.
|
||||
operationId: getAPI
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/provider"
|
||||
- $ref: "#/components/parameters/api"
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/API"
|
||||
description: OK
|
||||
summary: Retrieve one version of a particular API
|
||||
tags:
|
||||
- APIs
|
||||
"/specs/{provider}/{service}/{api}.json":
|
||||
get:
|
||||
description: Returns the API entry for one specific version of an API where there is a serviceName.
|
||||
operationId: getServiceAPI
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/provider"
|
||||
- in: path
|
||||
name: service
|
||||
required: true
|
||||
schema:
|
||||
example: graph
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
type: string
|
||||
- $ref: "#/components/parameters/api"
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/API"
|
||||
description: OK
|
||||
summary: Retrieve one version of a particular API with a serviceName.
|
||||
tags:
|
||||
- APIs
|
||||
"/{provider}.json":
|
||||
get:
|
||||
description: |
|
||||
List all APIs in the directory for a particular providerName
|
||||
Returns links to the individual API entry for each API.
|
||||
operationId: getProvider
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/provider"
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIs"
|
||||
description: OK
|
||||
summary: List all APIs for a particular provider
|
||||
tags:
|
||||
- APIs
|
||||
"/{provider}/services.json":
|
||||
get:
|
||||
description: |
|
||||
List all serviceNames in the directory for a particular providerName
|
||||
operationId: getServices
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/provider"
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
minLength: 0
|
||||
type: string
|
||||
minItems: 1
|
||||
type: array
|
||||
type: object
|
||||
description: OK
|
||||
summary: List all serviceNames for a particular provider
|
||||
tags:
|
||||
- APIs
|
||||
components:
|
||||
parameters:
|
||||
api:
|
||||
in: path
|
||||
name: api
|
||||
required: true
|
||||
schema:
|
||||
example: 2.1.0
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
type: string
|
||||
provider:
|
||||
in: path
|
||||
name: provider
|
||||
required: true
|
||||
schema:
|
||||
example: apis.guru
|
||||
maxLength: 255
|
||||
minLength: 1
|
||||
type: string
|
||||
schemas:
|
||||
API:
|
||||
additionalProperties: false
|
||||
description: Meta information about API
|
||||
properties:
|
||||
added:
|
||||
description: Timestamp when the API was first added to the directory
|
||||
format: date-time
|
||||
type: string
|
||||
preferred:
|
||||
description: Recommended version
|
||||
type: string
|
||||
versions:
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/ApiVersion"
|
||||
description: List of supported versions of the API
|
||||
minProperties: 1
|
||||
type: object
|
||||
required:
|
||||
- added
|
||||
- preferred
|
||||
- versions
|
||||
type: object
|
||||
APIs:
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/API"
|
||||
description: |
|
||||
List of API details.
|
||||
It is a JSON object with API IDs(`<provider>[:<service>]`) as keys.
|
||||
example:
|
||||
googleapis.com:drive:
|
||||
added: 2015-02-22T20:00:45.000Z
|
||||
preferred: v3
|
||||
versions:
|
||||
v2:
|
||||
added: 2015-02-22T20:00:45.000Z
|
||||
info:
|
||||
title: Drive
|
||||
version: v2
|
||||
x-apiClientRegistration:
|
||||
url: https://console.developers.google.com
|
||||
x-logo:
|
||||
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||
x-origin:
|
||||
format: google
|
||||
url: https://www.googleapis.com/discovery/v1/apis/drive/v2/rest
|
||||
version: v1
|
||||
x-preferred: false
|
||||
x-providerName: googleapis.com
|
||||
x-serviceName: drive
|
||||
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.json
|
||||
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.yaml
|
||||
updated: 2016-06-17T00:21:44.000Z
|
||||
v3:
|
||||
added: 2015-12-12T00:25:13.000Z
|
||||
info:
|
||||
title: Drive
|
||||
version: v3
|
||||
x-apiClientRegistration:
|
||||
url: https://console.developers.google.com
|
||||
x-logo:
|
||||
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||
x-origin:
|
||||
format: google
|
||||
url: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest
|
||||
version: v1
|
||||
x-preferred: true
|
||||
x-providerName: googleapis.com
|
||||
x-serviceName: drive
|
||||
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.json
|
||||
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.yaml
|
||||
updated: 2016-06-17T00:21:44.000Z
|
||||
minProperties: 1
|
||||
type: object
|
||||
ApiVersion:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
added:
|
||||
description: Timestamp when the version was added
|
||||
format: date-time
|
||||
type: string
|
||||
externalDocs:
|
||||
description: Copy of `externalDocs` section from OpenAPI definition
|
||||
minProperties: 1
|
||||
type: object
|
||||
info:
|
||||
description: Copy of `info` section from OpenAPI definition
|
||||
minProperties: 1
|
||||
type: object
|
||||
link:
|
||||
description: Link to the individual API entry for this API
|
||||
format: url
|
||||
type: string
|
||||
openapiVer:
|
||||
description: The value of the `openapi` or `swagger` property of the source definition
|
||||
type: string
|
||||
swaggerUrl:
|
||||
description: URL to OpenAPI definition in JSON format
|
||||
format: url
|
||||
type: string
|
||||
swaggerYamlUrl:
|
||||
description: URL to OpenAPI definition in YAML format
|
||||
format: url
|
||||
type: string
|
||||
updated:
|
||||
description: Timestamp when the version was updated
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- added
|
||||
- updated
|
||||
- swaggerUrl
|
||||
- swaggerYamlUrl
|
||||
- info
|
||||
- openapiVer
|
||||
type: object
|
||||
Metrics:
|
||||
additionalProperties: false
|
||||
description: List of basic metrics
|
||||
example:
|
||||
datasets: []
|
||||
fixedPct: 22
|
||||
fixes: 81119
|
||||
invalid: 598
|
||||
issues: 28
|
||||
numAPIs: 2501
|
||||
numDrivers: 10
|
||||
numEndpoints: 106448
|
||||
numProviders: 659
|
||||
numSpecs: 3329
|
||||
stars: 2429
|
||||
thisWeek:
|
||||
added: 45
|
||||
updated: 171
|
||||
unofficial: 25
|
||||
unreachable: 123
|
||||
properties:
|
||||
datasets:
|
||||
description: Data used for charting etc
|
||||
items: {}
|
||||
type: array
|
||||
fixedPct:
|
||||
description: Percentage of all APIs where auto fixes have been applied
|
||||
type: integer
|
||||
fixes:
|
||||
description: Total number of fixes applied across all APIs
|
||||
type: integer
|
||||
invalid:
|
||||
description: Number of newly invalid APIs
|
||||
type: integer
|
||||
issues:
|
||||
description: Open GitHub issues on our main repo
|
||||
type: integer
|
||||
numAPIs:
|
||||
description: Number of unique APIs
|
||||
minimum: 1
|
||||
type: integer
|
||||
numDrivers:
|
||||
description: Number of methods of API retrieval
|
||||
type: integer
|
||||
numEndpoints:
|
||||
description: Total number of endpoints inside all definitions
|
||||
minimum: 1
|
||||
type: integer
|
||||
numProviders:
|
||||
description: Number of API providers in directory
|
||||
type: integer
|
||||
numSpecs:
|
||||
description: Number of API definitions including different versions of the same API
|
||||
minimum: 1
|
||||
type: integer
|
||||
stars:
|
||||
description: GitHub stars for our main repo
|
||||
type: integer
|
||||
thisWeek:
|
||||
description: Summary totals for the last 7 days
|
||||
properties:
|
||||
added:
|
||||
description: APIs added in the last week
|
||||
type: integer
|
||||
updated:
|
||||
description: APIs updated in the last week
|
||||
type: integer
|
||||
type: object
|
||||
unofficial:
|
||||
description: Number of unofficial APIs
|
||||
type: integer
|
||||
unreachable:
|
||||
description: Number of unreachable (4XX,5XX status) APIs
|
||||
type: integer
|
||||
required:
|
||||
- numSpecs
|
||||
- numAPIs
|
||||
- numEndpoints
|
||||
type: object
|
||||
x-optic-standard: "@febf8ac6-ee67-4565-b45a-5c85a469dca7/Fz6KU3_wMIO5iJ6_VUZ30"
|
||||
x-optic-url: https://app.useoptic.com/organizations/febf8ac6-ee67-4565-b45a-5c85a469dca7/apis/_0fKWqUvhs9ssYNkq1k-c
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
openapi: 3.0.0
|
||||
servers:
|
||||
- url: https://api.nasa.gov/planetary
|
||||
- url: http://api.nasa.gov/planetary
|
||||
info:
|
||||
contact:
|
||||
email: evan.t.yates@nasa.gov
|
||||
description: This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
|
||||
license:
|
||||
name: Apache 2.0
|
||||
url: http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
title: APOD
|
||||
version: 1.0.0
|
||||
x-apisguru-categories:
|
||||
- media
|
||||
- open_data
|
||||
x-origin:
|
||||
- format: swagger
|
||||
url: https://raw.githubusercontent.com/nasa/api-docs/gh-pages/assets/json/APOD
|
||||
version: "2.0"
|
||||
x-providerName: nasa.gov
|
||||
x-serviceName: apod
|
||||
x-logo:
|
||||
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_assets_images_no-logo.svg
|
||||
tags:
|
||||
- description: An example tag
|
||||
externalDocs:
|
||||
description: Here's a link
|
||||
url: https://example.com
|
||||
name: request tag
|
||||
paths:
|
||||
/apod:
|
||||
get:
|
||||
description: Returns the picture of the day
|
||||
parameters:
|
||||
- description: The date of the APOD image to retrieve
|
||||
in: query
|
||||
name: date
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- description: Retrieve the URL for the high resolution image
|
||||
in: query
|
||||
name: hd
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
x-thing: ok
|
||||
type: array
|
||||
description: successful operation
|
||||
"400":
|
||||
description: Date must be between Jun 16, 1995 and Mar 28, 2019.
|
||||
security:
|
||||
- api_key: []
|
||||
summary: Returns images
|
||||
tags:
|
||||
- request tag
|
||||
components:
|
||||
securitySchemes:
|
||||
api_key:
|
||||
in: query
|
||||
name: api_key
|
||||
type: apiKey
|
||||
@@ -0,0 +1,78 @@
|
||||
openapi: 3.0.0
|
||||
servers:
|
||||
- url: http://xkcd.com/
|
||||
info:
|
||||
description: Webcomic of romance, sarcasm, math, and language.
|
||||
title: XKCD
|
||||
version: 1.0.0
|
||||
x-apisguru-categories:
|
||||
- media
|
||||
x-logo:
|
||||
url: https://api.apis.guru/v2/cache/logo/http_imgs.xkcd.com_static_terrible_small_logo.png
|
||||
x-origin:
|
||||
- format: openapi
|
||||
url: https://raw.githubusercontent.com/APIs-guru/unofficial_openapi_specs/master/xkcd.com/1.0.0/openapi.yaml
|
||||
version: "3.0"
|
||||
x-providerName: xkcd.com
|
||||
x-tags:
|
||||
- humor
|
||||
- comics
|
||||
x-unofficialSpec: true
|
||||
externalDocs:
|
||||
url: https://xkcd.com/json.html
|
||||
paths:
|
||||
/info.0.json:
|
||||
get:
|
||||
description: |
|
||||
Fetch current comic and metadata.
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
"*/*":
|
||||
schema:
|
||||
$ref: "#/components/schemas/comic"
|
||||
description: OK
|
||||
"/{comicId}/info.0.json":
|
||||
get:
|
||||
description: |
|
||||
Fetch comics and metadata by comic id.
|
||||
parameters:
|
||||
- in: path
|
||||
name: comicId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
"*/*":
|
||||
schema:
|
||||
$ref: "#/components/schemas/comic"
|
||||
description: OK
|
||||
components:
|
||||
schemas:
|
||||
comic:
|
||||
properties:
|
||||
alt:
|
||||
type: string
|
||||
day:
|
||||
type: string
|
||||
img:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
month:
|
||||
type: string
|
||||
news:
|
||||
type: string
|
||||
num:
|
||||
type: number
|
||||
safe_title:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
transcript:
|
||||
type: string
|
||||
year:
|
||||
type: string
|
||||
type: object
|
||||
@@ -5,7 +5,13 @@ import { convertOpenApi } from "../src";
|
||||
|
||||
describe("importer-openapi", () => {
|
||||
const p = path.join(__dirname, "fixtures");
|
||||
const fixtures = fs.readdirSync(p);
|
||||
const fixtures = fs.readdirSync(p).filter((fixture) => {
|
||||
return fs.statSync(path.join(p, fixture)).isFile();
|
||||
});
|
||||
const realWorldFixturesPath = path.join(p, "real-world");
|
||||
const realWorldFixtures = fs
|
||||
.readdirSync(realWorldFixturesPath)
|
||||
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||
|
||||
test("Maps operation description to request description", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
|
||||
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
description: "Lijst van klanten",
|
||||
description: expect.stringContaining("Lijst van klanten"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports requests directly from OpenAPI details", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Native Import Test", version: "1.0.0" },
|
||||
servers: [
|
||||
{ url: "https://api.example.com/{version}", variables: { version: { default: "v1" } } },
|
||||
],
|
||||
tags: [{ name: "accounts", description: "Account operations" }],
|
||||
paths: {
|
||||
"/accounts/{accountId}/members": {
|
||||
parameters: [
|
||||
{
|
||||
name: "accountId",
|
||||
in: "path",
|
||||
required: true,
|
||||
description: "Account identifier",
|
||||
schema: { type: "string", example: "acct_123" },
|
||||
},
|
||||
],
|
||||
post: {
|
||||
tags: ["accounts"],
|
||||
summary: "Create member",
|
||||
operationId: "createMember",
|
||||
parameters: [
|
||||
{
|
||||
name: "include",
|
||||
in: "query",
|
||||
description: "Related resources to include",
|
||||
schema: { type: "string", enum: ["roles"] },
|
||||
},
|
||||
{
|
||||
name: "X-Trace-Id",
|
||||
in: "header",
|
||||
schema: { type: "string", example: "trace-123" },
|
||||
},
|
||||
],
|
||||
security: [{ tokenAuth: [] }],
|
||||
requestBody: {
|
||||
description: "Member payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MemberInput" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": { description: "Created" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
tokenAuth: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
schemas: {
|
||||
MemberInput: {
|
||||
type: "object",
|
||||
required: ["email"],
|
||||
properties: {
|
||||
email: { type: "string", example: "me@example.com" },
|
||||
admin: { type: "boolean", default: false },
|
||||
primaryContact: { $ref: "#/components/schemas/Contact" },
|
||||
secondaryContact: { $ref: "#/components/schemas/Contact" },
|
||||
},
|
||||
},
|
||||
Contact: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", example: "Taylor" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.folders).toEqual([
|
||||
expect.objectContaining({ name: "accounts", description: "Account operations" }),
|
||||
]);
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||
}),
|
||||
]);
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Create member",
|
||||
method: "POST",
|
||||
url: "${[baseUrl]}/accounts/:accountId/members",
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: "", prefix: "Bearer" },
|
||||
bodyType: "application/json",
|
||||
body: {
|
||||
text: JSON.stringify(
|
||||
{
|
||||
email: "me@example.com",
|
||||
admin: false,
|
||||
primaryContact: { name: "Taylor" },
|
||||
secondaryContact: { name: "Taylor" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
headers: expect.arrayContaining([
|
||||
{ enabled: false, name: "X-Trace-Id", value: "trace-123" },
|
||||
{ enabled: true, name: "Content-Type", value: "application/json" },
|
||||
]),
|
||||
urlParameters: [
|
||||
{ enabled: true, name: ":accountId", value: "acct_123" },
|
||||
{ enabled: false, name: "include", value: "roles" },
|
||||
],
|
||||
description: expect.stringContaining("Operation ID: createMember"),
|
||||
}),
|
||||
]);
|
||||
expect(imported?.resources.httpRequests[0]?.description).toContain("Member payload");
|
||||
expect(imported?.resources.httpRequests[0]?.description).toContain("201: Created");
|
||||
});
|
||||
|
||||
test("Handles large schemas without the Postman converter path", async () => {
|
||||
const paths: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 500; i++) {
|
||||
paths[`/zones/{zoneId}/resources/${i}`] = {
|
||||
get: {
|
||||
tags: ["zones"],
|
||||
summary: `Read resource ${i}`,
|
||||
parameters: [
|
||||
{ name: "zoneId", in: "path", required: true, schema: { type: "string" } },
|
||||
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/Resource" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Large API", version: "1.0.0" },
|
||||
servers: [{ url: "https://api.example.com/client/v4" }],
|
||||
tags: [{ name: "zones" }],
|
||||
paths,
|
||||
components: {
|
||||
schemas: {
|
||||
Resource: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
metadata: { $ref: "#/components/schemas/Metadata" },
|
||||
},
|
||||
},
|
||||
Metadata: {
|
||||
type: "object",
|
||||
properties: {
|
||||
createdOn: { type: "string", format: "date-time" },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.length).toBe(500);
|
||||
expect(imported?.resources.httpRequests[499]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Read resource 499",
|
||||
url: "${[baseUrl]}/zones/:zoneId/resources/499",
|
||||
}),
|
||||
);
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -35,6 +229,340 @@ describe("importer-openapi", () => {
|
||||
expect(imported).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Prefers operation and path servers over the spec base URL", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Servers Test", version: "1.0.0" },
|
||||
servers: [{ url: "https://root.example.com" }],
|
||||
paths: {
|
||||
"/root": { get: { responses: {} } },
|
||||
"/path-level": {
|
||||
servers: [{ url: "https://path.example.com" }],
|
||||
get: { responses: {} },
|
||||
},
|
||||
"/operation-level": {
|
||||
servers: [{ url: "https://path.example.com" }],
|
||||
get: { servers: [{ url: "https://operation.example.com" }], responses: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.map((r) => r.url)).toEqual([
|
||||
"${[baseUrl]}/root",
|
||||
"https://path.example.com/path-level",
|
||||
"https://operation.example.com/operation-level",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports OpenAPI 3 OAuth2 flows", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "OAuth Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/a": { get: { security: [{ oauth: ["read", "write"] }], responses: {} } },
|
||||
"/b": { get: { security: [{ implicitOauth: [] }], responses: {} } },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
oauth: {
|
||||
type: "oauth2",
|
||||
flows: {
|
||||
clientCredentials: { tokenUrl: "https://example.com/token", scopes: {} },
|
||||
},
|
||||
},
|
||||
implicitOauth: {
|
||||
type: "oauth2",
|
||||
flows: {
|
||||
implicit: { authorizationUrl: "https://example.com/authorize", scopes: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "client_credentials",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
headerPrefix: "Bearer",
|
||||
scope: "read write",
|
||||
accessTokenUrl: "https://example.com/token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(imported?.resources.httpRequests[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "implicit",
|
||||
clientId: "",
|
||||
headerPrefix: "Bearer",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "Swagger OAuth Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
produces: ["application/json"],
|
||||
paths: { "/a": { get: { security: [{ oauth: ["admin"] }], responses: {} } } },
|
||||
securityDefinitions: {
|
||||
oauth: {
|
||||
type: "oauth2",
|
||||
flow: "accessCode",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
tokenUrl: "https://example.com/token",
|
||||
scopes: { admin: "Admin access" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "authorization_code",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
headerPrefix: "Bearer",
|
||||
scope: "admin",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
accessTokenUrl: "https://example.com/token",
|
||||
},
|
||||
headers: [{ enabled: true, name: "Accept", value: "application/json" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Names operations that only carry a description", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Naming Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/a": { get: { description: "Fetch the current comic.\nMore detail here.\n" } },
|
||||
"/b": { get: { description: `${"x".repeat(101)}` } },
|
||||
"/c": { get: { summary: "Explicit summary", description: "Ignored" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.map((r) => r.name)).toEqual([
|
||||
"Fetch the current comic.",
|
||||
// Too long to read as a name, so the route is clearer
|
||||
"GET /b",
|
||||
"Explicit summary",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Disambiguates requests that would share a name", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Duplicate Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/anything": {
|
||||
get: { summary: "Returns anything" },
|
||||
post: { summary: "Returns anything" },
|
||||
},
|
||||
"/unique": { get: { summary: "Stands alone" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests.map((r) => r.name)).toEqual([
|
||||
"Returns anything (GET /anything)",
|
||||
"Returns anything (POST /anything)",
|
||||
"Stands alone",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Flags deprecated operations", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Deprecated Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/old": { get: { summary: "Old", deprecated: true, description: "Use /new instead." } },
|
||||
"/new": { get: { summary: "New" } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.description).toBe(
|
||||
"Deprecated.\n\nUse /new instead.",
|
||||
);
|
||||
expect(imported?.resources.httpRequests[1]?.description).toBe("New");
|
||||
});
|
||||
|
||||
test("Derives an Accept header from OpenAPI 3 responses", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Accept Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/prefers-json": {
|
||||
get: {
|
||||
responses: {
|
||||
"200": {
|
||||
description: "ok",
|
||||
content: { "application/xml": {}, "application/json": {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Only failures describe content, so there is nothing to accept
|
||||
"/errors-only": {
|
||||
get: {
|
||||
responses: { "500": { description: "nope", content: { "application/json": {} } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
|
||||
{ enabled: true, name: "Accept", value: "application/json" },
|
||||
]);
|
||||
expect(imported?.resources.httpRequests[1]?.headers).toEqual([]);
|
||||
});
|
||||
|
||||
test("Lets an operation override a path-level parameter", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Override Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/a": {
|
||||
parameters: [
|
||||
{ name: "page", in: "query", required: false, schema: { example: "path-level" } },
|
||||
{ name: "keep", in: "query", required: true, schema: { example: "untouched" } },
|
||||
],
|
||||
get: {
|
||||
parameters: [
|
||||
// Same name and location as above, so it replaces rather than adds
|
||||
{ name: "page", in: "query", required: true, schema: { example: "operation" } },
|
||||
// Same name but a different location, so it is its own parameter
|
||||
{ name: "page", in: "header", schema: { example: "header-level" } },
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
|
||||
{ enabled: true, name: "page", value: "operation" },
|
||||
{ enabled: true, name: "keep", value: "untouched" },
|
||||
]);
|
||||
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
|
||||
{ enabled: false, name: "page", value: "header-level" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Prefers operation-level consumes for Swagger bodies", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "Consumes Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
consumes: ["application/json"],
|
||||
paths: {
|
||||
"/a": {
|
||||
post: {
|
||||
consumes: ["application/xml"],
|
||||
parameters: [{ name: "body", in: "body", schema: { type: "object" } }],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
bodyType: "application/xml",
|
||||
headers: expect.arrayContaining([
|
||||
{ enabled: true, name: "Content-Type", value: "application/xml" },
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 basic auth and cookie API keys", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "Auth Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
paths: {
|
||||
"/a": { get: { security: [{ basicAuth: [] }], responses: {} } },
|
||||
"/b": { get: { security: [{ cookieKey: [] }], responses: {} } },
|
||||
},
|
||||
securityDefinitions: {
|
||||
basicAuth: { type: "basic" },
|
||||
cookieKey: { type: "apiKey", in: "cookie", name: "session" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "basic",
|
||||
authentication: { username: "", password: "" },
|
||||
}),
|
||||
);
|
||||
// The auth plugin has no cookie location, so it becomes the Cookie header
|
||||
expect(imported?.resources.httpRequests[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "apikey",
|
||||
authentication: { location: "header", key: "Cookie", value: "session=" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Reports references that point outside the document", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "External Ref Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/a": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "./shared.yaml#/components/schemas/Foo" } },
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/b": { get: { responses: {} } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.description).toContain(
|
||||
"./shared.yaml#/components/schemas/Foo",
|
||||
);
|
||||
// The report is per-operation, so an unrelated request stays clean
|
||||
expect(imported?.resources.httpRequests[1]?.description).toBeUndefined();
|
||||
});
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
test(`Imports ${fixture}`, async () => {
|
||||
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
|
||||
@@ -46,7 +574,15 @@ describe("importer-openapi", () => {
|
||||
}),
|
||||
]);
|
||||
expect(imported?.resources.httpRequests.length).toBe(19);
|
||||
expect(imported?.resources.folders.length).toBe(7);
|
||||
expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const fixture of realWorldFixtures) {
|
||||
test(`Snapshots real-world fixture ${fixture}`, async () => {
|
||||
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
|
||||
const imported = await convertOpenApi(contents);
|
||||
expect(imported).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tar = require("tar");
|
||||
const yauzl = require("yauzl");
|
||||
|
||||
// Resolve an archive entry against destDir, refusing anything that escapes it.
|
||||
function safeJoin(destDir, entryName) {
|
||||
const root = path.resolve(destDir);
|
||||
const resolved = path.resolve(root, entryName);
|
||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
||||
throw new Error(`Archive entry escapes destination directory: ${entryName}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function extractZip(filePath, destDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(filePath, { lazyEntries: true }, (err, zip) => {
|
||||
if (err) return reject(err);
|
||||
zip.on("error", reject);
|
||||
zip.on("end", resolve);
|
||||
zip.on("entry", (entry) => {
|
||||
let dst;
|
||||
try {
|
||||
dst = safeJoin(destDir, entry.fileName);
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
|
||||
// Unix mode lives in the high 16 bits of the external attributes
|
||||
const rawMode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
||||
const isSymlink = (rawMode & 0o170000) === 0o120000;
|
||||
if (isSymlink) {
|
||||
return reject(new Error(`Refusing to extract symlink from archive: ${entry.fileName}`));
|
||||
}
|
||||
|
||||
if (entry.fileName.endsWith("/")) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
return zip.readEntry();
|
||||
}
|
||||
|
||||
zip.openReadStream(entry, (err2, stream) => {
|
||||
if (err2) return reject(err2);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
const out = fs.createWriteStream(dst);
|
||||
stream.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("close", () => {
|
||||
const mode = rawMode & 0o7777;
|
||||
if (mode !== 0) fs.chmodSync(dst, mode);
|
||||
zip.readEntry();
|
||||
});
|
||||
stream.pipe(out);
|
||||
});
|
||||
});
|
||||
zip.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a `.zip` or `.tar.gz` archive into destDir, preserving file modes.
|
||||
* Entries that would land outside destDir are rejected.
|
||||
*/
|
||||
async function extractArchive(filePath, destDir) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
if (filePath.endsWith(".zip")) {
|
||||
await extractZip(filePath, destDir);
|
||||
} else if (filePath.endsWith(".tar.gz") || filePath.endsWith(".tgz")) {
|
||||
// oxlint-disable-next-line await-thenable -- tar.x() returns a promise when `file` is set
|
||||
await tar.x({ file: filePath, cwd: destDir });
|
||||
} else {
|
||||
throw new Error(`Unsupported archive format: ${path.basename(filePath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extractArchive };
|
||||
@@ -17,7 +17,10 @@ import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const isBranchCheckout = process.argv[4] === "1";
|
||||
// Git supplies three arguments when invoking this as a post-checkout hook. When run directly,
|
||||
// assume the caller wants to configure the current worktree instead of requiring placeholder refs.
|
||||
const isManualRun = process.argv.length === 2;
|
||||
const isBranchCheckout = isManualRun || process.argv[4] === "1";
|
||||
|
||||
if (!isBranchCheckout) {
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const decompress = require("decompress");
|
||||
const Downloader = require("nodejs-file-downloader");
|
||||
const { extractArchive } = require("./extract-archive.cjs");
|
||||
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
@@ -92,7 +92,7 @@ rmSync(tmpDir, { recursive: true, force: true });
|
||||
console.log("SHA256 verified:", actualHash);
|
||||
|
||||
// Decompress to the same directory
|
||||
await decompress(filePath, tmpDir, {});
|
||||
await extractArchive(filePath, tmpDir);
|
||||
|
||||
// Copy binary
|
||||
const binSrc = path.join(tmpDir, SRC_BIN_MAP[key]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const decompress = require("decompress");
|
||||
const Downloader = require("nodejs-file-downloader");
|
||||
const { extractArchive } = require("./extract-archive.cjs");
|
||||
const path = require("node:path");
|
||||
const { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require("node:fs");
|
||||
const { execSync } = require("node:child_process");
|
||||
@@ -86,7 +86,7 @@ mkdirSync(dstDir, { recursive: true });
|
||||
console.log("SHA256 verified:", actualHash);
|
||||
|
||||
// Decompress to the same directory
|
||||
await decompress(filePath, tmpDir, {});
|
||||
await extractArchive(filePath, tmpDir);
|
||||
|
||||
// Copy binary
|
||||
cpSync(binSrc, binDst);
|
||||
|
||||
Reference in New Issue
Block a user