Add yaak-actions-builtin crate and integrate with CLI

This commit is contained in:
Gregory Schier
2026-02-01 08:59:35 -08:00
parent 50b0e23d53
commit 986143c4ae
9 changed files with 511 additions and 174 deletions

18
Cargo.lock generated
View File

@@ -8005,6 +8005,22 @@ dependencies = [
"ts-rs", "ts-rs",
] ]
[[package]]
name = "yaak-actions-builtin"
version = "0.1.0"
dependencies = [
"log",
"serde",
"serde_json",
"tokio",
"yaak-actions",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-templates",
]
[[package]] [[package]]
name = "yaak-app" name = "yaak-app"
version = "0.0.0" version = "0.0.0"
@@ -8074,6 +8090,8 @@ dependencies = [
"log", "log",
"serde_json", "serde_json",
"tokio", "tokio",
"yaak-actions",
"yaak-actions-builtin",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",

View File

@@ -3,6 +3,7 @@ resolver = "2"
members = [ members = [
# Shared crates (no Tauri dependency) # Shared crates (no Tauri dependency)
"crates/yaak-actions", "crates/yaak-actions",
"crates/yaak-actions-builtin",
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
"crates/yaak-crypto", "crates/yaak-crypto",
@@ -47,6 +48,7 @@ ts-rs = "11.1.0"
# Internal crates - shared # Internal crates - shared
yaak-actions = { path = "crates/yaak-actions" } yaak-actions = { path = "crates/yaak-actions" }
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
yaak-core = { path = "crates/yaak-core" } yaak-core = { path = "crates/yaak-core" }
yaak-common = { path = "crates/yaak-common" } yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" } yaak-crypto = { path = "crates/yaak-crypto" }

View File

@@ -15,6 +15,8 @@ env_logger = "0.11"
log = { workspace = true } log = { 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"] }
yaak-actions = { workspace = true }
yaak-actions-builtin = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }

View File

@@ -1,21 +1,13 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use log::info;
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::sync::mpsc; use tokio::sync::mpsc;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender}; use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions}; use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter}; use yaak_models::models::HttpRequest;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::events::{PluginContext, RenderPurpose}; use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "yaakcli")] #[command(name = "yaakcli")]
@@ -72,86 +64,6 @@ enum Commands {
}, },
} }
/// Render an HTTP request with template variables and plugin functions
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
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 mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.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 mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.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();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.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(r.url.clone().as_str(), vars, cb, opt).await?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -176,10 +88,6 @@ async fn main() {
let db = query_manager.connect(); let db = query_manager.connect();
// Initialize encryption manager for secure() template function
// Use the same app_id as the Tauri app for keyring access
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
// Initialize plugin manager for template functions // Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins"); let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-plugins"); let installed_plugin_dir = data_dir.join("installed-plugins");
@@ -198,9 +106,9 @@ async fn main() {
// Create plugin manager (plugins may not be available in CLI context) // Create plugin manager (plugins may not be available in CLI context)
let plugin_manager = Arc::new( let plugin_manager = Arc::new(
PluginManager::new( PluginManager::new(
vendored_plugin_dir, vendored_plugin_dir.clone(),
installed_plugin_dir, installed_plugin_dir.clone(),
node_bin_path, node_bin_path.clone(),
plugin_runtime_main, plugin_runtime_main,
false, false,
) )
@@ -239,94 +147,67 @@ async fn main() {
} }
} }
Commands::Send { request_id } => { Commands::Send { request_id } => {
let request = db.get_http_request(&request_id).expect("Failed to get request"); use yaak_actions::{
ActionExecutor, ActionId, ActionParams, ActionResult, ActionTarget, CurrentContext,
};
use yaak_actions_builtin::{BuiltinActionDependencies, register_http_actions};
// Resolve environment chain for variable substitution // Create dependencies
let environment_chain = db let deps = BuiltinActionDependencies::new_standalone(
.resolve_environments( &db_path,
&request.workspace_id, &blob_path,
request.folder_id.as_deref(), &app_id,
cli.environment.as_deref(), vendored_plugin_dir.clone(),
) installed_plugin_dir.clone(),
.unwrap_or_default(); node_bin_path.clone(),
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
) )
.await .await
.expect("Failed to render request templates"); .expect("Failed to initialize dependencies");
if cli.verbose { // Create executor and register actions
println!("> {} {}", rendered_request.method, rendered_request.url); let executor = ActionExecutor::new();
} executor.register_builtin_groups().await.expect("Failed to register groups");
register_http_actions(&executor, &deps).await.expect("Failed to register HTTP actions");
// Convert to sendable request // Prepare context
let sendable = SendableHttpRequest::from_http_request( let context = CurrentContext {
&rendered_request, target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
SendableHttpRequestOptions::default(), environment_id: cli.environment.clone(),
) workspace_id: None,
.await has_window: false,
.expect("Failed to build request"); can_prompt: false,
// Create event channel for progress
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to print events if verbose
let verbose = cli.verbose;
let verbose_handle = if verbose {
Some(tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
println!("{}", event);
}
}))
} else {
// Drain events silently
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
None
}; };
// Send the request // Prepare params
let sender = ReqwestSender::new().expect("Failed to create HTTP client"); let params = ActionParams {
let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); data: serde_json::json!({
"render": true,
"follow_redirects": false,
"timeout_ms": 30000,
}),
};
// Wait for event handler to finish // Invoke action
if let Some(handle) = verbose_handle { let action_id = ActionId::builtin("http", "send-request");
let _ = handle.await; let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
}
// Print response // Handle result
if verbose { match result {
println!(); ActionResult::Success { data, message } => {
} if let Some(msg) = message {
println!( println!("{}", msg);
"HTTP {} {}", }
response.status, if let Some(data) = data {
response.status_reason.as_deref().unwrap_or("") println!("{}", serde_json::to_string_pretty(&data).unwrap());
); }
}
if verbose { ActionResult::RequiresInput { .. } => {
for (name, value) in &response.headers { eprintln!("Action requires input (not supported in CLI)");
println!("{}: {}", name, value); }
ActionResult::Cancelled => {
eprintln!("Action cancelled");
} }
println!();
} }
// Print body
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
} }
Commands::Get { url } => { Commands::Get { url } => {
if cli.verbose { if cli.verbose {

View File

@@ -0,0 +1,18 @@
[package]
name = "yaak-actions-builtin"
version = "0.1.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
yaak-actions = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-templates = { workspace = true }
yaak-plugins = { workspace = true }
yaak-crypto = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt-multi-thread"] }
log = { workspace = true }

View File

@@ -0,0 +1,88 @@
//! Dependency injection for built-in actions.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use yaak_crypto::manager::EncryptionManager;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Dependencies needed by built-in action implementations.
///
/// This struct bundles all the dependencies that action handlers need,
/// providing a clean way to initialize them in different contexts
/// (CLI, Tauri app, MCP server, etc.).
pub struct BuiltinActionDependencies {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
impl BuiltinActionDependencies {
/// Create dependencies for standalone usage (CLI, MCP server, etc.)
///
/// This initializes all the necessary managers following the same pattern
/// as the yaak-cli implementation.
pub async fn new_standalone(
db_path: &Path,
blob_path: &Path,
app_id: &str,
plugin_vendored_dir: PathBuf,
plugin_installed_dir: PathBuf,
node_path: PathBuf,
) -> Result<Self, Box<dyn std::error::Error>> {
// Initialize database
let (query_manager, _, _) = yaak_models::init_standalone(db_path, blob_path)?;
// Initialize encryption manager (takes QueryManager by value)
let encryption_manager = Arc::new(EncryptionManager::new(
query_manager.clone(),
app_id.to_string(),
));
let query_manager = Arc::new(query_manager);
// Find plugin runtime
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
// Development fallback
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
// Initialize plugin manager
let plugin_manager = Arc::new(
PluginManager::new(
plugin_vendored_dir,
plugin_installed_dir,
node_path,
plugin_runtime_main,
false, // not sandboxed in CLI
)
.await,
);
// Initialize plugins from database
let db = query_manager.connect();
let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors = plugin_manager
.initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors {
log::warn!(
"Failed to initialize plugin '{}': {}",
plugin_dir,
error_msg
);
}
}
Ok(Self {
query_manager,
plugin_manager,
encryption_manager,
})
}
}

View File

@@ -0,0 +1,24 @@
//! HTTP action implementations.
pub mod send;
use crate::BuiltinActionDependencies;
use yaak_actions::{ActionError, ActionExecutor, ActionSource};
/// Register all HTTP-related actions with the executor.
pub async fn register_http_actions(
executor: &ActionExecutor,
deps: &BuiltinActionDependencies,
) -> Result<(), ActionError> {
let handler = send::HttpSendActionHandler {
query_manager: deps.query_manager.clone(),
plugin_manager: deps.plugin_manager.clone(),
encryption_manager: deps.encryption_manager.clone(),
};
executor
.register(send::metadata(), ActionSource::Builtin, handler)
.await?;
Ok(())
}

View File

@@ -0,0 +1,293 @@
//! HTTP send action implementation.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use yaak_actions::{
ActionError, ActionGroupId, ActionHandler, ActionId, ActionMetadata,
ActionParams, ActionResult, ActionScope, CurrentContext,
RequiredContext,
};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
/// Handler for HTTP send action.
pub struct HttpSendActionHandler {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
/// Metadata for the HTTP send action.
pub fn metadata() -> ActionMetadata {
ActionMetadata {
id: ActionId::builtin("http", "send-request"),
label: "Send HTTP Request".to_string(),
description: Some("Execute an HTTP request and return the response".to_string()),
icon: Some("play".to_string()),
scope: ActionScope::HttpRequest,
keyboard_shortcut: None,
requires_selection: true,
enabled_condition: None,
group_id: Some(ActionGroupId::builtin("send")),
order: 10,
required_context: RequiredContext::requires_target(),
}
}
impl ActionHandler for HttpSendActionHandler {
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ActionResult, ActionError>> + Send + 'static>,
> {
let query_manager = self.query_manager.clone();
let plugin_manager = self.plugin_manager.clone();
let encryption_manager = self.encryption_manager.clone();
Box::pin(async move {
// Extract request_id from context
let request_id = context
.target
.as_ref()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target".to_string()],
}
})?
.id()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target.id".to_string()],
}
})?
.to_string();
// Fetch request and environment from database (synchronous)
let (request, environment_chain) = {
let db = query_manager.connect();
// Fetch HTTP request from database
let request = db.get_http_request(&request_id).map_err(|e| {
ActionError::Internal(format!("Failed to fetch request {}: {}", request_id, e))
})?;
// Resolve environment chain for variable substitution
let environment_chain = if let Some(env_id) = &context.environment_id {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
Some(env_id),
)
.unwrap_or_default()
} else {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
None,
)
.unwrap_or_default()
};
(request, environment_chain)
}; // db is dropped here
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
.map_err(|e| ActionError::Internal(format!("Failed to render request: {}", e)))?;
// Build sendable request
let options = SendableHttpRequestOptions {
timeout: params
.data
.get("timeout_ms")
.and_then(|v| v.as_u64())
.map(|ms| std::time::Duration::from_millis(ms)),
follow_redirects: params
.data
.get("follow_redirects")
.and_then(|v| v.as_bool())
.unwrap_or(false),
};
let sendable = SendableHttpRequest::from_http_request(&rendered_request, options)
.await
.map_err(|e| ActionError::Internal(format!("Failed to build request: {}", e)))?;
// Create event channel
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to drain events
let _event_handle = tokio::spawn(async move {
while event_rx.recv().await.is_some() {
// For now, just drain events
// In the future, we could log them or emit them to UI
}
});
// Send the request
let sender = ReqwestSender::new()
.map_err(|e| ActionError::Internal(format!("Failed to create HTTP client: {}", e)))?;
let response = sender
.send(sendable, event_tx)
.await
.map_err(|e| ActionError::Internal(format!("Failed to send request: {}", e)))?;
// Consume response body
let status = response.status;
let status_reason = response.status_reason.clone();
let headers = response.headers.clone();
let url = response.url.clone();
let (body_text, stats) = response
.text()
.await
.map_err(|e| ActionError::Internal(format!("Failed to read response body: {}", e)))?;
// Return success result with response data
Ok(ActionResult::Success {
data: Some(json!({
"status": status,
"statusReason": status_reason,
"headers": headers,
"body": body_text,
"contentLength": stats.size_decompressed,
"url": url,
})),
message: Some(format!("HTTP {}", status)),
})
})
}
}
/// Helper function to render templates in an HTTP request.
/// Copied from yaak-cli implementation.
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> Result<HttpRequest, String> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.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 r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest {
url,
url_parameters,
headers,
body,
authentication,
..r.to_owned()
})
}

View File

@@ -0,0 +1,11 @@
//! Built-in action implementations for Yaak.
//!
//! This crate provides concrete implementations of built-in actions using
//! the yaak-actions framework. It depends on domain-specific crates like
//! yaak-http, yaak-models, yaak-plugins, etc.
pub mod dependencies;
pub mod http;
pub use dependencies::BuiltinActionDependencies;
pub use http::register_http_actions;