Implement CLI send flows and refactor plugin event handling

This commit is contained in:
Gregory Schier
2026-02-17 15:41:09 -08:00
parent 6cc659e5c4
commit 9e177136af
29 changed files with 1587 additions and 252 deletions

22
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
@@ -23,7 +23,7 @@ pub struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Commands { pub enum Commands {
/// Send an HTTP request by ID /// Send a request, folder, or workspace by ID
Send(SendArgs), Send(SendArgs),
/// Workspace commands /// Workspace commands
@@ -41,8 +41,20 @@ pub enum Commands {
#[derive(Args)] #[derive(Args)]
pub struct SendArgs { pub struct SendArgs {
/// Request ID /// Request, folder, or workspace ID
pub request_id: String, 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)] #[derive(Args)]
@@ -119,12 +131,18 @@ pub enum RequestCommands {
request_id: String, request_id: String,
}, },
/// Send an HTTP request by ID /// Send a request by ID
Send { Send {
/// Request ID /// Request ID
request_id: String, request_id: String,
}, },
/// Output JSON schema for request create/update payloads
Schema {
#[arg(value_enum)]
request_type: RequestSchemaType,
},
/// Create a new HTTP request /// Create a new HTTP request
Create { Create {
/// Workspace ID (or positional JSON payload shorthand) /// 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)] #[derive(Args)]
pub struct FolderArgs { pub struct FolderArgs {
#[command(subcommand)] #[command(subcommand)]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"));
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -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:?}"),
}
}
}

9
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"plugins/template-function-cookie", "plugins/template-function-cookie",
"plugins/template-function-ctx", "plugins/template-function-ctx",
"plugins/template-function-encode", "plugins/template-function-encode",
"plugins/template-function-faker",
"plugins/template-function-fs", "plugins/template-function-fs",
"plugins/template-function-hash", "plugins/template-function-hash",
"plugins/template-function-json", "plugins/template-function-json",
@@ -16087,7 +16088,13 @@
}, },
"plugins/auth-oauth2": { "plugins/auth-oauth2": {
"name": "@yaak/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": { "plugins/filter-jsonpath": {
"name": "@yaak/filter-jsonpath", "name": "@yaak/filter-jsonpath",

File diff suppressed because one or more lines are too long

View File

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