cli: add grpc/template render handlers and unsupported event errors

This commit is contained in:
Gregory Schier
2026-03-03 09:00:14 -08:00
parent fb5ad8c7f7
commit ef63b88710

View File

@@ -1,3 +1,5 @@
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::task::JoinHandle; 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::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
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::models::{Environment, GrpcRequest, HttpRequestHeader};
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::events::{ use yaak_plugins::events::{
EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse, EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse,
RenderHttpRequestResponse, SendHttpRequestResponse, WorkspaceInfo, RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
TemplateRenderResponse, WorkspaceInfo,
}; };
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback; 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 { pub struct CliPluginEventBridge {
rx_id: String, rx_id: String,
task: JoinHandle<()>, task: JoinHandle<()>,
} }
struct CliSendHttpContext { struct CliHostContext {
query_manager: QueryManager,
blob_manager: BlobManager, blob_manager: BlobManager,
plugin_manager: Arc<PluginManager>, plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>, encryption_manager: Arc<EncryptionManager>,
@@ -41,7 +47,8 @@ impl CliPluginEventBridge {
let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let (rx_id, mut rx) = plugin_manager.subscribe("cli").await;
let rx_id_for_task = rx_id.clone(); let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.clone(); let pm = plugin_manager.clone();
let send_http_context = Arc::new(CliSendHttpContext { let host_context = Arc::new(CliHostContext {
query_manager,
blob_manager, blob_manager,
plugin_manager, plugin_manager,
encryption_manager, encryption_manager,
@@ -65,21 +72,15 @@ impl CliPluginEventBridge {
}; };
let pm = pm.clone(); let pm = pm.clone();
let query_manager = query_manager.clone(); let host_context = host_context.clone();
let send_http_context = send_http_context.clone();
// Avoid deadlocks for nested plugin-host requests (for example, template functions // Avoid deadlocks for nested plugin-host requests (for example, template functions
// that trigger additional host requests during render) by handling each event in // that trigger additional host requests during render) by handling each event in
// its own task. // its own task.
tokio::spawn(async move { tokio::spawn(async move {
let plugin_name = plugin_handle.info().name; let plugin_name = plugin_handle.info().name;
let Some(reply_payload) = build_plugin_reply( let Some(reply_payload) =
&query_manager, build_plugin_reply(host_context.as_ref(), &event, &plugin_name).await
&event,
&plugin_name,
Some(send_http_context.as_ref()),
)
.await
else { else {
return; return;
}; };
@@ -104,13 +105,12 @@ impl CliPluginEventBridge {
} }
async fn build_plugin_reply( async fn build_plugin_reply(
query_manager: &QueryManager, host_context: &CliHostContext,
event: &InternalEvent, event: &InternalEvent,
plugin_name: &str, plugin_name: &str,
send_http_context: Option<&CliSendHttpContext>,
) -> Option<InternalEventPayload> { ) -> Option<InternalEventPayload> {
match handle_shared_plugin_event( match handle_shared_plugin_event(
query_manager, &host_context.query_manager,
&event.payload, &event.payload,
SharedPluginEventContext { SharedPluginEventContext {
plugin_name, plugin_name,
@@ -129,7 +129,7 @@ async fn build_plugin_reply(
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})) Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
} }
HostRequest::ListOpenWorkspaces(_) => { HostRequest::ListOpenWorkspaces(_) => {
let workspaces = match query_manager.connect().list_workspaces() { let workspaces = match host_context.query_manager.connect().list_workspaces() {
Ok(workspaces) => workspaces Ok(workspaces) => workspaces
.into_iter() .into_iter()
.map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id }) .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) => { 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(); let mut http_request = send_http_request_request.http_request.clone();
if http_request.workspace_id.is_empty() { if http_request.workspace_id.is_empty() {
let Some(workspace_id) = event.context.workspace_id.clone() else { 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 { match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager, query_manager: &host_context.query_manager,
blob_manager: &send_ctx.blob_manager, blob_manager: &host_context.blob_manager,
request: http_request, request: http_request,
environment_id: None, environment_id: None,
update_source: UpdateSource::Plugin, update_source: UpdateSource::Plugin,
cookie_jar_id: None, cookie_jar_id: None,
response_dir: &send_ctx.response_dir, response_dir: &host_context.response_dir,
emit_events_to: None, emit_events_to: None,
emit_response_body_chunks_to: None, emit_response_body_chunks_to: None,
existing_response: None, existing_response: None,
plugin_manager: send_ctx.plugin_manager.clone(), plugin_manager: host_context.plugin_manager.clone(),
encryption_manager: send_ctx.encryption_manager.clone(), encryption_manager: host_context.encryption_manager.clone(),
plugin_context: &plugin_context, plugin_context: &plugin_context,
cancelled_rx: None, cancelled_rx: None,
connection_manager: None, connection_manager: None,
@@ -194,17 +188,62 @@ async fn build_plugin_reply(
})), })),
} }
} }
HostRequest::CopyText(copy_text_request) => todo!("copy_text_request"), HostRequest::RenderGrpcRequest(render_grpc_request_request) => {
HostRequest::PromptText(prompt_text_request) => todo!("prompt_text_request"), let mut grpc_request = render_grpc_request_request.grpc_request.clone();
HostRequest::PromptForm(prompt_form_request) => todo!("prompt_form_request"), if grpc_request.workspace_id.is_empty() {
HostRequest::RenderGrpcRequest(render_grpc_request_request) => todo!("render_grpc"), let Some(workspace_id) = event.context.workspace_id.clone() else {
HostRequest::RenderHttpRequest(render_http_request_request) => { return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
let Some(send_ctx) = send_http_context else { error: "workspace_id is required to render gRPC requests in CLI"
return Some(InternalEventPayload::ErrorResponse(ErrorResponse { .to_string(),
error: "Render HTTP request support is not initialized 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(); let mut http_request = render_http_request_request.http_request.clone();
if http_request.workspace_id.is_empty() { if http_request.workspace_id.is_empty() {
let Some(workspace_id) = event.context.workspace_id.clone() else { 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()); plugin_context.workspace_id = Some(http_request.workspace_id.clone());
} }
let environment_chain = match query_manager.connect().resolve_environments( let environment_chain =
&http_request.workspace_id, match host_context.query_manager.connect().resolve_environments(
http_request.folder_id.as_deref(), &http_request.workspace_id,
None, http_request.folder_id.as_deref(),
) { None,
Ok(chain) => chain, ) {
Err(err) => { Ok(chain) => chain,
return Some(InternalEventPayload::ErrorResponse(ErrorResponse { Err(err) => {
error: format!("Failed to resolve environments in CLI: {err}"), return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
})); error: format!("Failed to resolve environments in CLI: {err}"),
} }));
}; }
};
let template_callback = PluginTemplateCallback::new( let template_callback = PluginTemplateCallback::new(
send_ctx.plugin_manager.clone(), host_context.plugin_manager.clone(),
send_ctx.encryption_manager.clone(), host_context.encryption_manager.clone(),
&plugin_context, &plugin_context,
render_http_request_request.purpose.clone(), render_http_request_request.purpose.clone(),
); );
@@ -258,127 +298,175 @@ async fn build_plugin_reply(
})), })),
} }
} }
HostRequest::TemplateRender(template_render_request) => todo!("template_render"), HostRequest::TemplateRender(template_render_request) => {
HostRequest::OpenWindow(open_window_request) => todo!("open_window"), let Some(workspace_id) = event.context.workspace_id.clone() else {
HostRequest::CloseWindow(close_window_request) => todo!("close_window"), return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
HostRequest::OpenExternalUrl(open_external_url_request) => todo!("open_url"), error: "workspace_id is required to render templates in CLI".to_string(),
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"), 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)] async fn render_json_value_for_cli<T: TemplateCallback>(
mod tests { value: Value,
use super::*; environment_chain: Vec<Environment>,
use tempfile::TempDir; cb: &T,
use yaak_models::models::HttpRequest; opt: &RenderOptions,
use yaak_plugins::events::{GetKeyValueRequest, PluginContext, SendHttpRequestRequest}; ) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
fn query_manager_for_test() -> (QueryManager, TempDir) { render_json_value_raw(value, vars, cb, opt).await
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"); async fn render_grpc_request_for_cli<T: TemplateCallback>(
let (query_manager, _blob_manager, _rx) = grpc_request: &GrpcRequest,
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB"); environment_chain: Vec<Environment>,
(query_manager, temp_dir) cb: &T,
} opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
fn event(payload: InternalEventPayload) -> InternalEvent { let vars = &make_vars_hashmap(environment_chain);
InternalEvent {
id: "evt_1".to_string(), let mut metadata = Vec::new();
plugin_ref_id: "plugin_ref_1".to_string(), for p in grpc_request.metadata.clone() {
plugin_name: "@yaak/test-plugin".to_string(), if !p.enabled {
reply_id: None, continue;
context: PluginContext::new_empty(), }
payload, 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?,
#[tokio::test] id: p.id,
async fn key_value_requests_round_trip() { })
let (query_manager, _temp_dir) = query_manager_for_test(); }
let plugin_name = "@yaak/test-plugin";
let authentication = {
let get_missing = build_plugin_reply( let mut disabled = false;
&query_manager, let mut auth = BTreeMap::new();
&event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest { match grpc_request.authentication.get("disabled") {
key: "missing".to_string(), Some(Value::Bool(true)) => {
})), disabled = true;
plugin_name, }
None, Some(Value::String(tmpl)) => {
) disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await; .await
match get_missing { .unwrap_or_default()
Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None), .is_empty();
other => panic!("unexpected payload for missing get: {other:?}"), }
} _ => {}
}
let set = build_plugin_reply( if disabled {
&query_manager, auth.insert("disabled".to_string(), Value::Bool(true));
&event(InternalEventPayload::SetKeyValueRequest( } else {
yaak_plugins::events::SetKeyValueRequest { for (k, v) in grpc_request.authentication.clone() {
key: "token".to_string(), if k == "disabled" {
value: "{\"access_token\":\"abc\"}".to_string(), auth.insert(k, Value::Bool(false));
}, } else {
)), auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
plugin_name, }
None, }
) }
.await; auth
assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_)))); };
let get_present = build_plugin_reply( let url = parse_and_render(grpc_request.url.as_str(), vars, cb, opt).await?;
&query_manager,
&event(InternalEventPayload::GetKeyValueRequest(GetKeyValueRequest { Ok(GrpcRequest { url, metadata, authentication, ..grpc_request.to_owned() })
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:?}"),
}
}
} }