Compare commits

..

7 Commits

Author SHA1 Message Date
Gregory Schier
1eaf276b75 release: include setup-machine signature artifact 2026-02-18 07:08:34 -08:00
Gregory Schier
e9559dfdfa Update release.yml 2026-02-18 06:40:09 -08:00
Gregory Schier
4c2e7b8609 Rename machine setup file for easier suffix matching 2026-02-18 06:36:59 -08:00
Gregory Schier
e638cecf07 release: add per-machine NSIS installer and X-Install-Mode updater header 2026-02-17 15:11:20 -08:00
Gregory Schier
076058da4f release: add per-machine NSIS installer for enterprise deployment 2026-02-17 14:49:54 -08:00
Gregory Schier
f1bc4aa146 Reapply "models: remove legacy template-function-faker plugin row"
This reverts commit 773c4a24a5.
2026-02-17 09:11:55 -08:00
Gregory Schier
773c4a24a5 Revert "models: remove legacy template-function-faker plugin row"
This reverts commit 6cc659e5c4.
2026-02-17 09:08:44 -08:00
31 changed files with 290 additions and 1587 deletions

View File

@@ -153,3 +153,24 @@ jobs:
releaseDraft: true
prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows'
shell: pwsh
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
run: |
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber

22
Cargo.lock generated
View File

