From 9e177136aff7fbabbb3054589e34ceb7f7ef8d60 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 17 Feb 2026 15:41:09 -0800 Subject: [PATCH] Implement CLI send flows and refactor plugin event handling --- Cargo.lock | 22 + crates-cli/yaak-cli/Cargo.toml | 2 + crates-cli/yaak-cli/PLAN.md | 19 +- crates-cli/yaak-cli/src/cli.rs | 35 +- .../yaak-cli/src/commands/environment.rs | 14 +- crates-cli/yaak-cli/src/commands/folder.rs | 30 +- crates-cli/yaak-cli/src/commands/request.rs | 274 ++++++++++- crates-cli/yaak-cli/src/commands/send.rs | 168 ++++++- crates-cli/yaak-cli/src/commands/workspace.rs | 8 +- crates-cli/yaak-cli/src/context.rs | 21 +- crates-cli/yaak-cli/src/main.rs | 5 +- crates-cli/yaak-cli/src/plugin_events.rs | 212 +++++++++ crates-cli/yaak-cli/src/utils/json.rs | 15 +- crates-cli/yaak-cli/tests/common/mod.rs | 46 +- crates-cli/yaak-cli/tests/request_commands.rs | 51 ++- crates-cli/yaak-cli/tests/send_commands.rs | 81 ++++ crates-tauri/yaak-app/src/plugin_events.rs | 320 +++++++------ crates/yaak-models/Cargo.toml | 1 + crates/yaak-models/src/models.rs | 11 +- .../yaak-models/src/queries/grpc_requests.rs | 16 +- .../src/queries/websocket_requests.rs | 20 +- crates/yaak-plugins/bindings/gen_events.ts | 6 +- crates/yaak-plugins/src/events.rs | 8 +- crates/yaak/Cargo.toml | 3 + crates/yaak/src/lib.rs | 1 + crates/yaak/src/plugin_events.rs | 429 ++++++++++++++++++ package-lock.json | 9 +- .../src/bindings/gen_events.ts | 6 +- packages/plugin-runtime/src/PluginInstance.ts | 6 +- 29 files changed, 1587 insertions(+), 252 deletions(-) create mode 100644 crates-cli/yaak-cli/src/plugin_events.rs create mode 100644 crates-cli/yaak-cli/tests/send_commands.rs create mode 100644 crates/yaak/src/plugin_events.rs diff --git a/Cargo.lock b/Cargo.lock index ffb476f5..8ae3b89b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1891,6 +1891,21 @@ 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" @@ -1898,6 +1913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1965,6 +1981,7 @@ 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", @@ -5250,6 +5267,7 @@ 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", @@ -8247,6 +8265,7 @@ dependencies = [ "log 0.4.29", "md5 0.8.0", "serde_json", + "tempfile", "thiserror 2.0.17", "tokio", "yaak-crypto", @@ -8337,8 +8356,10 @@ dependencies = [ "clap", "dirs", "env_logger", + "futures", "log 0.4.29", "predicates", + "schemars", "serde", "serde_json", "tempfile", @@ -8511,6 +8532,7 @@ dependencies = [ "r2d2", "r2d2_sqlite", "rusqlite", + "schemars", "sea-query", "sea-query-rusqlite", "serde", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index c13011e3..cfd8b75e 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -12,7 +12,9 @@ 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"] } diff --git a/crates-cli/yaak-cli/PLAN.md b/crates-cli/yaak-cli/PLAN.md index 61d9ecfb..888864bc 100644 --- a/crates-cli/yaak-cli/PLAN.md +++ b/crates-cli/yaak-cli/PLAN.md @@ -12,22 +12,23 @@ 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` + - `request list|show|create|update|send|delete|schema` - `folder list|show|create|update|delete` - `environment list|show|create|update|delete` -- Top-level `send` exists as a request-send shortcut (not yet flexible request/folder/workspace resolution) +- Top-level `send` resolves request/folder/workspace IDs and supports `--sequential|--parallel` + `--fail-fast` - Legacy `get` command removed - JSON create/update flow implemented (`--json` and positional JSON shorthand) -- No `request schema` command yet +- 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 Progress checklist: - [x] Phase 1 complete - [x] Phase 2 complete - [x] Phase 3 complete -- [ ] Phase 4 complete -- [ ] Phase 5 complete -- [ ] Phase 6 complete +- [x] Phase 4 complete +- [x] Phase 5 complete +- [x] Phase 6 complete ## Command Architecture @@ -47,7 +48,7 @@ Progress checklist: ``` # Top-level shortcut -yaakcli send [-e ] # id can be a request, folder, or workspace +yaakcli send [--sequential|--parallel] [--fail-fast] [-e ] # Resource commands yaakcli workspace list @@ -102,8 +103,8 @@ is purely by DB lookup. `send` means "execute this request" regardless of protocol: - **HTTP**: send request, print response, exit -- **gRPC**: invoke the method; for streaming, stream output to stdout until done/Ctrl+C -- **WebSocket**: connect, stream messages to stdout until closed/Ctrl+C +- **gRPC**: currently returns explicit "not implemented yet in yaak-cli" +- **WebSocket**: currently returns explicit "not implemented yet in yaak-cli" ### `request schema` — Runtime JSON Schema diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index 793d3215..2b74ca9b 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Parser)] @@ -23,7 +23,7 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Send an HTTP request by ID + /// Send a request, folder, or workspace by ID Send(SendArgs), /// Workspace commands @@ -41,8 +41,20 @@ pub enum Commands { #[derive(Args)] pub struct SendArgs { - /// Request ID - pub request_id: String, + /// 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, } #[derive(Args)] @@ -119,12 +131,18 @@ pub enum RequestCommands { request_id: String, }, - /// Send an HTTP request by ID + /// Send a 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) @@ -169,6 +187,13 @@ pub enum RequestCommands { }, } +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum RequestSchemaType { + Http, + Grpc, + Websocket, +} + #[derive(Args)] pub struct FolderArgs { #[command(subcommand)] diff --git a/crates-cli/yaak-cli/src/commands/environment.rs b/crates-cli/yaak-cli/src/commands/environment.rs index 3277d05a..deb13677 100644 --- a/crates-cli/yaak-cli/src/commands/environment.rs +++ b/crates-cli/yaak-cli/src/commands/environment.rs @@ -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,9 +81,8 @@ 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()); @@ -105,8 +104,9 @@ 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, diff --git a/crates-cli/yaak-cli/src/commands/folder.rs b/crates-cli/yaak-cli/src/commands/folder.rs index 3a5161cc..b280bdcd 100644 --- a/crates-cli/yaak-cli/src/commands/folder.rs +++ b/crates-cli/yaak-cli/src/commands/folder.rs @@ -31,7 +31,8 @@ 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 { @@ -43,9 +44,10 @@ 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(()) } @@ -72,8 +74,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()); @@ -88,10 +90,12 @@ 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() }; @@ -108,10 +112,8 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> 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 diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index 52cac00f..a9f6a713 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -1,15 +1,19 @@ -use crate::cli::{RequestArgs, RequestCommands}; +use crate::cli::{RequestArgs, RequestCommands, RequestSchemaType}; 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::HttpRequest; +use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest}; +use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::UpdateSource; -use yaak_plugins::events::PluginContext; +use yaak_plugins::events::{FormInput, FormInputBase, JsonPrimitive, PluginContext}; type CommandResult = std::result::Result; @@ -31,6 +35,15 @@ 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) } @@ -62,6 +75,221 @@ 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::::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, + required: &mut Vec, +) { + 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 = + 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, + required: &mut Vec, +) { + 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, @@ -146,12 +374,10 @@ fn update(ctx: &CliContext, json: Option, json_input: Option) -> } 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(()) } @@ -178,9 +404,35 @@ pub async fn send_request_by_id( verbose: bool, ) -> Result<(), String> { let request = - ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?; + 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()) + } + } +} - let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone())); +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 (event_tx, mut event_rx) = mpsc::channel(100); let event_handle = tokio::spawn(async move { diff --git a/crates-cli/yaak-cli/src/commands/send.rs b/crates-cli/yaak-cli/src/commands/send.rs index e3511a96..d885c45a 100644 --- a/crates-cli/yaak-cli/src/commands/send.rs +++ b/crates-cli/yaak-cli/src/commands/send.rs @@ -1,6 +1,12 @@ 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, @@ -8,7 +14,7 @@ pub async fn run( environment: Option<&str>, verbose: bool, ) -> i32 { - match request::send_request_by_id(ctx, &args.request_id, environment, verbose).await { + match send_target(ctx, args, environment, verbose).await { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); @@ -16,3 +22,163 @@ 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, 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::>(); + 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::>(); + 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::>(); + ids.append(&mut websocket_ids); + + Ok(ids) +} + +fn collect_workspace_request_ids( + ctx: &CliContext, + workspace_id: &str, +) -> Result, 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::>(); + 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::>(); + 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::>(); + ids.append(&mut websocket_ids); + + Ok(ids) +} + +async fn send_many( + ctx: &CliContext, + request_ids: Vec, + 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::>(); + + 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()) +} diff --git a/crates-cli/yaak-cli/src/commands/workspace.rs b/crates-cli/yaak-cli/src/commands/workspace.rs index d7e8db02..838223aa 100644 --- a/crates-cli/yaak-cli/src/commands/workspace.rs +++ b/crates-cli/yaak-cli/src/commands/workspace.rs @@ -28,7 +28,8 @@ 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 { @@ -75,8 +76,9 @@ 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 diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index 2002bb33..e03cee73 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -1,5 +1,7 @@ +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; @@ -13,6 +15,7 @@ pub struct CliContext { blob_manager: BlobManager, pub encryption_manager: Arc, plugin_manager: Option>, + plugin_event_bridge: Mutex>, } impl CliContext { @@ -65,7 +68,20 @@ impl CliContext { None }; - Self { data_dir, query_manager, blob_manager, encryption_manager, plugin_manager } + 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), + } } pub fn data_dir(&self) -> &Path { @@ -90,6 +106,9 @@ 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; } } diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index d5a8271b..58b83bb1 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod commands; mod context; +mod plugin_events; mod utils; use clap::Parser; @@ -24,7 +25,9 @@ async fn main() { let needs_plugins = matches!( &command, Commands::Send(_) - | Commands::Request(cli::RequestArgs { command: RequestCommands::Send { .. } }) + | Commands::Request(cli::RequestArgs { + command: RequestCommands::Send { .. } | RequestCommands::Schema { .. }, + }) ); let context = CliContext::initialize(data_dir, app_id, needs_plugins).await; diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs new file mode 100644 index 00000000..0f8f9f92 --- /dev/null +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -0,0 +1,212 @@ +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, 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 { + 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:?}"), + } + } +} diff --git a/crates-cli/yaak-cli/src/utils/json.rs b/crates-cli/yaak-cli/src/utils/json.rs index a074f8cb..1c6b2744 100644 --- a/crates-cli/yaak-cli/src/utils/json.rs +++ b/crates-cli/yaak-cli/src/utils/json.rs @@ -25,9 +25,9 @@ pub fn parse_optional_json( context: &str, ) -> JsonResult> { 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,9 +39,8 @@ pub fn parse_required_json( json_shorthand: Option, context: &str, ) -> JsonResult { - 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 { @@ -60,9 +59,7 @@ 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")), } } diff --git a/crates-cli/yaak-cli/tests/common/mod.rs b/crates-cli/yaak-cli/tests/common/mod.rs index 4043a81e..9b61c850 100644 --- a/crates-cli/yaak-cli/tests/common/mod.rs +++ b/crates-cli/yaak-cli/tests/common/mod.rs @@ -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::{HttpRequest, Workspace}; +use yaak_models::models::{Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace}; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; @@ -60,3 +60,47 @@ 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"); +} diff --git a/crates-cli/yaak-cli/tests/request_commands.rs b/crates-cli/yaak-cli/tests/request_commands.rs index 041e70af..41b2aa5e 100644 --- a/crates-cli/yaak-cli/tests/request_commands.rs +++ b/crates-cli/yaak-cli/tests/request_commands.rs @@ -1,7 +1,10 @@ mod common; use common::http_server::TestHttpServer; -use common::{cli_cmd, parse_created_id, query_manager, seed_request, seed_workspace}; +use common::{ + cli_cmd, parse_created_id, query_manager, seed_grpc_request, seed_request, + seed_websocket_request, seed_workspace, +}; use predicates::str::contains; use tempfile::TempDir; use yaak_models::models::HttpResponseState; @@ -114,8 +117,7 @@ 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) @@ -177,3 +179,46 @@ 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")); +} diff --git a/crates-cli/yaak-cli/tests/send_commands.rs b/crates-cli/yaak-cli/tests/send_commands.rs new file mode 100644 index 00000000..07703481 --- /dev/null +++ b/crates-cli/yaak-cli/tests/send_commands.rs @@ -0,0 +1,81 @@ +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")); +} diff --git a/crates-tauri/yaak-app/src/plugin_events.rs b/crates-tauri/yaak-app/src/plugin_events.rs index 52ecb7a7..87cef10d 100644 --- a/crates-tauri/yaak-app/src/plugin_events.rs +++ b/crates-tauri/yaak-app/src/plugin_events.rs @@ -15,18 +15,21 @@ 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, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, - GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent, - InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse, - ListWorkspacesResponse, RenderGrpcRequestResponse, RenderHttpRequestResponse, - SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse, - WindowInfoResponse, WindowNavigateEvent, WorkspaceInfo, + Color, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, Icon, + InternalEvent, InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse, + RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse, + ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent, + WorkspaceInfo, }; use yaak_plugins::manager::PluginManager; use yaak_plugins::plugin_handle::PluginHandle; @@ -41,30 +44,113 @@ pub(crate) async fn handle_plugin_event( ) -> Result> { // log::debug!("Got event to app {event:?}"); let plugin_context = event.context.to_owned(); - match event.clone().payload { - InternalEventPayload::CopyTextRequest(req) => { + 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( + app_handle: &AppHandle, + event: &InternalEvent, + plugin_handle: &PluginHandle, + plugin_context: &yaak_plugins::events::PluginContext, + host_request: HostRequest<'_>, +) -> Result> { + match host_request { + HostRequest::CopyText(req) => { app_handle.clipboard().write_text(req.text.as_str())?; Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))) } - InternalEventPayload::ShowToastRequest(req) => { - match plugin_context.label { + HostRequest::ShowToast(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 {}))) } - InternalEventPayload::PromptTextRequest(_) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::PromptText(_) => { + let window = get_window_from_plugin_context(app_handle, plugin_context)?; Ok(call_frontend(&window, event).await) } - InternalEventPayload::PromptFormRequest(_) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::PromptForm(_) => { + 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(); @@ -72,17 +158,14 @@ pub(crate) async fn handle_plugin_event( 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::(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, @@ -109,7 +192,7 @@ pub(crate) async fn handle_plugin_event( Ok(None) } } - InternalEventPayload::FindHttpResponsesRequest(req) => { + HostRequest::FindHttpResponses(req) => { let http_responses = app_handle .db() .list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64)) @@ -118,32 +201,7 @@ pub(crate) async fn handle_plugin_event( http_responses, }))) } - 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) => { + HostRequest::UpsertModel(req) => { use AnyModel::*; let model = match &req.model { HttpRequest(m) => { @@ -171,7 +229,7 @@ pub(crate) async fn handle_plugin_event( yaak_plugins::events::UpsertModelResponse { model }, ))) } - InternalEventPayload::DeleteModelRequest(req) => { + HostRequest::DeleteModel(req) => { let model = match req.model.as_str() { "http_request" => AnyModel::HttpRequest( app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?, @@ -199,14 +257,8 @@ pub(crate) async fn handle_plugin_event( yaak_plugins::events::DeleteModelResponse { model }, ))) } - 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)?; + HostRequest::RenderGrpcRequest(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"); @@ -221,8 +273,8 @@ pub(crate) async fn handle_plugin_event( let cb = PluginTemplateCallback::new( plugin_manager, encryption_manager, - &plugin_context, - req.purpose, + plugin_context, + req.purpose.clone(), ); let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let grpc_request = @@ -231,8 +283,8 @@ pub(crate) async fn handle_plugin_event( grpc_request, }))) } - InternalEventPayload::RenderHttpRequestRequest(req) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::RenderHttpRequest(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"); @@ -247,18 +299,18 @@ pub(crate) async fn handle_plugin_event( let cb = PluginTemplateCallback::new( plugin_manager, encryption_manager, - &plugin_context, - req.purpose, + plugin_context, + req.purpose.clone(), ); 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, }))) } - InternalEventPayload::TemplateRenderRequest(req) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::TemplateRender(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"); @@ -283,65 +335,16 @@ pub(crate) async fn handle_plugin_event( let cb = PluginTemplateCallback::new( plugin_manager, encryption_manager, - &plugin_context, - req.purpose, + plugin_context, + req.purpose.clone(), ); let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; - let data = render_json_value(req.data, environment_chain, &cb, &opt).await?; + let data = render_json_value(req.data.clone(), environment_chain, &cb, &opt).await?; Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))) } - 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; + HostRequest::SendHttpRequest(req) => { + let window = get_window_from_plugin_context(app_handle, plugin_context)?; + let mut http_request = req.http_request.clone(); let workspace = workspace_from_window(&window).expect("Failed to get workspace_id from window URL"); let cookie_jar = cookie_jar_from_window(&window); @@ -372,8 +375,8 @@ pub(crate) async fn handle_plugin_event( &http_response, environment, cookie_jar, - &mut tokio::sync::watch::channel(false).1, // No-op cancel channel - &plugin_context, + &mut tokio::sync::watch::channel(false).1, + plugin_context, ) .await?; @@ -381,7 +384,7 @@ pub(crate) async fn handle_plugin_event( http_response, }))) } - InternalEventPayload::OpenWindowRequest(req) => { + HostRequest::OpenWindow(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 { @@ -396,7 +399,7 @@ pub(crate) async fn handle_plugin_event( }; 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), }), @@ -414,7 +417,7 @@ pub(crate) async fn handle_plugin_event( 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, // NOTE: Sending existing context on purpose here + &plugin_context, &InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }), Some(event_id.clone()), ); @@ -428,7 +431,7 @@ pub(crate) async fn handle_plugin_event( let plugin_handle = plugin_handle.clone(); let plugin_context = plugin_context.clone(); tauri::async_runtime::spawn(async move { - while let Some(_) = close_rx.recv().await { + while close_rx.recv().await.is_some() { let event_to_send = plugin_handle.build_event_to_send( &plugin_context, &InternalEventPayload::WindowCloseEvent, @@ -441,35 +444,33 @@ pub(crate) async fn handle_plugin_event( Ok(None) } - InternalEventPayload::CloseWindowRequest(req) => { + HostRequest::CloseWindow(req) => { if let Some(window) = app_handle.webview_windows().get(&req.label) { window.close()?; } Ok(None) } - InternalEventPayload::OpenExternalUrlRequest(req) => { + HostRequest::OpenExternalUrl(req) => { app_handle.opener().open_url(&req.url, None::<&str>)?; Ok(Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))) } - 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::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::ListCookieNamesRequest(_req) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::ListCookieNames(_) => { + 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 @@ -482,8 +483,8 @@ pub(crate) async fn handle_plugin_event( names, }))) } - InternalEventPayload::GetCookieValueRequest(req) => { - let window = get_window_from_plugin_context(app_handle, &plugin_context)?; + HostRequest::GetCookieValue(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) { @@ -495,12 +496,11 @@ pub(crate) async fn handle_plugin_event( }; Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))) } - InternalEventPayload::WindowInfoRequest(req) => { + HostRequest::WindowInfo(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,25 +518,13 @@ pub(crate) async fn handle_plugin_event( environment_id, }))) } - - 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, + HostRequest::OtherRequest(req) => { + Ok(Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!( + "Unsupported plugin request in app host handler: {}", + req.type_name() + ), }))) } - - _ => Ok(None), } } diff --git a/crates/yaak-models/Cargo.toml b/crates/yaak-models/Cargo.toml index 015d489f..626e191e 100644 --- a/crates/yaak-models/Cargo.toml +++ b/crates/yaak-models/Cargo.toml @@ -17,6 +17,7 @@ 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"] } diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 6997149d..7fef4dcd 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -6,6 +6,7 @@ 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}; @@ -824,7 +825,7 @@ impl UpsertModelInfo for Folder { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpRequestHeader { @@ -837,7 +838,7 @@ pub struct HttpRequestHeader { pub id: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpUrlParameter { @@ -850,7 +851,7 @@ pub struct HttpUrlParameter { pub id: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "http_requests")] @@ -1095,7 +1096,7 @@ impl Default for WebsocketMessageType { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "websocket_requests")] @@ -1704,7 +1705,7 @@ impl UpsertModelInfo for GraphQlIntrospection { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "grpc_requests")] diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index 003f883e..5b65cb52 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -1,7 +1,7 @@ use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader}; +use crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader}; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; @@ -15,6 +15,20 @@ 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> { + let mut children = Vec::new(); + for folder in self.find_many::(FolderIden::FolderId, folder_id, None)? { + children.extend(self.list_grpc_requests_for_folder_recursive(&folder.id)?); + } + for request in self.find_many::(GrpcRequestIden::FolderId, folder_id, None)? { + children.push(request); + } + Ok(children) + } + pub fn delete_grpc_request( &self, m: &GrpcRequest, diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index 1f9ecb36..77a8e19c 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -1,7 +1,9 @@ use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden}; +use crate::models::{ + Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden, +}; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; @@ -15,6 +17,22 @@ 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> { + let mut children = Vec::new(); + for folder in self.find_many::(FolderIden::FolderId, folder_id, None)? { + children.extend(self.list_websocket_requests_for_folder_recursive(&folder.id)?); + } + for request in + self.find_many::(WebsocketRequestIden::FolderId, folder_id, None)? + { + children.push(request); + } + Ok(children) + } + pub fn delete_websocket_request( &self, websocket_request: &WebsocketRequest, diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index d7f4e55c..57310aaa 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -452,7 +452,7 @@ export type ImportResponse = { resources: ImportResources, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; -export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_workspaces_request" } & ListWorkspacesRequest | { "type": "list_workspaces_response" } & ListWorkspacesResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "list_http_requests_request" } & ListHttpRequestsRequest | { "type": "list_http_requests_response" } & ListHttpRequestsResponse | { "type": "list_folders_request" } & ListFoldersRequest | { "type": "list_folders_response" } & ListFoldersResponse | { "type": "upsert_model_request" } & UpsertModelRequest | { "type": "upsert_model_response" } & UpsertModelResponse | { "type": "delete_model_request" } & DeleteModelRequest | { "type": "delete_model_response" } & DeleteModelResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse; +export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_open_workspaces_request" } & ListOpenWorkspacesRequest | { "type": "list_open_workspaces_response" } & ListOpenWorkspacesResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "list_http_requests_request" } & ListHttpRequestsRequest | { "type": "list_http_requests_response" } & ListHttpRequestsResponse | { "type": "list_folders_request" } & ListFoldersRequest | { "type": "list_folders_response" } & ListFoldersResponse | { "type": "upsert_model_request" } & UpsertModelRequest | { "type": "upsert_model_response" } & UpsertModelResponse | { "type": "delete_model_request" } & DeleteModelRequest | { "type": "delete_model_response" } & DeleteModelResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse; export type JsonPrimitive = string | number | boolean | null; @@ -468,9 +468,9 @@ export type ListHttpRequestsRequest = { folderId?: string, }; export type ListHttpRequestsResponse = { httpRequests: Array, }; -export type ListWorkspacesRequest = Record; +export type ListOpenWorkspacesRequest = Record; -export type ListWorkspacesResponse = { workspaces: Array, }; +export type ListOpenWorkspacesResponse = { workspaces: Array, }; export type OpenExternalUrlRequest = { url: string, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index aa21de80..13e19096 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -163,8 +163,8 @@ pub enum InternalEventPayload { WindowInfoRequest(WindowInfoRequest), WindowInfoResponse(WindowInfoResponse), - ListWorkspacesRequest(ListWorkspacesRequest), - ListWorkspacesResponse(ListWorkspacesResponse), + ListOpenWorkspacesRequest(ListOpenWorkspacesRequest), + ListOpenWorkspacesResponse(ListOpenWorkspacesResponse), 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 ListWorkspacesRequest {} +pub struct ListOpenWorkspacesRequest {} #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] -pub struct ListWorkspacesResponse { +pub struct ListOpenWorkspacesResponse { pub workspaces: Vec, } diff --git a/crates/yaak/Cargo.toml b/crates/yaak/Cargo.toml index c5eb14fb..3c32ecf2 100644 --- a/crates/yaak/Cargo.toml +++ b/crates/yaak/Cargo.toml @@ -17,3 +17,6 @@ yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-templates = { workspace = true } yaak-tls = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/yaak/src/lib.rs b/crates/yaak/src/lib.rs index 2f068b24..8c79eacd 100644 --- a/crates/yaak/src/lib.rs +++ b/crates/yaak/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod plugin_events; pub mod render; pub mod send; diff --git a/crates/yaak/src/plugin_events.rs b/crates/yaak/src/plugin_events.rs new file mode 100644 index 00000000..2b16bf15 --- /dev/null +++ b/crates/yaak/src/plugin_events.rs @@ -0,0 +1,429 @@ +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:?}"), + } + } +} diff --git a/package-lock.json b/package-lock.json index a6112490..fb111360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "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", @@ -16087,7 +16088,13 @@ }, "plugins/auth-oauth2": { "name": "@yaak/auth-oauth2", - "version": "0.1.0" + "version": "0.1.0", + "dependencies": { + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.7" + } }, "plugins/filter-jsonpath": { "name": "@yaak/filter-jsonpath", diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index d7f4e55c..57310aaa 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -452,7 +452,7 @@ export type ImportResponse = { resources: ImportResources, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; -export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_workspaces_request" } & ListWorkspacesRequest | { "type": "list_workspaces_response" } & ListWorkspacesResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "list_http_requests_request" } & ListHttpRequestsRequest | { "type": "list_http_requests_response" } & ListHttpRequestsResponse | { "type": "list_folders_request" } & ListFoldersRequest | { "type": "list_folders_response" } & ListFoldersResponse | { "type": "upsert_model_request" } & UpsertModelRequest | { "type": "upsert_model_response" } & UpsertModelResponse | { "type": "delete_model_request" } & DeleteModelRequest | { "type": "delete_model_response" } & DeleteModelResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse; +export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } | { "type": "reload_response" } & ReloadResponse | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_websocket_request_actions_request" } & EmptyPayload | { "type": "get_websocket_request_actions_response" } & GetWebsocketRequestActionsResponse | { "type": "call_websocket_request_action_request" } & CallWebsocketRequestActionRequest | { "type": "get_workspace_actions_request" } & EmptyPayload | { "type": "get_workspace_actions_response" } & GetWorkspaceActionsResponse | { "type": "call_workspace_action_request" } & CallWorkspaceActionRequest | { "type": "get_folder_actions_request" } & EmptyPayload | { "type": "get_folder_actions_response" } & GetFolderActionsResponse | { "type": "call_folder_action_request" } & CallFolderActionRequest | { "type": "get_grpc_request_actions_request" } & EmptyPayload | { "type": "get_grpc_request_actions_response" } & GetGrpcRequestActionsResponse | { "type": "call_grpc_request_action_request" } & CallGrpcRequestActionRequest | { "type": "get_template_function_summary_request" } & EmptyPayload | { "type": "get_template_function_summary_response" } & GetTemplateFunctionSummaryResponse | { "type": "get_template_function_config_request" } & GetTemplateFunctionConfigRequest | { "type": "get_template_function_config_response" } & GetTemplateFunctionConfigResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "render_grpc_request_request" } & RenderGrpcRequestRequest | { "type": "render_grpc_request_response" } & RenderGrpcRequestResponse | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "open_external_url_request" } & OpenExternalUrlRequest | { "type": "open_external_url_response" } & EmptyPayload | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "prompt_form_request" } & PromptFormRequest | { "type": "prompt_form_response" } & PromptFormResponse | { "type": "window_info_request" } & WindowInfoRequest | { "type": "window_info_response" } & WindowInfoResponse | { "type": "list_open_workspaces_request" } & ListOpenWorkspacesRequest | { "type": "list_open_workspaces_response" } & ListOpenWorkspacesResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "list_http_requests_request" } & ListHttpRequestsRequest | { "type": "list_http_requests_response" } & ListHttpRequestsResponse | { "type": "list_folders_request" } & ListFoldersRequest | { "type": "list_folders_response" } & ListFoldersResponse | { "type": "upsert_model_request" } & UpsertModelRequest | { "type": "upsert_model_response" } & UpsertModelResponse | { "type": "delete_model_request" } & DeleteModelRequest | { "type": "delete_model_response" } & DeleteModelResponse | { "type": "get_themes_request" } & GetThemesRequest | { "type": "get_themes_response" } & GetThemesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse; export type JsonPrimitive = string | number | boolean | null; @@ -468,9 +468,9 @@ export type ListHttpRequestsRequest = { folderId?: string, }; export type ListHttpRequestsResponse = { httpRequests: Array, }; -export type ListWorkspacesRequest = Record; +export type ListOpenWorkspacesRequest = Record; -export type ListWorkspacesResponse = { workspaces: Array, }; +export type ListOpenWorkspacesResponse = { workspaces: Array, }; export type OpenExternalUrlRequest = { url: string, }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index bca142dc..bc46f613 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -33,7 +33,7 @@ import type { ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse, - ListWorkspacesResponse, + ListOpenWorkspacesResponse, PluginContext, PromptFormResponse, PromptTextResponse, @@ -942,9 +942,9 @@ export class PluginInstance { workspace: { list: async () => { const payload = { - type: 'list_workspaces_request', + type: 'list_open_workspaces_request', } as InternalEventPayload; - const response = await this.#sendForReply(context, payload); + const response = await this.#sendForReply(context, payload); return response.workspaces.map((w) => { // Internal workspace info includes label field not in public API type WorkspaceInfoInternal = typeof w & { label?: string };