Compare commits

..
Author SHA1 Message Date
Gregory Schier 0f8413c441 Read OAuth 2.0 token responses from the send instead of the filesystem
Both token requests opened HttpResponse.bodyPath with readFileSync, which
ties the plugin to bodies living on a filesystem. The send now hands the body
back, so they read it from there and keep working once bodies move into the
blob DB and in the browser Worker, where there is no fs to read.

text() on a response with no body returns "", which is what the bodyPath
check produced, so an empty response still parses to {} rather than throwing.
2026-08-16 10:09:43 -07:00
Gregory Schier 0e14625e62 Hand the body back from send instead of holding it for a lookup
Holding an unsaved body against its response id let a plugin stash the id
and read it in a later call, which would throw only sometimes and only
for ad-hoc sends. Documenting that was never going to be enough.

send now returns the response and its body together, so there is nothing
to stash: an unsaved body is a value you were handed. ctx.httpResponse
.body() goes back to meaning one thing, a saved response read by id, and
refuses ids it has no row for. Reading is identical either way, so no
caller has to know which kind of send it made.
2026-08-16 10:07:44 -07:00
Gregory Schier 8c72538102 Say on send() that an unsaved response's body is call-scoped 2026-08-16 10:00:35 -07:00
Gregory Schier ffa6a610b8 Return the body of a send that saved nothing
Replaces the response-directory fallback with what the frontend already
does for ephemeral sends: the engine hands the body back, because it is
the only copy. Guessing at a file named for an id was the store reaching
around its own abstraction, and it is exactly what must not survive the
move to blob storage.

The send reply carries the bytes when the engine returned them, and the
runtime holds them for the rest of the call that sent them, so
ctx.httpResponse.body() answers for a saved and an unsaved response the
same way. Unsaved ones now report a real contentType too, taken from the
response the send already handed back.
2026-08-16 09:54:56 -07:00
Gregory Schier 8b031db685 Read bodies of responses the engine never recorded
A send with no request behind it — a plugin's ad-hoc ctx.httpRequest.send,
GraphQL introspection — gets a generated id and a body file, but no row.
Resolving purely through the database refused those, which would have
broken auth-oauth2 the moment it moved off readFileSync, since every
request it sends is ad-hoc.

The store now falls back to the response directory when there is no row,
accepting only ids shaped the way the engine generates them. Such a
response has no stored headers, so contentType is null and text()
decodes as UTF-8 — which is what the filesystem readers did anyway.
2026-08-16 09:35:03 -07:00
Gregory Schier 96c8a95094 Add a plugin API for reading HTTP response bodies
Plugins read bodies by response id through ctx.httpResponse.body()
instead of opening HttpResponse.bodyPath themselves. The accessors are
named after fetch's, minus the single-use semantics, since the bytes are
durable and re-reading should work.