@@ -1891,21 +1891,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1913,7 +1898,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1981,7 +1965,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -5267,7 +5250,6 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"chrono",
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive",
@@ -8265,7 +8247,6 @@ dependencies = [
"log 0.4.29",
"md5 0.8.0",
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tokio",
"yaak-crypto",
@@ -8356,10 +8337,8 @@ dependencies = [
"clap",
"dirs",
"env_logger",
"futures",
"log 0.4.29",
"predicates",
"schemars",
"serde",
"serde_json",
"tempfile",
@@ -8532,7 +8511,6 @@ dependencies = [
"r2d2",
"r2d2_sqlite",
"rusqlite",
"schemars",
"sea-query",
"sea-query-rusqlite",
"serde",

View File

@@ -12,9 +12,7 @@ path = "src/main.rs"
clap = { version = "4", features = ["derive"] }
dirs = "6"
env_logger = "0.11"
futures = "0.3"
log = { workspace = true }
schemars = { version = "0.8.22", features = ["chrono"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -12,23 +12,22 @@ Current branch state:
- Modular CLI structure with command modules and shared `CliContext`
- Resource/action hierarchy in place for:
- `workspace list|show|create|update|delete`
- `request list|show|create|update|send|delete|schema`
- `request list|show|create|update|send|delete`
- `folder list|show|create|update|delete`
- `environment list|show|create|update|delete`
- Top-level `send` resolves request/folder/workspace IDs and supports `--sequential|--parallel` + `--fail-fast`
- Top-level `send` exists as a request-send shortcut (not yet flexible request/folder/workspace resolution)
- Legacy `get` command removed
- JSON create/update flow implemented (`--json` and positional JSON shorthand)
- Request schema generation implemented via `schemars`, with plugin auth-field merge into `authentication`
- `request send` is polymorphic via `get_any_request`; HTTP is implemented, gRPC/WebSocket return explicit NYI errors
- No `request schema` command yet
Progress checklist:
- [x] Phase 1 complete
- [x] Phase 2 complete
- [x] Phase 3 complete
- [x] Phase 4 complete
- [x] Phase 5 complete
- [x] Phase 6 complete
- [ ] Phase 4 complete
- [ ] Phase 5 complete
- [ ] Phase 6 complete
## Command Architecture
@@ -48,7 +47,7 @@ Progress checklist:
```
# Top-level shortcut
yaakcli send <id> [--sequential|--parallel] [--fail-fast] [-e <env_id>]
yaakcli send <id> [-e <env_id>] # id can be a request, folder, or workspace
# Resource commands
yaakcli workspace list
@@ -103,8 +102,8 @@ is purely by DB lookup.
`send` means "execute this request" regardless of protocol:
- **HTTP**: send request, print response, exit
- **gRPC**: currently returns explicit "not implemented yet in yaak-cli"
- **WebSocket**: currently returns explicit "not implemented yet in yaak-cli"
- **gRPC**: invoke the method; for streaming, stream output to stdout until done/Ctrl+C
- **WebSocket**: connect, stream messages to stdout until closed/Ctrl+C
### `request schema` — Runtime JSON Schema

View File

@@ -1,4 +1,4 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
@@ -23,7 +23,7 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Send a request, folder, or workspace by ID
/// Send an HTTP request by ID
Send(SendArgs),
/// Workspace commands
@@ -41,20 +41,8 @@ pub enum Commands {
#[derive(Args)]
pub struct SendArgs {
/// Request, folder, or workspace ID
pub id: String,
/// Execute requests sequentially (default)
#[arg(long, conflicts_with = "parallel")]
pub sequential: bool,
/// Execute requests in parallel
#[arg(long, conflicts_with = "sequential")]
pub parallel: bool,
/// Stop on first request failure when sending folders/workspaces
#[arg(long, conflicts_with = "parallel")]
pub fail_fast: bool,
/// Request ID
pub request_id: String,
}
#[derive(Args)]
@@ -131,18 +119,12 @@ pub enum RequestCommands {
request_id: String,
},
/// Send a request by ID
/// Send an HTTP request by ID
Send {
/// Request ID
request_id: String,
},
/// Output JSON schema for request create/update payloads
Schema {
#[arg(value_enum)]
request_type: RequestSchemaType,
},
/// Create a new HTTP request
Create {
/// Workspace ID (or positional JSON payload shorthand)
@@ -187,13 +169,6 @@ pub enum RequestCommands {
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum RequestSchemaType {
Http,
Grpc,
Websocket,
}
#[derive(Args)]
pub struct FolderArgs {
#[command(subcommand)]

View File

@@ -51,8 +51,8 @@ fn show(ctx: &CliContext, environment_id: &str) -> CommandResult {
.db()
.get_environment(environment_id)
.map_err(|e| format!("Failed to get environment: {e}"))?;
let output = serde_json::to_string_pretty(&environment)
.map_err(|e| format!("Failed to serialize environment: {e}"))?;
let output =
serde_json::to_string_pretty(&environment).map_err(|e| format!("Failed to serialize environment: {e}"))?;
println!("{output}");
Ok(())
}
@@ -81,8 +81,9 @@ fn create(
}
validate_create_id(&payload, "environment")?;
let mut environment: Environment = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
let mut environment: Environment =
serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
if environment.workspace_id.is_empty() {
return Err("environment create JSON requires non-empty \"workspaceId\"".to_string());
@@ -104,9 +105,8 @@ fn create(
let workspace_id = workspace_id.ok_or_else(|| {
"environment create requires workspace_id unless JSON payload is provided".to_string()
})?;
let name = name.ok_or_else(|| {
"environment create requires --name unless JSON payload is provided".to_string()
})?;
let name = name
.ok_or_else(|| "environment create requires --name unless JSON payload is provided".to_string())?;
let environment = Environment {
workspace_id,

View File

@@ -31,8 +31,7 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
}
fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
let folders =
ctx.db().list_folders(workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?;
let folders = ctx.db().list_folders(workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?;
if folders.is_empty() {
println!("No folders found in workspace {}", workspace_id);
} else {
@@ -44,10 +43,9 @@ fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
}
fn show(ctx: &CliContext, folder_id: &str) -> CommandResult {
let folder =
ctx.db().get_folder(folder_id).map_err(|e| format!("Failed to get folder: {e}"))?;
let output = serde_json::to_string_pretty(&folder)
.map_err(|e| format!("Failed to serialize folder: {e}"))?;
let folder = ctx.db().get_folder(folder_id).map_err(|e| format!("Failed to get folder: {e}"))?;
let output =
serde_json::to_string_pretty(&folder).map_err(|e| format!("Failed to serialize folder: {e}"))?;
println!("{output}");
Ok(())
}
@@ -74,8 +72,8 @@ fn create(
}
validate_create_id(&payload, "folder")?;
let folder: Folder = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
let folder: Folder =
serde_json::from_value(payload).map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
if folder.workspace_id.is_empty() {
return Err("folder create JSON requires non-empty \"workspaceId\"".to_string());
@@ -90,12 +88,10 @@ fn create(
return Ok(());
}
let workspace_id = workspace_id.ok_or_else(|| {
"folder create requires workspace_id unless JSON payload is provided".to_string()
})?;
let name = name.ok_or_else(|| {
"folder create requires --name unless JSON payload is provided".to_string()
})?;
let workspace_id = workspace_id
.ok_or_else(|| "folder create requires workspace_id unless JSON payload is provided".to_string())?;
let name =
name.ok_or_else(|| "folder create requires --name unless JSON payload is provided".to_string())?;
let folder = Folder { workspace_id, name, ..Default::default() };
@@ -112,8 +108,10 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let patch = parse_required_json(json, json_input, "folder update")?;
let id = require_id(&patch, "folder update")?;
let existing =
ctx.db().get_folder(&id).map_err(|e| format!("Failed to get folder for update: {e}"))?;
let existing = ctx
.db()
.get_folder(&id)
.map_err(|e| format!("Failed to get folder for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
let saved = ctx

View File

@@ -1,19 +1,15 @@
use crate::cli::{RequestArgs, RequestCommands, RequestSchemaType};
use crate::cli::{RequestArgs, RequestCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id,
validate_create_id,
};
use schemars::schema_for;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use tokio::sync::mpsc;
use yaak::send::{SendHttpRequestByIdWithPluginsParams, send_http_request_by_id_with_plugins};
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{FormInput, FormInputBase, JsonPrimitive, PluginContext};
use yaak_plugins::events::PluginContext;
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -35,15 +31,6 @@ pub async fn run(
}
};
}
RequestCommands::Schema { request_type } => {
return match schema(ctx, request_type).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
};
}
RequestCommands::Create { workspace_id, name, method, url, json } => {
create(ctx, workspace_id, name, method, url, json)
}
@@ -75,221 +62,6 @@ fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
Ok(())
}
async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandResult {
let mut schema = match request_type {
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
RequestSchemaType::Grpc => serde_json::to_value(schema_for!(GrpcRequest))
.map_err(|e| format!("Failed to serialize gRPC request schema: {e}"))?,
RequestSchemaType::Websocket => serde_json::to_value(schema_for!(WebsocketRequest))
.map_err(|e| format!("Failed to serialize WebSocket request schema: {e}"))?,
};
if let Err(error) = merge_auth_schema_from_plugins(ctx, &mut schema).await {
eprintln!("Warning: Failed to enrich authentication schema from plugins: {error}");
}
let output = serde_json::to_string_pretty(&schema)
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
async fn merge_auth_schema_from_plugins(
ctx: &CliContext,
schema: &mut Value,
) -> Result<(), String> {
let plugin_context = PluginContext::new_empty();
let plugin_manager = ctx.plugin_manager();
let summaries = plugin_manager
.get_http_authentication_summaries(&plugin_context)
.await
.map_err(|e| e.to_string())?;
let mut auth_variants = Vec::new();
for (_, summary) in summaries {
let config = match plugin_manager
.get_http_authentication_config(
&plugin_context,
&summary.name,
HashMap::<String, JsonPrimitive>::new(),
"yaakcli_request_schema",
)
.await
{
Ok(config) => config,
Err(error) => {
eprintln!(
"Warning: Failed to load auth config for strategy '{}': {}",
summary.name, error
);
continue;
}
};
auth_variants.push(auth_variant_schema(&summary.name, &summary.label, &config.args));
}
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return Ok(());
};
let Some(auth_schema) = properties.get_mut("authentication") else {
return Ok(());
};
if !auth_variants.is_empty() {
let mut one_of = vec![auth_schema.clone()];
one_of.extend(auth_variants);
*auth_schema = json!({ "oneOf": one_of });
}
Ok(())
}
fn auth_variant_schema(auth_name: &str, auth_label: &str, args: &[FormInput]) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for input in args {
add_input_schema(input, &mut properties, &mut required);
}
let mut schema = json!({
"title": auth_label,
"description": format!("Authentication values for strategy '{}'", auth_name),
"type": "object",
"properties": properties,
"additionalProperties": true
});
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
fn add_input_schema(
input: &FormInput,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
) {
match input {
FormInput::Text(v) => add_base_schema(
&v.base,
json!({
"type": "string",
"writeOnly": v.password.unwrap_or(false),
}),
properties,
required,
),
FormInput::Editor(v) => add_base_schema(
&v.base,
json!({
"type": "string",
"x-editorLanguage": v.language.clone(),
}),
properties,
required,
),
FormInput::Select(v) => {
let options: Vec<Value> =
v.options.iter().map(|o| Value::String(o.value.clone())).collect();
add_base_schema(
&v.base,
json!({
"type": "string",
"enum": options,
}),
properties,
required,
);
}
FormInput::Checkbox(v) => {
add_base_schema(&v.base, json!({ "type": "boolean" }), properties, required);
}
FormInput::File(v) => {
if v.multiple.unwrap_or(false) {
add_base_schema(
&v.base,
json!({
"type": "array",
"items": { "type": "string" },
}),
properties,
required,
);
} else {
add_base_schema(&v.base, json!({ "type": "string" }), properties, required);
}
}
FormInput::HttpRequest(v) => {
add_base_schema(&v.base, json!({ "type": "string" }), properties, required);
}
FormInput::KeyValue(v) => {
add_base_schema(
&v.base,
json!({
"type": "object",
"additionalProperties": true,
}),
properties,
required,
);
}
FormInput::Accordion(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::HStack(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::Banner(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::Markdown(_) => {}
}
}
fn add_base_schema(
base: &FormInputBase,
mut schema: Value,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
) {
if base.hidden.unwrap_or(false) || base.name.trim().is_empty() {
return;
}
if let Some(description) = &base.description {
schema["description"] = Value::String(description.clone());
}
if let Some(label) = &base.label {
schema["title"] = Value::String(label.clone());
}
if let Some(default_value) = &base.default_value {
schema["default"] = Value::String(default_value.clone());
}
let name = base.name.clone();
properties.insert(name.clone(), schema);
if !base.optional.unwrap_or(false) {
required.push(name);
}
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
@@ -374,10 +146,12 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
}
fn show(ctx: &CliContext, request_id: &str) -> CommandResult {
let request =
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
let output = serde_json::to_string_pretty(&request)
.map_err(|e| format!("Failed to serialize request: {e}"))?;
let request = ctx
.db()
.get_http_request(request_id)
.map_err(|e| format!("Failed to get request: {e}"))?;
let output =
serde_json::to_string_pretty(&request).map_err(|e| format!("Failed to serialize request: {e}"))?;
println!("{output}");
Ok(())
}
@@ -404,35 +178,9 @@ pub async fn send_request_by_id(
verbose: bool,
) -> Result<(), String> {
let request =
ctx.db().get_any_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
match request {
AnyRequest::HttpRequest(http_request) => {
send_http_request_by_id(
ctx,
&http_request.id,
&http_request.workspace_id,
environment,
verbose,
)
.await
}
AnyRequest::GrpcRequest(_) => {
Err("gRPC request send is not implemented yet in yaak-cli".to_string())
}
AnyRequest::WebsocketRequest(_) => {
Err("WebSocket request send is not implemented yet in yaak-cli".to_string())
}
}
}
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
async fn send_http_request_by_id(
ctx: &CliContext,
request_id: &str,
workspace_id: &str,
environment: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let plugin_context = PluginContext::new(None, Some(workspace_id.to_string()));
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let (event_tx, mut event_rx) = mpsc::channel(100);
let event_handle = tokio::spawn(async move {

View File

@@ -1,12 +1,6 @@
use crate::cli::SendArgs;
use crate::commands::request;
use crate::context::CliContext;
use futures::future::join_all;
enum ExecutionMode {
Sequential,
Parallel,
}
pub async fn run(
ctx: &CliContext,
@@ -14,7 +8,7 @@ pub async fn run(
environment: Option<&str>,
verbose: bool,
) -> i32 {
match send_target(ctx, args, environment, verbose).await {
match request::send_request_by_id(ctx, &args.request_id, environment, verbose).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
@@ -22,163 +16,3 @@ pub async fn run(
}
}
}
async fn send_target(
ctx: &CliContext,
args: SendArgs,
environment: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };
if ctx.db().get_any_request(&args.id).is_ok() {
return request::send_request_by_id(ctx, &args.id, environment, verbose).await;
}
if ctx.db().get_folder(&args.id).is_ok() {
let request_ids = collect_folder_request_ids(ctx, &args.id)?;
if request_ids.is_empty() {
println!("No requests found in folder {}", args.id);
return Ok(());
}
return send_many(ctx, request_ids, mode, args.fail_fast, environment, verbose).await;
}
if ctx.db().get_workspace(&args.id).is_ok() {
let request_ids = collect_workspace_request_ids(ctx, &args.id)?;
if request_ids.is_empty() {
println!("No requests found in workspace {}", args.id);
return Ok(());
}
return send_many(ctx, request_ids, mode, args.fail_fast, environment, verbose).await;
}
Err(format!("Could not resolve ID '{}' as request, folder, or workspace", args.id))
}
fn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<String>, String> {
let mut ids = Vec::new();
let mut http_ids = ctx
.db()
.list_http_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list HTTP requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut http_ids);
let mut grpc_ids = ctx
.db()
.list_grpc_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list gRPC requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut grpc_ids);
let mut websocket_ids = ctx
.db()
.list_websocket_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list WebSocket requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut websocket_ids);
Ok(ids)
}
fn collect_workspace_request_ids(
ctx: &CliContext,
workspace_id: &str,
) -> Result<Vec<String>, String> {
let mut ids = Vec::new();
let mut http_ids = ctx
.db()
.list_http_requests(workspace_id)
.map_err(|e| format!("Failed to list HTTP requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut http_ids);
let mut grpc_ids = ctx
.db()
.list_grpc_requests(workspace_id)
.map_err(|e| format!("Failed to list gRPC requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut grpc_ids);
let mut websocket_ids = ctx
.db()
.list_websocket_requests(workspace_id)
.map_err(|e| format!("Failed to list WebSocket requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut websocket_ids);
Ok(ids)
}
async fn send_many(
ctx: &CliContext,
request_ids: Vec<String>,
mode: ExecutionMode,
fail_fast: bool,
environment: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let mut success_count = 0usize;
let mut failures: Vec<(String, String)> = Vec::new();
match mode {
ExecutionMode::Sequential => {
for request_id in request_ids {
match request::send_request_by_id(ctx, &request_id, environment, verbose).await {
Ok(()) => success_count += 1,
Err(error) => {
failures.push((request_id, error));
if fail_fast {
break;
}
}
}
}
}
ExecutionMode::Parallel => {
let tasks = request_ids
.iter()
.map(|request_id| async move {
(
request_id.clone(),
request::send_request_by_id(ctx, request_id, environment, verbose).await,
)
})
.collect::<Vec<_>>();
for (request_id, result) in join_all(tasks).await {
match result {
Ok(()) => success_count += 1,
Err(error) => failures.push((request_id, error)),
}
}
}
}
let failure_count = failures.len();
println!("Send summary: {success_count} succeeded, {failure_count} failed");
if failure_count == 0 {
return Ok(());
}
for (request_id, error) in failures {
eprintln!(" {}: {}", request_id, error);
}
Err("One or more requests failed".to_string())
}

View File

@@ -28,8 +28,7 @@ pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
}
fn list(ctx: &CliContext) -> CommandResult {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
let workspaces = ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
println!("No workspaces found");
} else {
@@ -76,9 +75,8 @@ fn create(
return Ok(());
}
let name = name.ok_or_else(|| {
"workspace create requires --name unless JSON payload is provided".to_string()
})?;
let name =
name.ok_or_else(|| "workspace create requires --name unless JSON payload is provided".to_string())?;
let workspace = Workspace { name, ..Default::default() };
let created = ctx

View File

@@ -1,7 +1,5 @@
use crate::plugin_events::CliPluginEventBridge;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_crypto::manager::EncryptionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::db_context::DbContext;
@@ -15,7 +13,6 @@ pub struct CliContext {
blob_manager: BlobManager,
pub encryption_manager: Arc<EncryptionManager>,
plugin_manager: Option<Arc<PluginManager>>,
plugin_event_bridge: Mutex<Option<CliPluginEventBridge>>,
}
impl CliContext {
@@ -68,20 +65,7 @@ impl CliContext {
None
};
let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager {
Some(CliPluginEventBridge::start(plugin_manager.clone(), query_manager.clone()).await)
} else {
None
};
Self {
data_dir,
query_manager,
blob_manager,
encryption_manager,
plugin_manager,
plugin_event_bridge: Mutex::new(plugin_event_bridge),
}
Self { data_dir, query_manager, blob_manager, encryption_manager, plugin_manager }
}
pub fn data_dir(&self) -> &Path {
@@ -106,9 +90,6 @@ impl CliContext {
pub async fn shutdown(&self) {
if let Some(plugin_manager) = &self.plugin_manager {
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
plugin_event_bridge.shutdown(plugin_manager).await;
}
plugin_manager.terminate().await;
}
}

View File

@@ -1,7 +1,6 @@
mod cli;
mod commands;
mod context;
mod plugin_events;
mod utils;
use clap::Parser;
@@ -25,9 +24,7 @@ async fn main() {
let needs_plugins = matches!(
&command,
Commands::Send(_)
| Commands::Request(cli::RequestArgs {
command: RequestCommands::Send { .. } | RequestCommands::Schema { .. },
})
| Commands::Request(cli::RequestArgs { command: RequestCommands::Send { .. } })
);
let context = CliContext::initialize(data_dir, app_id, needs_plugins).await;

View File

@@ -1,212 +0,0 @@
use std::sync::Arc;
use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedEvent, SharedPluginEventContext,
handle_shared_plugin_event,
};
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse,
WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
pub struct CliPluginEventBridge {
rx_id: String,
task: JoinHandle<()>,
}
impl CliPluginEventBridge {
pub async fn start(plugin_manager: Arc<PluginManager>, query_manager: QueryManager) -> Self {
let (rx_id, mut rx) = plugin_manager.subscribe("cli").await;
let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.clone();
let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
// Events with reply IDs are replies to app-originated requests.
if event.reply_id.is_some() {
continue;
}
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
else {
eprintln!(
"Warning: Ignoring plugin event with unknown plugin ref '{}'",
event.plugin_ref_id
);
continue;
};
let plugin_name = plugin_handle.info().name;
let Some(reply_payload) = build_plugin_reply(&query_manager, &event, &plugin_name)
else {
continue;
};
if let Err(err) = pm.reply(&event, &reply_payload).await {
eprintln!("Warning: Failed replying to plugin event: {err}");
}
}
pm.unsubscribe(&rx_id_for_task).await;
});
Self { rx_id, task }
}
pub async fn shutdown(self, plugin_manager: &PluginManager) {
plugin_manager.unsubscribe(&self.rx_id).await;
self.task.abort();
let _ = self.task.await;
}
}
fn build_plugin_reply(
query_manager: &QueryManager,
event: &InternalEvent,
plugin_name: &str,
) -> Option<InternalEventPayload> {
match handle_shared_plugin_event(
query_manager,
&event.payload,
SharedPluginEventContext {
plugin_name,
workspace_id: event.context.workspace_id.as_deref(),
},
) {
GroupedPluginEvent::Shared(SharedEvent::Reply(payload)) => Some(payload),
GroupedPluginEvent::Shared(SharedEvent::ErrorResponse(resp)) => {
eprintln!("[plugin:{}] error: {}", plugin_name, resp.error);
None
}
GroupedPluginEvent::Shared(SharedEvent::ReloadResponse(_)) => None,
GroupedPluginEvent::Host(HostRequest::ShowToast(req)) => {
eprintln!("[plugin:{}] {}", plugin_name, req.message);
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
}
GroupedPluginEvent::Host(HostRequest::ListOpenWorkspaces(_)) => {
let workspaces = match query_manager.connect().list_workspaces() {
Ok(workspaces) => workspaces
.into_iter()
.map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id })
.collect(),
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list workspaces in CLI: {err}"),
}));
}
};
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
workspaces,
}))
}
GroupedPluginEvent::Host(req) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Unsupported plugin request in CLI: {}", req.type_name()),
})),
GroupedPluginEvent::Ignore => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use yaak_plugins::events::{GetKeyValueRequest, PluginContext, WindowInfoRequest};
fn query_manager_for_test() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
let blob_path = temp_dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB");
(query_manager, temp_dir)
}
fn event(payload: InternalEventPayload) -> InternalEvent {
InternalEvent {
id: "evt_1".to_string(),
plugin_ref_id: "plugin_ref_1".to_string(),
plugin_name: "@yaak/test-plugin".to_string(),
reply_id: None,
context: PluginContext::new_empty(),
payload,
}
}
#[test]
fn key_value_requests_round_trip() {
let (query_manager, _temp_dir) = query_manager_for_test();
let plugin_name = "@yaak/test-plugin";
let get_missing = build_plugin_reply(
&query_manager,
&event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest {
key: "missing".to_string(),
})),
plugin_name,
);
match get_missing {
Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None),
other => panic!("unexpected payload for missing get: {other:?}"),
}
let set = build_plugin_reply(
&query_manager,
&event(InternalEventPayload::SetKeyValueRequest(
yaak_plugins::events::SetKeyValueRequest {
key: "token".to_string(),
value: "{\"access_token\":\"abc\"}".to_string(),
},
)),
plugin_name,
);
assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_))));
let get_present = build_plugin_reply(
&query_manager,
&event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest {
key: "token".to_string(),
})),
plugin_name,
);
match get_present {
Some(InternalEventPayload::GetKeyValueResponse(r)) => {
assert_eq!(r.value, Some("{\"access_token\":\"abc\"}".to_string()))
}
other => panic!("unexpected payload for present get: {other:?}"),
}
let delete = build_plugin_reply(
&query_manager,
&event(InternalEventPayload::DeleteKeyValueRequest(
yaak_plugins::events::DeleteKeyValueRequest { key: "token".to_string() },
)),
plugin_name,
);
match delete {
Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted),
other => panic!("unexpected payload for delete: {other:?}"),
}
}
#[test]
fn unsupported_request_gets_error_reply() {
let (query_manager, _temp_dir) = query_manager_for_test();
let payload = build_plugin_reply(
&query_manager,
&event(InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
})),
"@yaak/test-plugin",
);
match payload {
Some(InternalEventPayload::ErrorResponse(err)) => {
assert!(err.error.contains("Unsupported plugin request in CLI"));
assert!(err.error.contains("window_info_request"));
}
other => panic!("unexpected payload for unsupported request: {other:?}"),
}
}
}

View File

@@ -25,9 +25,9 @@ pub fn parse_optional_json(
context: &str,
) -> JsonResult<Option<Value>> {
match (json_flag, json_shorthand) {
(Some(_), Some(_)) => {
Err(format!("Cannot provide both --json and positional JSON for {context}"))
}
(Some(_), Some(_)) => Err(format!(
"Cannot provide both --json and positional JSON for {context}"
)),
(Some(raw), None) => parse_json_object(&raw, context).map(Some),
(None, Some(raw)) => parse_json_object(&raw, context).map(Some),
(None, None) => Ok(None),
@@ -39,8 +39,9 @@ pub fn parse_required_json(
json_shorthand: Option<String>,
context: &str,
) -> JsonResult<Value> {
parse_optional_json(json_flag, json_shorthand, context)?
.ok_or_else(|| format!("Missing JSON payload for {context}. Use --json or positional JSON"))
parse_optional_json(json_flag, json_shorthand, context)?.ok_or_else(|| {
format!("Missing JSON payload for {context}. Use --json or positional JSON")
})
}
pub fn require_id(payload: &Value, context: &str) -> JsonResult<String> {
@@ -59,7 +60,9 @@ pub fn validate_create_id(payload: &Value, context: &str) -> JsonResult<()> {
match id_value {
Value::String(id) if id.is_empty() => Ok(()),
_ => Err(format!("{context} create JSON must omit \"id\" or set it to an empty string")),
_ => Err(format!(
"{context} create JSON must omit \"id\" or set it to an empty string"
)),
}
}

View File

@@ -5,7 +5,7 @@ pub mod http_server;
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
use std::path::Path;
use yaak_models::models::{Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
use yaak_models::models::{HttpRequest, Workspace};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
@@ -60,47 +60,3 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed request");
}
pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
let folder = Folder {
id: folder_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seed Folder".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_folder(&folder, &UpdateSource::Sync)
.expect("Failed to seed folder");
}
pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
let request = GrpcRequest {
id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seeded gRPC Request".to_string(),
url: "https://example.com".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_grpc_request(&request, &UpdateSource::Sync)
.expect("Failed to seed gRPC request");
}
pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
let request = WebsocketRequest {
id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seeded WebSocket Request".to_string(),
url: "wss://example.com/socket".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_websocket_request(&request, &UpdateSource::Sync)
.expect("Failed to seed WebSocket request");
}

View File

@@ -1,10 +1,7 @@
mod common;
use common::http_server::TestHttpServer;
use common::{
cli_cmd, parse_created_id, query_manager, seed_grpc_request, seed_request,
seed_websocket_request, seed_workspace,
};
use common::{cli_cmd, parse_created_id, query_manager, seed_request, seed_workspace};
use predicates::str::contains;
use tempfile::TempDir;
use yaak_models::models::HttpResponseState;
@@ -117,7 +114,8 @@ fn create_allows_workspace_only_with_empty_defaults() {
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir).args(["request", "create", "wk_test"]).assert().success();
let create_assert =
cli_cmd(data_dir).args(["request", "create", "wk_test"]).assert().success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
let request = query_manager(data_dir)
@@ -179,46 +177,3 @@ fn request_send_persists_response_body_and_events() {
db.list_http_response_events(&response.id).expect("Failed to load response events");
assert!(!events.is_empty(), "expected at least one persisted response event");
}
#[test]
fn request_schema_http_outputs_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["request", "schema", "http"])
.assert()
.success()
.stdout(contains("\"type\": \"object\""))
.stdout(contains("\"authentication\""));
}
#[test]
fn request_send_grpc_returns_explicit_nyi_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_grpc_request(data_dir, "wk_test", "gr_seed_nyi");
cli_cmd(data_dir)
.args(["request", "send", "gr_seed_nyi"])
.assert()
.failure()
.code(1)
.stderr(contains("gRPC request send is not implemented yet in yaak-cli"));
}
#[test]
fn request_send_websocket_returns_explicit_nyi_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_websocket_request(data_dir, "wk_test", "wr_seed_nyi");
cli_cmd(data_dir)
.args(["request", "send", "wr_seed_nyi"])
.assert()
.failure()
.code(1)
.stderr(contains("WebSocket request send is not implemented yet in yaak-cli"));
}

View File

@@ -1,81 +0,0 @@
mod common;
use common::http_server::TestHttpServer;
use common::{cli_cmd, query_manager, seed_folder, seed_workspace};
use predicates::str::contains;
use tempfile::TempDir;
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
#[test]
fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let server = TestHttpServer::spawn_ok("workspace bulk send");
let request = HttpRequest {
id: "rq_workspace_send".to_string(),
workspace_id: "wk_test".to_string(),
name: "Workspace Send".to_string(),
method: "GET".to_string(),
url: server.url.clone(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed workspace request");
cli_cmd(data_dir)
.args(["send", "wk_test"])
.assert()
.success()
.stdout(contains("HTTP 200 OK"))
.stdout(contains("workspace bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
#[test]
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_folder(data_dir, "wk_test", "fl_test");
let server = TestHttpServer::spawn_ok("folder bulk send");
let request = HttpRequest {
id: "rq_folder_send".to_string(),
workspace_id: "wk_test".to_string(),
folder_id: Some("fl_test".to_string()),
name: "Folder Send".to_string(),
method: "GET".to_string(),
url: server.url.clone(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed folder request");
cli_cmd(data_dir)
.args(["send", "fl_test"])
.assert()
.success()
.stdout(contains("HTTP 200 OK"))
.stdout(contains("folder bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
#[test]
fn top_level_send_unknown_id_fails_with_clear_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["send", "does_not_exist"])
.assert()
.failure()
.code(1)
.stderr(contains("Could not resolve ID 'does_not_exist' as request, folder, or workspace"));
}

View File

@@ -15,21 +15,18 @@ use std::sync::Arc;
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedEvent, SharedPluginEventContext,
handle_shared_plugin_event,
};
use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{
Color, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, Icon,
InternalEvent, InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
WorkspaceInfo,
Color, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse,
GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent,
InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse,
ListWorkspacesResponse, RenderGrpcRequestResponse, RenderHttpRequestResponse,
SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse,
WindowInfoResponse, WindowNavigateEvent, WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
@@ -44,113 +41,30 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
) -> Result<Option<InternalEventPayload>> {
// log::debug!("Got event to app {event:?}");
let plugin_context = event.context.to_owned();
let plugin_name = plugin_handle.info().name;
let fallback_workspace_id = plugin_context.workspace_id.clone().or_else(|| {
plugin_context
.label
.as_ref()
.and_then(|label| app_handle.get_webview_window(label))
.and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))
});
match handle_shared_plugin_event(
app_handle.db_manager().inner(),
&event.payload,
SharedPluginEventContext {
plugin_name: &plugin_name,
workspace_id: fallback_workspace_id.as_deref(),
},
) {
GroupedPluginEvent::Shared(SharedEvent::Reply(payload)) => Ok(Some(payload)),
GroupedPluginEvent::Shared(SharedEvent::ErrorResponse(resp)) => {
error!("Plugin error: {}: {:?}", resp.error, resp);
let toast_event = plugin_handle.build_event_to_send(
&plugin_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!(
"Plugin error from {}: {}",
plugin_handle.info().name,
resp.error
),
color: Some(Color::Danger),
timeout: Some(30000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
}
GroupedPluginEvent::Shared(SharedEvent::ReloadResponse(req)) => {
let plugins = app_handle.db().list_plugins()?;
for plugin in plugins {
if plugin.directory != plugin_handle.dir {
continue;
}
let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
}
if !req.silent {
let info = plugin_handle.info();
let toast_event = plugin_handle.build_event_to_send(
&plugin_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!("Reloaded plugin {}@{}", info.name, info.version),
icon: Some(Icon::Info),
timeout: Some(3000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
} else {
Ok(None)
}
}
GroupedPluginEvent::Host(host_request) => {
handle_host_plugin_request(
app_handle,
event,
plugin_handle,
&plugin_context,
host_request,
)
.await
}
GroupedPluginEvent::Ignore => Ok(None),
}
}
async fn handle_host_plugin_request<R: Runtime>(
app_handle: &AppHandle<R>,
event: &InternalEvent,
plugin_handle: &PluginHandle,
plugin_context: &yaak_plugins::events::PluginContext,
host_request: HostRequest<'_>,
) -> Result<Option<InternalEventPayload>> {
match host_request {
HostRequest::CopyText(req) => {
match event.clone().payload {
InternalEventPayload::CopyTextRequest(req) => {
app_handle.clipboard().write_text(req.text.as_str())?;
Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})))
}
HostRequest::ShowToast(req) => {
match &plugin_context.label {
InternalEventPayload::ShowToastRequest(req) => {
match plugin_context.label {
Some(label) => app_handle.emit_to(label, "show_toast", req)?,
None => app_handle.emit("show_toast", req)?,
};
Ok(Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})))
}
HostRequest::PromptText(_) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::PromptTextRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
Ok(call_frontend(&window, event).await)
}
HostRequest::PromptForm(_) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::PromptFormRequest(_) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
if event.reply_id.is_some() {
// Follow-up update from plugin runtime with resolved inputs — forward to frontend
window.emit_to(window.label(), "plugin_event", event.clone())?;
Ok(None)
} else {
// Initial request — set up bidirectional communication
window.emit_to(window.label(), "plugin_event", event.clone()).unwrap();
let event_id = event.id.clone();
@@ -158,14 +72,17 @@ async fn handle_host_plugin_request<R: Runtime>(
let plugin_context = plugin_context.clone();
let window = window.clone();
// Spawn async task to handle bidirectional form communication
tauri::async_runtime::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);
// Listen for replies from the frontend
let listener_id = window.listen(event_id, move |ev: tauri::Event| {
let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();
let _ = tx.try_send(resp);
});
// Forward each reply to the plugin runtime
while let Some(resp) = rx.recv().await {
let is_done = matches!(
&resp.payload,
@@ -192,7 +109,7 @@ async fn handle_host_plugin_request<R: Runtime>(
Ok(None)
}
}
HostRequest::FindHttpResponses(req) => {
InternalEventPayload::FindHttpResponsesRequest(req) => {
let http_responses = app_handle
.db()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
@@ -201,7 +118,32 @@ async fn handle_host_plugin_request<R: Runtime>(
http_responses,
})))
}
HostRequest::UpsertModel(req) => {
InternalEventPayload::ListHttpRequestsRequest(req) => {
let w = get_window_from_plugin_context(app_handle, &plugin_context)?;
let workspace = workspace_from_window(&w)
.ok_or(PluginErr("Failed to get workspace from window".into()))?;
let http_requests = if let Some(folder_id) = req.folder_id {
app_handle.db().list_http_requests_for_folder_recursive(&folder_id)?
} else {
app_handle.db().list_http_requests(&workspace.id)?
};
Ok(Some(InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse {
http_requests,
})))
}
InternalEventPayload::ListFoldersRequest(_req) => {
let w = get_window_from_plugin_context(app_handle, &plugin_context)?;
let workspace = workspace_from_window(&w)
.ok_or(PluginErr("Failed to get workspace from window".into()))?;
let folders = app_handle.db().list_folders(&workspace.id)?;
Ok(Some(InternalEventPayload::ListFoldersResponse(
yaak_plugins::events::ListFoldersResponse { folders },
)))
}
InternalEventPayload::UpsertModelRequest(req) => {
use AnyModel::*;
let model = match &req.model {
HttpRequest(m) => {
@@ -229,7 +171,7 @@ async fn handle_host_plugin_request<R: Runtime>(
yaak_plugins::events::UpsertModelResponse { model },
)))
}
HostRequest::DeleteModel(req) => {
InternalEventPayload::DeleteModelRequest(req) => {
let model = match req.model.as_str() {
"http_request" => AnyModel::HttpRequest(
app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
@@ -257,8 +199,14 @@ async fn handle_host_plugin_request<R: Runtime>(
yaak_plugins::events::DeleteModelResponse { model },
)))
}
HostRequest::RenderGrpcRequest(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::GetHttpRequestByIdRequest(req) => {
let http_request = app_handle.db().get_http_request(&req.id).ok();
Ok(Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
http_request,
})))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
@@ -273,8 +221,8 @@ async fn handle_host_plugin_request<R: Runtime>(
let cb = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
plugin_context,
req.purpose.clone(),
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let grpc_request =
@@ -283,8 +231,8 @@ async fn handle_host_plugin_request<R: Runtime>(
grpc_request,
})))
}
HostRequest::RenderHttpRequest(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::RenderHttpRequestRequest(req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
@@ -299,18 +247,18 @@ async fn handle_host_plugin_request<R: Runtime>(
let cb = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
plugin_context,
req.purpose.clone(),
&plugin_context,
req.purpose,
);
let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let http_request =
render_http_request(&req.http_request, environment_chain, &cb, opt).await?;
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
http_request,
})))
}
HostRequest::TemplateRender(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::TemplateRenderRequest(req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
@@ -335,16 +283,65 @@ async fn handle_host_plugin_request<R: Runtime>(
let cb = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
plugin_context,
req.purpose.clone(),
&plugin_context,
req.purpose,
);
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let data = render_json_value(req.data.clone(), environment_chain, &cb, &opt).await?;
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
}
HostRequest::SendHttpRequest(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
let mut http_request = req.http_request.clone();
InternalEventPayload::ErrorResponse(resp) => {
error!("Plugin error: {}: {:?}", resp.error, resp);
let toast_event = plugin_handle.build_event_to_send(
&plugin_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!(
"Plugin error from {}: {}",
plugin_handle.info().name,
resp.error
),
color: Some(Color::Danger),
timeout: Some(30000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
}
InternalEventPayload::ReloadResponse(req) => {
let plugins = app_handle.db().list_plugins()?;
for plugin in plugins {
if plugin.directory != plugin_handle.dir {
continue;
}
let new_plugin = Plugin {
updated_at: Utc::now().naive_utc(), // TODO: Add reloaded_at field to use instead
..plugin
};
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
}
if !req.silent {
let info = plugin_handle.info();
let toast_event = plugin_handle.build_event_to_send(
&plugin_context,
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!("Reloaded plugin {}@{}", info.name, info.version),
icon: Some(Icon::Info),
timeout: Some(3000),
..Default::default()
}),
None,
);
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
} else {
Ok(None)
}
}
InternalEventPayload::SendHttpRequestRequest(req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let mut http_request = req.http_request;
let workspace =
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
let cookie_jar = cookie_jar_from_window(&window);
@@ -375,8 +372,8 @@ async fn handle_host_plugin_request<R: Runtime>(
&http_response,
environment,
cookie_jar,
&mut tokio::sync::watch::channel(false).1,
plugin_context,
&mut tokio::sync::watch::channel(false).1, // No-op cancel channel
&plugin_context,
)
.await?;
@@ -384,7 +381,7 @@ async fn handle_host_plugin_request<R: Runtime>(
http_response,
})))
}
HostRequest::OpenWindow(req) => {
InternalEventPayload::OpenWindowRequest(req) => {
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);
let win_config = CreateWindowConfig {
@@ -399,7 +396,7 @@ async fn handle_host_plugin_request<R: Runtime>(
};
if let Err(e) = create_window(app_handle, win_config) {
let error_event = plugin_handle.build_event_to_send(
plugin_context,
&plugin_context,
&InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to create window: {:?}", e),
}),
@@ -417,7 +414,7 @@ async fn handle_host_plugin_request<R: Runtime>(
while let Some(url) = navigation_rx.recv().await {
let url = url.to_string();
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&plugin_context, // NOTE: Sending existing context on purpose here
&InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }),
Some(event_id.clone()),
);
@@ -431,7 +428,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let plugin_handle = plugin_handle.clone();
let plugin_context = plugin_context.clone();
tauri::async_runtime::spawn(async move {
while close_rx.recv().await.is_some() {
while let Some(_) = close_rx.recv().await {
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&InternalEventPayload::WindowCloseEvent,
@@ -444,33 +441,35 @@ async fn handle_host_plugin_request<R: Runtime>(
Ok(None)
}
HostRequest::CloseWindow(req) => {
InternalEventPayload::CloseWindowRequest(req) => {
if let Some(window) = app_handle.webview_windows().get(&req.label) {
window.close()?;
}
Ok(None)
}
HostRequest::OpenExternalUrl(req) => {
InternalEventPayload::OpenExternalUrlRequest(req) => {
app_handle.opener().open_url(&req.url, None::<&str>)?;
Ok(Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})))
}
HostRequest::ListOpenWorkspaces(_) => {
let mut workspaces = Vec::new();
for (_, window) in app_handle.webview_windows() {
if let Some(workspace) = workspace_from_window(&window) {
workspaces.push(WorkspaceInfo {
id: workspace.id.clone(),
name: workspace.name.clone(),
label: window.label().to_string(),
});
}
}
Ok(Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
workspaces,
InternalEventPayload::SetKeyValueRequest(req) => {
let name = plugin_handle.info().name;
app_handle.db().set_plugin_key_value(&name, &req.key, &req.value);
Ok(Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {})))
}
InternalEventPayload::GetKeyValueRequest(req) => {
let name = plugin_handle.info().name;
let value = app_handle.db().get_plugin_key_value(&name, &req.key).map(|v| v.value);
Ok(Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })))
}
InternalEventPayload::DeleteKeyValueRequest(req) => {
let name = plugin_handle.info().name;
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key)?;
Ok(Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse {
deleted,
})))
}
HostRequest::ListCookieNames(_) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::ListCookieNamesRequest(_req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let names = match cookie_jar_from_window(&window) {
None => Vec::new(),
Some(j) => j
@@ -483,8 +482,8 @@ async fn handle_host_plugin_request<R: Runtime>(
names,
})))
}
HostRequest::GetCookieValue(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
InternalEventPayload::GetCookieValueRequest(req) => {
let window = get_window_from_plugin_context(app_handle, &plugin_context)?;
let value = match cookie_jar_from_window(&window) {
None => None,
Some(j) => j.cookies.into_iter().find_map(|c| match Cookie::parse(c.raw_cookie) {
@@ -496,11 +495,12 @@ async fn handle_host_plugin_request<R: Runtime>(
};
Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })))
}
HostRequest::WindowInfo(req) => {
InternalEventPayload::WindowInfoRequest(req) => {
let w = app_handle
.get_webview_window(&req.label)
.ok_or(PluginErr(format!("Failed to find window for {}", req.label)))?;
// Actually look up the data so we never return an invalid ID
let environment_id = environment_from_window(&w).map(|m| m.id);
let workspace_id = workspace_from_window(&w).map(|m| m.id);
let request_id =
@@ -518,13 +518,25 @@ async fn handle_host_plugin_request<R: Runtime>(
environment_id,
})))
}
HostRequest::OtherRequest(req) => {
Ok(Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!(
"Unsupported plugin request in app host handler: {}",
req.type_name()
),
InternalEventPayload::ListWorkspacesRequest(_) => {
let mut workspaces = Vec::new();
for (_, window) in app_handle.webview_windows() {
if let Some(workspace) = workspace_from_window(&window) {
workspaces.push(WorkspaceInfo {
id: workspace.id.clone(),
name: workspace.name.clone(),
label: window.label().to_string(),
});
}
}
Ok(Some(InternalEventPayload::ListWorkspacesResponse(ListWorkspacesResponse {
workspaces,
})))
}
_ => Ok(None),
}
}

View File

@@ -119,6 +119,7 @@ impl YaakUpdater {
UpdateTrigger::User => "user",
},
)?
.header("X-Install-Mode", detect_install_mode().unwrap_or("unknown"))?
.build()?
.check()
.await;
@@ -361,6 +362,22 @@ pub async fn download_update_idempotent<R: Runtime>(
Ok(dl_path)
}
/// Detect the installer type so the update server can serve the correct artifact.
fn detect_install_mode() -> Option<&'static str> {
#[cfg(target_os = "windows")]
{
if let Ok(exe) = std::env::current_exe() {
let path = exe.to_string_lossy().to_lowercase();
if path.starts_with(r"c:\program files") {
return Some("nsis-machine");
}
}
return Some("nsis");
}
#[allow(unreachable_code)]
None
}
pub async fn install_update_maybe_download<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,

View File

@@ -17,7 +17,6 @@ sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { version = "0.8.22", features = ["chrono"] }
sha2 = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }

View File

@@ -6,7 +6,6 @@ use crate::models::HttpRequestIden::{
use crate::util::{UpdateSource, generate_prefixed_id};
use chrono::{NaiveDateTime, Utc};
use rusqlite::Row;
use schemars::JsonSchema;
use sea_query::Order::Desc;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
use serde::{Deserialize, Deserializer, Serialize};
@@ -825,7 +824,7 @@ impl UpsertModelInfo for Folder {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader {
@@ -838,7 +837,7 @@ pub struct HttpRequestHeader {
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter {
@@ -851,7 +850,7 @@ pub struct HttpUrlParameter {
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "http_requests")]
@@ -1096,7 +1095,7 @@ impl Default for WebsocketMessageType {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "websocket_requests")]
@@ -1705,7 +1704,7 @@ impl UpsertModelInfo for GraphQlIntrospection {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "grpc_requests")]

View File

@@ -1,7 +1,7 @@
use super::dedupe_headers;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader};
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -15,20 +15,6 @@ impl<'a> DbContext<'a> {
self.find_many(GrpcRequestIden::WorkspaceId, workspace_id, None)
}
pub fn list_grpc_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<GrpcRequest>> {
let mut children = Vec::new();
for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_grpc_requests_for_folder_recursive(&folder.id)?);
}
for request in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, folder_id, None)? {
children.push(request);
}
Ok(children)
}
pub fn delete_grpc_request(
&self,
m: &GrpcRequest,

View File

@@ -1,9 +1,7 @@
use super::dedupe_headers;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{
Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden,
};
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -17,22 +15,6 @@ impl<'a> DbContext<'a> {
self.find_many(WebsocketRequestIden::WorkspaceId, workspace_id, None)
}
pub fn list_websocket_requests_for_folder_recursive(
&self,
folder_id: &str,
) -> Result<Vec<WebsocketRequest>> {
let mut children = Vec::new();
for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
children.extend(self.list_websocket_requests_for_folder_recursive(&folder.id)?);
}
for request in
self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, folder_id, None)?
{
children.push(request);
}
Ok(children)
}
pub fn delete_websocket_request(
&self,
websocket_request: &WebsocketRequest,

File diff suppressed because one or more lines are too long

View File

@@ -163,8 +163,8 @@ pub enum InternalEventPayload {
WindowInfoRequest(WindowInfoRequest),
WindowInfoResponse(WindowInfoResponse),
ListOpenWorkspacesRequest(ListOpenWorkspacesRequest),
ListOpenWorkspacesResponse(ListOpenWorkspacesResponse),
ListWorkspacesRequest(ListWorkspacesRequest),
ListWorkspacesResponse(ListWorkspacesResponse),
GetHttpRequestByIdRequest(GetHttpRequestByIdRequest),
GetHttpRequestByIdResponse(GetHttpRequestByIdResponse),
@@ -631,12 +631,12 @@ pub struct WindowInfoResponse {
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ListOpenWorkspacesRequest {}
pub struct ListWorkspacesRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ListOpenWorkspacesResponse {
pub struct ListWorkspacesResponse {
pub workspaces: Vec<WorkspaceInfo>,
}

View File

@@ -17,6 +17,3 @@ yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -1,5 +1,4 @@
pub mod error;
pub mod plugin_events;
pub mod render;
pub mod send;

View File

@@ -1,429 +0,0 @@
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, ErrorResponse, FindHttpResponsesRequest, GetCookieValueRequest,
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse,
InternalEventPayload, ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse,
ListHttpRequestsRequest, ListHttpRequestsResponse, ListOpenWorkspacesRequest,
OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest, PromptTextRequest,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
pub plugin_name: &'a str,
pub workspace_id: Option<&'a str>,
}
#[derive(Debug)]
pub enum GroupedPluginEvent<'a> {
Shared(SharedEvent<'a>),
Host(HostRequest<'a>),
Ignore,
}
#[derive(Debug)]
pub enum SharedEvent<'a> {
Reply(InternalEventPayload),
ErrorResponse(&'a ErrorResponse),
ReloadResponse(&'a ReloadResponse),
}
#[derive(Debug)]
pub enum GroupedPluginRequest<'a> {
Shared(SharedRequest<'a>),
Host(HostRequest<'a>),
Ignore,
}
#[derive(Debug)]
pub enum SharedRequest<'a> {
GetKeyValue(&'a GetKeyValueRequest),
SetKeyValue(&'a SetKeyValueRequest),
DeleteKeyValue(&'a DeleteKeyValueRequest),
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
ErrorResponse(&'a ErrorResponse),
ReloadResponse(&'a ReloadResponse),
ListFolders(&'a ListFoldersRequest),
ListHttpRequests(&'a ListHttpRequestsRequest),
}
#[derive(Debug)]
pub enum HostRequest<'a> {
ShowToast(&'a ShowToastRequest),
CopyText(&'a CopyTextRequest),
PromptText(&'a PromptTextRequest),
PromptForm(&'a PromptFormRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
RenderHttpRequest(&'a RenderHttpRequestRequest),
TemplateRender(&'a TemplateRenderRequest),
SendHttpRequest(&'a SendHttpRequestRequest),
OpenWindow(&'a OpenWindowRequest),
CloseWindow(&'a CloseWindowRequest),
OpenExternalUrl(&'a OpenExternalUrlRequest),
ListOpenWorkspaces(&'a ListOpenWorkspacesRequest),
ListCookieNames(&'a ListCookieNamesRequest),
GetCookieValue(&'a GetCookieValueRequest),
WindowInfo(&'a WindowInfoRequest),
OtherRequest(&'a InternalEventPayload),
}
impl HostRequest<'_> {
pub fn type_name(&self) -> String {
match self {
HostRequest::ShowToast(_) => "show_toast_request".to_string(),
HostRequest::CopyText(_) => "copy_text_request".to_string(),
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
HostRequest::PromptForm(_) => "prompt_form_request".to_string(),
HostRequest::FindHttpResponses(_) => "find_http_responses_request".to_string(),
HostRequest::UpsertModel(_) => "upsert_model_request".to_string(),
HostRequest::DeleteModel(_) => "delete_model_request".to_string(),
HostRequest::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(),
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
HostRequest::TemplateRender(_) => "template_render_request".to_string(),
HostRequest::SendHttpRequest(_) => "send_http_request_request".to_string(),
HostRequest::OpenWindow(_) => "open_window_request".to_string(),
HostRequest::CloseWindow(_) => "close_window_request".to_string(),
HostRequest::OpenExternalUrl(_) => "open_external_url_request".to_string(),
HostRequest::ListOpenWorkspaces(_) => "list_open_workspaces_request".to_string(),
HostRequest::ListCookieNames(_) => "list_cookie_names_request".to_string(),
HostRequest::GetCookieValue(_) => "get_cookie_value_request".to_string(),
HostRequest::WindowInfo(_) => "window_info_request".to_string(),
HostRequest::OtherRequest(payload) => payload.type_name(),
}
}
}
impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
fn from(payload: &'a InternalEventPayload) -> Self {
match payload {
InternalEventPayload::GetKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetKeyValue(req))
}
InternalEventPayload::SetKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::SetKeyValue(req))
}
InternalEventPayload::DeleteKeyValueRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::DeleteKeyValue(req))
}
InternalEventPayload::GetHttpRequestByIdRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetHttpRequestById(req))
}
InternalEventPayload::ErrorResponse(resp) => {
GroupedPluginRequest::Shared(SharedRequest::ErrorResponse(resp))
}
InternalEventPayload::ReloadResponse(req) => {
GroupedPluginRequest::Shared(SharedRequest::ReloadResponse(req))
}
InternalEventPayload::ListOpenWorkspacesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ListOpenWorkspaces(req))
}
InternalEventPayload::ListFoldersRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ListFolders(req))
}
InternalEventPayload::ListHttpRequestsRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ListHttpRequests(req))
}
InternalEventPayload::ShowToastRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ShowToast(req))
}
InternalEventPayload::CopyTextRequest(req) => {
GroupedPluginRequest::Host(HostRequest::CopyText(req))
}
InternalEventPayload::PromptTextRequest(req) => {
GroupedPluginRequest::Host(HostRequest::PromptText(req))
}
InternalEventPayload::PromptFormRequest(req) => {
GroupedPluginRequest::Host(HostRequest::PromptForm(req))
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::FindHttpResponses(req))
}
InternalEventPayload::UpsertModelRequest(req) => {
GroupedPluginRequest::Host(HostRequest::UpsertModel(req))
}
InternalEventPayload::DeleteModelRequest(req) => {
GroupedPluginRequest::Host(HostRequest::DeleteModel(req))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
}
InternalEventPayload::RenderHttpRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderHttpRequest(req))
}
InternalEventPayload::TemplateRenderRequest(req) => {
GroupedPluginRequest::Host(HostRequest::TemplateRender(req))
}
InternalEventPayload::SendHttpRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::SendHttpRequest(req))
}
InternalEventPayload::OpenWindowRequest(req) => {
GroupedPluginRequest::Host(HostRequest::OpenWindow(req))
}
InternalEventPayload::CloseWindowRequest(req) => {
GroupedPluginRequest::Host(HostRequest::CloseWindow(req))
}
InternalEventPayload::OpenExternalUrlRequest(req) => {
GroupedPluginRequest::Host(HostRequest::OpenExternalUrl(req))
}
InternalEventPayload::ListCookieNamesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::ListCookieNames(req))
}
InternalEventPayload::GetCookieValueRequest(req) => {
GroupedPluginRequest::Host(HostRequest::GetCookieValue(req))
}
InternalEventPayload::WindowInfoRequest(req) => {
GroupedPluginRequest::Host(HostRequest::WindowInfo(req))
}
payload if payload.type_name().ends_with("_request") => {
GroupedPluginRequest::Host(HostRequest::OtherRequest(payload))
}
_ => GroupedPluginRequest::Ignore,
}
}
}
pub fn handle_shared_plugin_event<'a>(
query_manager: &QueryManager,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
match GroupedPluginRequest::from(payload) {
GroupedPluginRequest::Shared(SharedRequest::ErrorResponse(resp)) => {
GroupedPluginEvent::Shared(SharedEvent::ErrorResponse(resp))
}
GroupedPluginRequest::Shared(SharedRequest::ReloadResponse(req)) => {
GroupedPluginEvent::Shared(SharedEvent::ReloadResponse(req))
}
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Shared(SharedEvent::Reply(
build_shared_reply(query_manager, req, context),
)),
GroupedPluginRequest::Host(req) => GroupedPluginEvent::Host(req),
GroupedPluginRequest::Ignore => GroupedPluginEvent::Ignore,
}
}
fn build_shared_reply(
query_manager: &QueryManager,
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
match request {
SharedRequest::GetKeyValue(req) => {
let value = query_manager
.connect()
.get_plugin_key_value(context.plugin_name, &req.key)
.map(|v| v.value);
InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })
}
SharedRequest::SetKeyValue(req) => {
query_manager.connect().set_plugin_key_value(context.plugin_name, &req.key, &req.value);
InternalEventPayload::SetKeyValueResponse(yaak_plugins::events::SetKeyValueResponse {})
}
SharedRequest::DeleteKeyValue(req) => {
match query_manager.connect().delete_plugin_key_value(context.plugin_name, &req.key) {
Ok(deleted) => {
InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted })
}
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete plugin key '{}' : {err}", req.key),
}),
}
}
SharedRequest::GetHttpRequestById(req) => {
let http_request = query_manager.connect().get_http_request(&req.id).ok();
InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
http_request,
})
}
SharedRequest::ErrorResponse(_) | SharedRequest::ReloadResponse(_) => {
unreachable!("non-reply shared events are handled before build_shared_reply")
}
SharedRequest::ListFolders(_) => {
let Some(workspace_id) = context.workspace_id else {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required for list_folders_request".to_string(),
});
};
let folders = match query_manager.connect().list_folders(workspace_id) {
Ok(folders) => folders,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list folders: {err}"),
});
}
};
InternalEventPayload::ListFoldersResponse(ListFoldersResponse { folders })
}
SharedRequest::ListHttpRequests(req) => {
let http_requests = if let Some(folder_id) = req.folder_id.as_deref() {
match query_manager.connect().list_http_requests_for_folder_recursive(folder_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list HTTP requests for folder: {err}"),
});
}
}
} else {
let Some(workspace_id) = context.workspace_id else {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error:
"workspace_id is required for list_http_requests_request without folder_id"
.to_string(),
});
};
match query_manager.connect().list_http_requests(workspace_id) {
Ok(http_requests) => http_requests,
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list HTTP requests: {err}"),
});
}
}
};
InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse {
http_requests,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::models::{Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
fn seed_query_manager() -> QueryManager {
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
let blob_path = temp_dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB");
query_manager
.connect()
.upsert_workspace(
&Workspace {
id: "wk_test".to_string(),
name: "Workspace".to_string(),
..Default::default()
},
&UpdateSource::Sync,
)
.expect("Failed to seed workspace");
query_manager
.connect()
.upsert_folder(
&Folder {
id: "fl_test".to_string(),
workspace_id: "wk_test".to_string(),
name: "Folder".to_string(),
..Default::default()
},
&UpdateSource::Sync,
)
.expect("Failed to seed folder");
query_manager
.connect()
.upsert_http_request(
&HttpRequest {
id: "rq_test".to_string(),
workspace_id: "wk_test".to_string(),
folder_id: Some("fl_test".to_string()),
name: "Request".to_string(),
method: "GET".to_string(),
url: "https://example.com".to_string(),
..Default::default()
},
&UpdateSource::Sync,
)
.expect("Failed to seed request");
query_manager
}
#[test]
fn list_requests_requires_workspace_when_folder_missing() {
let query_manager = seed_query_manager();
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
assert!(matches!(
result,
GroupedPluginEvent::Shared(SharedEvent::Reply(InternalEventPayload::ErrorResponse(_)))
));
}
#[test]
fn list_requests_by_workspace_and_folder() {
let query_manager = seed_query_manager();
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let by_workspace = handle_shared_plugin_event(
&query_manager,
&by_workspace_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match by_workspace {
GroupedPluginEvent::Shared(SharedEvent::Reply(
InternalEventPayload::ListHttpRequestsResponse(resp),
)) => {
assert_eq!(resp.http_requests.len(), 1);
}
other => panic!("unexpected workspace response: {other:?}"),
}
let by_folder_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest {
folder_id: Some("fl_test".to_string()),
},
);
let by_folder = handle_shared_plugin_event(
&query_manager,
&by_folder_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match by_folder {
GroupedPluginEvent::Shared(SharedEvent::Reply(
InternalEventPayload::ListHttpRequestsResponse(resp),
)) => {
assert_eq!(resp.http_requests.len(), 1);
}
other => panic!("unexpected folder response: {other:?}"),
}
}
#[test]
fn host_request_classification_works() {
let query_manager = seed_query_manager();
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::Host(HostRequest::WindowInfo(req)) => assert_eq!(req.label, "main"),
other => panic!("unexpected host classification: {other:?}"),
}
}
}

9
package-lock.json generated
View File

@@ -37,7 +37,6 @@
"plugins/template-function-cookie",
"plugins/template-function-ctx",
"plugins/template-function-encode",
"plugins/template-function-faker",
"plugins/template-function-fs",
"plugins/template-function-hash",
"plugins/template-function-json",
@@ -16088,13 +16087,7 @@
},
"plugins/auth-oauth2": {
"name": "@yaak/auth-oauth2",
"version": "0.1.0",
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
}
"version": "0.1.0"
},
"plugins/filter-jsonpath": {
"name": "@yaak/filter-jsonpath",

File diff suppressed because one or more lines are too long

View File

@@ -33,7 +33,7 @@ import type {
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListOpenWorkspacesResponse,
ListWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
@@ -942,9 +942,9 @@ export class PluginInstance {
workspace: {
list: async () => {
const payload = {
type: 'list_open_workspaces_request',
type: 'list_workspaces_request',
} as InternalEventPayload;
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
const response = await this.#sendForReply<ListWorkspacesResponse>(context, payload);
return response.workspaces.map((w) => {
// Internal workspace info includes label field not in public API
type WorkspaceInfoInternal = typeof w & { label?: string };