diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index b443448f..29bff774 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -1,3 +1,5 @@ +use serde_json::Value; +use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; use tokio::task::JoinHandle; @@ -8,22 +10,26 @@ use yaak::render::render_http_request; use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; use yaak_crypto::manager::EncryptionManager; use yaak_models::blob_manager::BlobManager; +use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader}; use yaak_models::query_manager::QueryManager; +use yaak_models::render::make_vars_hashmap; use yaak_models::util::UpdateSource; use yaak_plugins::events::{ EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse, - RenderHttpRequestResponse, SendHttpRequestResponse, WorkspaceInfo, + RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse, + TemplateRenderResponse, WorkspaceInfo, }; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; -use yaak_templates::RenderOptions; +use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, } -struct CliSendHttpContext { +struct CliHostContext { + query_manager: QueryManager, blob_manager: BlobManager, plugin_manager: Arc, encryption_manager: Arc, @@ -41,7 +47,8 @@ impl CliPluginEventBridge { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); - let send_http_context = Arc::new(CliSendHttpContext { + let host_context = Arc::new(CliHostContext { + query_manager, blob_manager, plugin_manager, encryption_manager, @@ -65,21 +72,15 @@ impl CliPluginEventBridge { }; let pm = pm.clone(); - let query_manager = query_manager.clone(); - let send_http_context = send_http_context.clone(); + let host_context = host_context.clone(); // Avoid deadlocks for nested plugin-host requests (for example, template functions // that trigger additional host requests during render) by handling each event in // its own task. tokio::spawn(async move { let plugin_name = plugin_handle.info().name; - let Some(reply_payload) = build_plugin_reply( - &query_manager, - &event, - &plugin_name, - Some(send_http_context.as_ref()), - ) - .await + let Some(reply_payload) = + build_plugin_reply(host_context.as_ref(), &event, &plugin_name).await else { return; }; @@ -104,13 +105,12 @@ impl CliPluginEventBridge { } async fn build_plugin_reply( - query_manager: &QueryManager, + host_context: &CliHostContext, event: &InternalEvent, plugin_name: &str, - send_http_context: Option<&CliSendHttpContext>, ) -> Option { match handle_shared_plugin_event( - query_manager, + &host_context.query_manager, &event.payload, SharedPluginEventContext { plugin_name, @@ -129,7 +129,7 @@ async fn build_plugin_reply( Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})) } HostRequest::ListOpenWorkspaces(_) => { - let workspaces = match query_manager.connect().list_workspaces() { + let workspaces = match host_context.query_manager.connect().list_workspaces() { Ok(workspaces) => workspaces .into_iter() .map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id }) @@ -145,12 +145,6 @@ async fn build_plugin_reply( })) } HostRequest::SendHttpRequest(send_http_request_request) => { - let Some(send_ctx) = send_http_context else { - return Some(InternalEventPayload::ErrorResponse(ErrorResponse { - error: "Send HTTP request support is not initialized in CLI".to_string(), - })); - }; - let mut http_request = send_http_request_request.http_request.clone(); if http_request.workspace_id.is_empty() { let Some(workspace_id) = event.context.workspace_id.clone() else { @@ -168,18 +162,18 @@ async fn build_plugin_reply( } match send_http_request_with_plugins(SendHttpRequestWithPluginsParams { - query_manager, - blob_manager: &send_ctx.blob_manager, + query_manager: &host_context.query_manager, + blob_manager: &host_context.blob_manager, request: http_request, environment_id: None, update_source: UpdateSource::Plugin, cookie_jar_id: None, - response_dir: &send_ctx.response_dir, + response_dir: &host_context.response_dir, emit_events_to: None, emit_response_body_chunks_to: None, existing_response: None, - plugin_manager: send_ctx.plugin_manager.clone(), - encryption_manager: send_ctx.encryption_manager.clone(), + plugin_manager: host_context.plugin_manager.clone(), + encryption_manager: host_context.encryption_manager.clone(), plugin_context: &plugin_context, cancelled_rx: None, connection_manager: None, @@ -194,17 +188,62 @@ async fn build_plugin_reply( })), } } - HostRequest::CopyText(copy_text_request) => todo!("copy_text_request"), - HostRequest::PromptText(prompt_text_request) => todo!("prompt_text_request"), - HostRequest::PromptForm(prompt_form_request) => todo!("prompt_form_request"), - HostRequest::RenderGrpcRequest(render_grpc_request_request) => todo!("render_grpc"), - HostRequest::RenderHttpRequest(render_http_request_request) => { - let Some(send_ctx) = send_http_context else { - return Some(InternalEventPayload::ErrorResponse(ErrorResponse { - error: "Render HTTP request support is not initialized in CLI".to_string(), - })); - }; + HostRequest::RenderGrpcRequest(render_grpc_request_request) => { + let mut grpc_request = render_grpc_request_request.grpc_request.clone(); + if grpc_request.workspace_id.is_empty() { + let Some(workspace_id) = event.context.workspace_id.clone() else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render gRPC requests in CLI" + .to_string(), + })); + }; + grpc_request.workspace_id = workspace_id; + } + let mut plugin_context = event.context.clone(); + if plugin_context.workspace_id.is_none() { + plugin_context.workspace_id = Some(grpc_request.workspace_id.clone()); + } + + let environment_chain = + match host_context.query_manager.connect().resolve_environments( + &grpc_request.workspace_id, + grpc_request.folder_id.as_deref(), + None, + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), + &plugin_context, + render_grpc_request_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_grpc_request_for_cli( + &grpc_request, + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse( + RenderGrpcRequestResponse { grpc_request }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render gRPC request in CLI: {err}"), + })), + } + } + HostRequest::RenderHttpRequest(render_http_request_request) => { let mut http_request = render_http_request_request.http_request.clone(); if http_request.workspace_id.is_empty() { let Some(workspace_id) = event.context.workspace_id.clone() else { @@ -221,22 +260,23 @@ async fn build_plugin_reply( plugin_context.workspace_id = Some(http_request.workspace_id.clone()); } - let environment_chain = match query_manager.connect().resolve_environments( - &http_request.workspace_id, - http_request.folder_id.as_deref(), - None, - ) { - Ok(chain) => chain, - Err(err) => { - return Some(InternalEventPayload::ErrorResponse(ErrorResponse { - error: format!("Failed to resolve environments in CLI: {err}"), - })); - } - }; + let environment_chain = + match host_context.query_manager.connect().resolve_environments( + &http_request.workspace_id, + http_request.folder_id.as_deref(), + None, + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; let template_callback = PluginTemplateCallback::new( - send_ctx.plugin_manager.clone(), - send_ctx.encryption_manager.clone(), + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), &plugin_context, render_http_request_request.purpose.clone(), ); @@ -258,127 +298,175 @@ async fn build_plugin_reply( })), } } - HostRequest::TemplateRender(template_render_request) => todo!("template_render"), - HostRequest::OpenWindow(open_window_request) => todo!("open_window"), - HostRequest::CloseWindow(close_window_request) => todo!("close_window"), - HostRequest::OpenExternalUrl(open_external_url_request) => todo!("open_url"), - HostRequest::ListCookieNames(list_cookie_names_request) => todo!("list_cookie"), - HostRequest::GetCookieValue(get_cookie_value_request) => todo!("get_cookie"), - HostRequest::WindowInfo(window_info_request) => todo!("window_info"), - HostRequest::OtherRequest(internal_event_payload) => todo!("other"), + HostRequest::TemplateRender(template_render_request) => { + let Some(workspace_id) = event.context.workspace_id.clone() else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render templates in CLI".to_string(), + })); + }; + + let mut plugin_context = event.context.clone(); + if plugin_context.workspace_id.is_none() { + plugin_context.workspace_id = Some(workspace_id.clone()); + } + + let environment_chain = match host_context + .query_manager + .connect() + .resolve_environments(&workspace_id, None, None) + { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + host_context.plugin_manager.clone(), + host_context.encryption_manager.clone(), + &plugin_context, + template_render_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_json_value_for_cli( + template_render_request.data.clone(), + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(data) => { + Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { + data, + })) + } + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render template data in CLI: {err}"), + })), + } + } + HostRequest::OpenExternalUrl(open_external_url_request) => { + match webbrowser::open(open_external_url_request.url.as_str()) { + Ok(_) => Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to open external URL in CLI: {err}"), + })), + } + } + HostRequest::CopyText(_) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: copy_text_request".to_string(), + })), + HostRequest::PromptText(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: prompt_text_request".to_string(), + })) + } + HostRequest::PromptForm(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: prompt_form_request".to_string(), + })) + } + HostRequest::OpenWindow(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: open_window_request".to_string(), + })) + } + HostRequest::CloseWindow(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: close_window_request".to_string(), + })) + } + HostRequest::ListCookieNames(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: list_cookie_names_request" + .to_string(), + })) + } + HostRequest::GetCookieValue(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: get_cookie_value_request" + .to_string(), + })) + } + HostRequest::WindowInfo(_) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Unsupported plugin request in CLI: window_info_request".to_string(), + })) + } + HostRequest::OtherRequest(payload) => { + Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Unsupported plugin request in CLI: {}", payload.type_name()), + })) + } }, } } -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - use yaak_models::models::HttpRequest; - use yaak_plugins::events::{GetKeyValueRequest, PluginContext, SendHttpRequestRequest}; - - 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, - } - } - - #[tokio::test] - async 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, - None, - ) - .await; - 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, - None, - ) - .await; - assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_)))); - - let get_present = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest { - key: "token".to_string(), - })), - plugin_name, - None, - ) - .await; - 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, - None, - ) - .await; - match delete { - Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted), - other => panic!("unexpected payload for delete: {other:?}"), - } - } - - #[tokio::test] - async fn send_http_request_without_context_gets_error_reply() { - let (query_manager, _temp_dir) = query_manager_for_test(); - let payload = build_plugin_reply( - &query_manager, - &event(InternalEventPayload::SendHttpRequestRequest(SendHttpRequestRequest { - http_request: HttpRequest::default(), - })), - "@yaak/test-plugin", - None, - ) - .await; - - match payload { - Some(InternalEventPayload::ErrorResponse(err)) => { - assert!(err.error.contains("Send HTTP request support is not initialized in CLI")); - } - other => panic!("unexpected payload for unsupported request: {other:?}"), - } - } +async fn render_json_value_for_cli( + value: Value, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + render_json_value_raw(value, vars, cb, opt).await +} + +async fn render_grpc_request_for_cli( + grpc_request: &GrpcRequest, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + + let mut metadata = Vec::new(); + for p in grpc_request.metadata.clone() { + if !p.enabled { + continue; + } + metadata.push(HttpRequestHeader { + enabled: p.enabled, + name: parse_and_render(p.name.as_str(), vars, cb, opt).await?, + value: parse_and_render(p.value.as_str(), vars, cb, opt).await?, + id: p.id, + }) + } + + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match grpc_request.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, opt) + .await + .unwrap_or_default() + .is_empty(); + } + _ => {} + } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in grpc_request.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?); + } + } + } + auth + }; + + let url = parse_and_render(grpc_request.url.as_str(), vars, cb, opt).await?; + + Ok(GrpcRequest { url, metadata, authentication, ..grpc_request.to_owned() }) }