Underneath is a chunked pull over the existing plugin protocol, so the
host can move bodies off the filesystem without plugins noticing.
text() now decodes with the response's charset rather than assuming
UTF-8, and the buffering accessors refuse past 32 MiB and point at
chunks().
2026-08-16 09:20:29 -07:00
67 changed files with 4760 additions and 10446 deletions
-12
View File
@@ -103,18 +103,6 @@ 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 -29
View File
@@ -249,7 +249,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.8.7",
"rand 0.8.5",
"serde",
"serde_repr",
"url",
@@ -265,7 +265,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"rand 0.9.1",
"raw-window-handle",
"serde",
"serde_repr",
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -595,15 +595,14 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -4464,7 +4463,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [
"rand 0.8.7",
"rand 0.8.5",
]
[[package]]
@@ -5024,14 +5023,15 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.81"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
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.6.1+3.6.3"
version = "300.5.0+3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
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.7",
"rand 0.8.5",
]
[[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.7",
"rand 0.8.5",
]
[[package]]
@@ -6455,9 +6455,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.7"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -6466,9 +6466,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.5"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -7348,7 +7348,7 @@ dependencies = [
"borsh",
"bytes",
"num-traits",
"rand 0.8.7",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
@@ -9509,7 +9509,7 @@ dependencies = [
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.7",
"rand 0.8.5",
"slab",
"tokio",
"tokio-util",
@@ -9726,7 +9726,7 @@ dependencies = [
"http",
"httparse",
"log 0.4.29",
"rand 0.9.5",
"rand 0.9.1",
"rustls",
"rustls-pki-types",
"sha1",
@@ -9996,7 +9996,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"rand 0.9.5",
"rand 0.9.1",
"serde",
"wasm-bindgen",
]
@@ -11241,7 +11241,7 @@ dependencies = [
"pretty_graphql",
"r2d2",
"r2d2_sqlite",
"rand 0.9.5",
"rand 0.9.1",
"reqwest 0.12.20",
"rlimit",
"serde",
@@ -11326,7 +11326,7 @@ dependencies = [
"log 0.4.29",
"oxc_resolver",
"predicates",
"rand 0.8.7",
"rand 0.8.5",
"reqwest 0.12.20",
"rolldown",
"schemars 0.8.22",
@@ -11352,6 +11352,7 @@ dependencies = [
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"serde_json",
"tempfile",
"thiserror 2.0.17",
@@ -11534,7 +11535,7 @@ dependencies = [
"csscolorparser",
"log 0.4.29",
"objc",
"rand 0.9.5",
"rand 0.9.1",
"tauri",
"tauri-plugin",
]
@@ -11576,7 +11577,7 @@ dependencies = [
"log 0.4.29",
"md5 0.7.0",
"path-slash",
"rand 0.9.5",
"rand 0.9.1",
"reqwest 0.12.20",
"serde",
"serde_json",
@@ -11754,7 +11755,7 @@ version = "0.1.0"
dependencies = [
"log 0.4.29",
"md5 0.8.0",
"rand 0.9.5",
"rand 0.9.1",
"serde",
"serde_json",
"tauri",
@@ -1,29 +1,9 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
import { parse as jsonLintParse } from "@prantlf/jsonlint";
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;
@@ -31,28 +11,34 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
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));
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 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",
},
];
// 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 [];
};
}
+1 -3
View File
@@ -333,9 +333,7 @@ export function formatHotkeyString(trigger: string): string[] {
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
// 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("↩︎");
labelParts.push("↩");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
+5 -3
View File
@@ -23,6 +23,7 @@
"@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",
@@ -53,7 +54,6 @@
"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,12 +93,14 @@
"@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.9",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"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.9"
"vite-plus": "^0.2.1"
}
}
+6 -6
View File
@@ -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
}
+4 -3
View File
@@ -6,6 +6,7 @@ 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);
@@ -42,11 +43,10 @@ export default defineConfig(async () => {
: {},
},
// The browser host runs the model layer in a worker; that bundle needs the
// same wasm handling as the main one. Top-level await needs no transform
// because the build targets esnext.
// same wasm and top-level-await handling as the main one.
worker: {
format: "es" as const,
plugins: () => [wasm()],
plugins: () => [wasm(), topLevelAwait()],
},
plugins: [
wasm(),
@@ -58,6 +58,7 @@ export default defineConfig(async () => {
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
+2 -2
View File
@@ -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.9",
"vite-plus": "^0.2.9"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1"
}
}
+2 -161
View File
@@ -181,11 +181,7 @@ async fn dev(args: PluginPathArg) -> CommandResult {
ui::info(&format!("Rebuilding plugin {display_path}"));
}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
// 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 {
match generate_plugin_metadata(&watch_root) {
Ok(()) => ui::success(&format!(
"Generated plugin metadata at {}",
watch_root.join("build/metadata.json").display()
@@ -412,7 +408,6 @@ 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}"))?;
@@ -503,63 +498,6 @@ 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() }]),
@@ -812,10 +750,7 @@ describe("Example Plugin", () => {
#[cfg(test)]
mod tests {
use super::{
copy_build_assets, create_publish_archive, generate_plugin_metadata,
prepare_build_output_dir,
};
use super::{create_publish_archive, generate_plugin_metadata};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
@@ -860,100 +795,6 @@ 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");
@@ -0,0 +1,12 @@
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?)
}
+17
View File
@@ -2,6 +2,7 @@ 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};
@@ -20,6 +21,22 @@ 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,3 +179,19 @@ 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))
}
+344 -17
View File
@@ -2,17 +2,18 @@ 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};
use crate::http_request::send_http_request;
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::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
use crate::render::{render_grpc_request, render_json_value, 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;
@@ -30,20 +31,26 @@ 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::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
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,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
@@ -51,9 +58,10 @@ 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};
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
mod error;
mod feedback;
@@ -212,6 +220,56 @@ 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,
@@ -238,8 +296,7 @@ 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.db(), &unrendered_request)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
@@ -299,8 +356,7 @@ 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.db(), &unrendered_request)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
@@ -1024,19 +1080,283 @@ async fn cmd_import_url<R: Runtime>(
import_url(&window, url).await
}
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.
///
@@ -1132,6 +1452,17 @@ 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>,
@@ -1451,7 +1782,6 @@ 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:?}");
@@ -1464,10 +1794,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
timeout: Some(30000),
}),
);
// 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() })
return;
}
};
+24 -7
View File
@@ -1,8 +1,25 @@
//! 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.
use serde_json::Value;
pub use yaak::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
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
}
+35 -190
View File
@@ -22,7 +22,6 @@ 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};
@@ -42,9 +41,7 @@ use yaak_models::models::{
use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
@@ -53,13 +50,11 @@ 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;
@@ -108,177 +103,27 @@ 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 handle = self.pm().get_plugin_by_dir(directory).await?;
let manager = self.window.state::<PluginManager>();
let handle = manager.get_plugin_by_dir(directory).await?;
Some(handle.info())
}
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
self.pm().take_init_errors().await
self.window.state::<PluginManager>().take_init_errors().await
}
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
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
self.window.state::<PluginManager>().resolve_plugins_for_runtime_from_db(plugins).await
}
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
let plugin_manager = Arc::new((*self.pm()).clone());
let plugin_manager = Arc::new((*self.window.state::<PluginManager>()).clone());
let encryption_manager = Arc::new(self.encryption_manager().clone());
Ok(encrypt_secure_template_function(
plugin_manager,
@@ -382,11 +227,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(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
}
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
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?)
}
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
@@ -449,68 +294,68 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).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_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_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_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_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, 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_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_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, 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_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_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, 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_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_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_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_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
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?)
}
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_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_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
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?)
}
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
}
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
}
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
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?)
}
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
}
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
@@ -529,8 +374,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(yaak_commands::actions::cmd_reload_plugins(ctx, req).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_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
@@ -573,8 +418,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(yaak_commands::templates::cmd_get_themes(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_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
+20 -4
View File
@@ -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,
WebsocketEventType, WebsocketRequest,
};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
@@ -27,7 +27,6 @@ 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>(
@@ -76,7 +75,7 @@ async fn send_websocket_message<R: Runtime>(
environment_id,
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
resolve_websocket_request(&window, &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(
@@ -155,7 +154,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.db(), &unrendered_request)?;
resolve_websocket_request(&window, &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(
@@ -455,6 +454,23 @@ 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
View File
@@ -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, 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, 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 HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
+2
View File
@@ -6,8 +6,10 @@ 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 }
-157
View File
@@ -1,157 +0,0 @@
//! 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)
}
-102
View File
@@ -1,102 +0,0 @@
//! 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)?)
}
+1 -106
View File
@@ -13,7 +13,6 @@
//! 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;
@@ -22,17 +21,8 @@ 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::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
PluginContext, RenderPurpose,
};
use yaak_plugins::events::PluginContext;
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
@@ -116,101 +106,6 @@ 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
-5
View File
@@ -11,18 +11,13 @@
//! 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};
-30
View File
@@ -1,30 +0,0 @@
//! 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
}
-56
View File
@@ -1,56 +0,0 @@
//! 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))
}
-67
View File
@@ -1,67 +0,0 @@
//! 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
}
+4 -268
View File
@@ -8,39 +8,25 @@
//! `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, Environment, EnvironmentVariable, Plugin, Workspace};
use yaak_models::models::{AnyModel, 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, CmdRenderTemplateReq, ModelsDeleteReq,
ModelsUpsertReq, ModelsWorkspaceModelsReq,
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq,
ModelsWorkspaceModelsReq,
};
use yaak_templates::TemplateCallback;
#[derive(Clone)]
struct TestHost {
@@ -169,9 +155,6 @@ 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 {
@@ -200,32 +183,6 @@ 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.
@@ -247,136 +204,12 @@ 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")),
auth_values: Rc::new(RefCell::new(None)),
};
let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")) };
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
@@ -394,42 +227,6 @@ 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");
@@ -438,64 +235,3 @@ 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
View File
@@ -225,6 +225,7 @@ export type HttpResponse = {
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
-7
View File
@@ -1677,13 +1677,6 @@ 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>,
+1 -7
View File
@@ -426,16 +426,10 @@ export type GetHttpResponseBodyInfoRequest = { responseId: string, };
export type GetHttpResponseBodyInfoResponse = {
/**
* How many bytes are stored right now, which is not necessarily what the
* How many bytes are actually stored, 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.
+1
View File
@@ -224,6 +224,7 @@ export type HttpResponse = {
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
+1 -6
View File
@@ -1443,16 +1443,11 @@ pub struct GetHttpResponseBodyInfoRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetHttpResponseBodyInfoResponse {
/// How many bytes are stored right now, which is not necessarily what the
/// How many bytes are actually stored, 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)]
+3 -18
View File
@@ -29,23 +29,8 @@ 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`, 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.
* 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.
*/
export function rpc(cmd: string, payload: any, label: string): any;
+6 -21
View File
@@ -66,24 +66,9 @@ 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`, 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.
* 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.
* @param {string} cmd
* @param {any} payload
* @param {string} label
@@ -689,7 +674,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__h7e53e249a4dc4aa9);
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha579407f9663b071);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
@@ -746,8 +731,8 @@ function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
Binary file not shown.
+1 -1
View File
@@ -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__h7e53e249a4dc4aa9: (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__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;
-2
View File
@@ -306,7 +306,6 @@ fn build_shared_reply(
GetHttpResponseBodyInfoResponse {
content_length: info.content_length,
content_type: info.content_type,
complete: info.complete,
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
@@ -646,7 +645,6 @@ mod tests {
Ok(ResponseBodyInfo {
content_length: self.body.len() as u64,
content_type: Some("text/plain; charset=utf-8".to_string()),
complete: true,
})
}
+1 -23
View File
@@ -11,7 +11,6 @@
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.
@@ -29,9 +28,6 @@ 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.
@@ -82,12 +78,7 @@ impl ResponseBodyStore for FileResponseBodyStore<'_> {
None => 0,
};
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),
})
Ok(ResponseBodyInfo { content_length, content_type })
}
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
@@ -201,19 +192,6 @@ 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"));
+4099 -2665
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -119,23 +119,21 @@
"@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": "0.5.1",
"@yaakapp/cli": "latest",
"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.9",
"vite-plus": "^0.2.9",
"vitest": "^4.1.10",
"yauzl": "^3.4.0"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1",
"vitest": "^4.1.9"
},
"overrides": {
"js-yaml": "^4.3.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
"js-yaml": "^4.1.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1"
},
"packageManager": "npm@11.11.1"
}
-1
View File
@@ -58,7 +58,6 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
localFiles: true,
timeline: true,
multiWindow: true,
windowChrome: true,
plugins: true,
encryption: true,
updater: true,
-7
View File
@@ -255,13 +255,6 @@ 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. */
+1 -4
View File
@@ -129,16 +129,13 @@ 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`, `windowChrome`, `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`, `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;
-3
View File
@@ -49,9 +49,6 @@ 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,
+1 -7
View File
@@ -426,16 +426,10 @@ export type GetHttpResponseBodyInfoRequest = { responseId: string, };
export type GetHttpResponseBodyInfoResponse = {
/**
* How many bytes are stored right now, which is not necessarily what the
* How many bytes are actually stored, 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.
@@ -224,6 +224,7 @@ export type HttpResponse = {
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
@@ -79,21 +79,17 @@ export interface ReadHttpResponseBodyOptions {
/**
* A response body, read back from wherever the host stored it.
*
* 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.
* 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.
*/
export interface HttpResponseBody {
/** The response these bytes belong to. */
readonly responseId: string;
/**
* 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`.
* How many bytes are stored, which is not necessarily what the
* `Content-Length` header claimed. Zero when the response has no body.
*/
readonly contentLength: number;
@@ -101,30 +97,20 @@ export interface HttpResponseBody {
readonly contentType: string | null;
/**
* 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.
* The body decoded to a string, using the charset from `contentType` and
* falling back to UTF-8. Throws if the body is over `maxBytes`.
*/
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
/** `text()`, parsed as JSON. */
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
/** The whole body as raw bytes. Waits and throws as `text()` does. */
/** The raw bytes. Throws if the body is over `maxBytes`. */
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>;
}
+1 -2
View File
@@ -10,7 +10,6 @@
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13",
"esbuild": "^0.28.0"
"@types/ws": "^8.5.13"
}
}
+20 -46
View File
@@ -27,7 +27,6 @@ import type {
HttpAuthenticationAction,
HttpRequest,
HttpRequestAction,
HttpResponse,
ImportResources,
InternalEvent,
InternalEventPayload,
@@ -54,21 +53,6 @@ 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;
@@ -571,17 +555,13 @@ 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);
@@ -592,9 +572,8 @@ export class PluginInstance {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
if (event.payload.type === "error_response") {
const { error } = payload as { error?: string };
reject(new Error(error || `Host failed to handle ${eventToSend.payload.type}`));
if (throwOnError && event.payload.type === "error_response") {
reject(new Error(String((payload as { error?: string }).error ?? "Unknown error")));
return;
}
resolve(payload as T);
@@ -630,30 +609,24 @@ export class PluginInstance {
}
#newCtx(context: PluginContext): Context {
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
/** Read a body the host has stored, a chunk at a time. */
const storedBody = async (responseId: string) => {
const bodyInfo = () =>
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
type: "get_http_response_body_info_request",
responseId,
});
const info = await bodyInfo();
const info = await this.#sendForReply<GetHttpResponseBodyInfoResponse>(
context,
{ type: "get_http_response_body_info_request", responseId },
{ throwOnError: true },
);
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
complete: info.complete,
},
{ responseId, contentLength: info.contentLength, contentType: info.contentType ?? null },
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 },
);
};
@@ -808,7 +781,7 @@ export class PluginInstance {
context,
payload,
);
return httpResponses.map(forPlugin);
return httpResponses;
},
body: ({ responseId }) => storedBody(responseId),
},
@@ -845,18 +818,21 @@ 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: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
return { httpResponse, body: await storedBody(httpResponse.id) };
}
const bytes = decodeBase64Chunk(body);
return {
httpResponse: forPlugin(httpResponse),
httpResponse,
body: createResponseBody(
{
responseId: httpResponse.id,
@@ -864,8 +840,6 @@ 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),
),
+8 -51
View File
@@ -13,9 +13,6 @@ 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>;
@@ -23,65 +20,26 @@ 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, complete } = info;
const { responseId, contentLength, contentType } = 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;
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));
// 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;
}
}
@@ -112,7 +70,6 @@ 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 finished body, in a store that records every window it was asked for. */
/** A store of bytes 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, complete: true },
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType },
async (offset, length) => {
reads.push([offset, length]);
return bytes.slice(offset, offset + length);
@@ -14,39 +14,6 @@ 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);
}
@@ -119,7 +86,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", complete: true },
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain" },
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
);
@@ -142,54 +109,6 @@ 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]);
+4 -9
View File
@@ -1,4 +1,3 @@
import { useCapability } from "@yaakapp-internal/platform";
import classNames from "classnames";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import { useMemo } from "react";
@@ -32,10 +31,6 @@ 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 };
@@ -43,8 +38,8 @@ export function HeaderSize({
if (size === "md") s.minHeight = HEADER_SIZE_MD;
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
if (!drawsWindowChrome) {
// No style updates when something else draws the titlebar
if (useNativeTitlebar) {
// No style updates when using native titlebar
} else if (osType === "macos") {
if (!isFullscreen) {
// Add large padding for window controls
@@ -62,7 +57,7 @@ export function HeaderSize({
interfaceScale,
size,
style,
drawsWindowChrome,
useNativeTitlebar,
osType,
]);
@@ -87,7 +82,7 @@ export function HeaderSize({
>
{children}
</div>
{!hideControls && drawsWindowChrome && (
{!hideControls && !useNativeTitlebar && (
<WindowControls
onlyX={onlyXWindowControl}
osType={osType}
+6 -10
View File
@@ -24,12 +24,10 @@ describe("auth-ntlm", () => {
test("uses NTLM challenge when Negotiate and NTLM headers are separate", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: {
headers: [
{ name: "WWW-Authenticate", value: "Negotiate" },
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
],
},
headers: [
{ name: "WWW-Authenticate", value: "Negotiate" },
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
],
});
const ctx = { httpRequest: { send } } as unknown as Context;
@@ -50,9 +48,7 @@ describe("auth-ntlm", () => {
test("uses NTLM challenge when auth schemes are comma-separated in one header", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: {
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
},
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
@@ -72,7 +68,7 @@ describe("auth-ntlm", () => {
test("throws a clear error when NTLM challenge is missing", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: { headers: [{ name: "WWW-Authenticate", value: "Negotiate" }] },
headers: [{ name: "WWW-Authenticate", value: "Negotiate" }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
+1 -3
View File
@@ -64,9 +64,7 @@ export async function fetchAccessToken(
throw new Error(`Failed to fetch access token: ${resp.error}`);
}
// 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.
// Empty when the response had no body, which parses to {} below.
const body = await responseBody.text();
if (resp.status < 200 || resp.status >= 300) {
@@ -84,8 +84,7 @@ export async function getOrRefreshAccessToken(
return null;
}
// Sent ad-hoc, so this body came back with the response rather than being
// saved anywhere to read later.
// Empty when the response had no body, which parses to {} below.
const body = await responseBody.text();
console.log("[oauth2] Got refresh token response", resp.status);
+4
View File
@@ -10,6 +10,10 @@
"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
@@ -1,8 +0,0 @@
# 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
@@ -1,399 +0,0 @@
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
@@ -1,69 +0,0 @@
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
@@ -1,78 +0,0 @@
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
+3 -539
View File
@@ -5,13 +5,7 @@ import { convertOpenApi } from "../src";
describe("importer-openapi", () => {
const p = path.join(__dirname, "fixtures");
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"));
const fixtures = fs.readdirSync(p);
test("Maps operation description to request description", async () => {
const imported = await convertOpenApi(
@@ -31,195 +25,7 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
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" }],
description: "Lijst van klanten",
}),
]);
});
@@ -229,340 +35,6 @@ 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");
@@ -574,15 +46,7 @@ describe("importer-openapi", () => {
}),
]);
expect(imported?.resources.httpRequests.length).toBe(19);
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();
expect(imported?.resources.folders.length).toBe(7);
});
}
});
-77
View File
@@ -1,77 +0,0 @@
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 };
+1 -4
View File
@@ -17,10 +17,7 @@ import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 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";
const isBranchCheckout = process.argv[4] === "1";
if (!isBranchCheckout) {
process.exit(0);
+2 -2
View File
@@ -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 extractArchive(filePath, tmpDir);
await decompress(filePath, tmpDir, {});
// Copy binary
const binSrc = path.join(tmpDir, SRC_BIN_MAP[key]);
+2 -2
View File
@@ -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 extractArchive(filePath, tmpDir);
await decompress(filePath, tmpDir, {});
// Copy binary
cpSync(binSrc, binDst);