use crate::cli::{RequestArgs, RequestCommands, RequestSchemaType}; use crate::context::CliContext; use crate::utils::confirm::confirm_delete; use crate::utils::json::{ apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json, parse_required_json, require_id, validate_create_id, }; use crate::utils::schema::append_agent_hints; use crate::utils::workspace::resolve_workspace_id; use schemars::schema_for; use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::io::Write; use tokio::sync::mpsc; use yaak::send::{SendHttpRequestByIdWithPluginsParams, send_http_request_by_id_with_plugins}; use yaak_http::sender::HttpResponseEvent as SenderHttpResponseEvent; use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest}; use yaak_models::queries::any_request::AnyRequest; use yaak_models::util::UpdateSource; use yaak_plugins::events::{FormInput, FormInputBase, JsonPrimitive, PluginContext}; type CommandResult = std::result::Result; pub async fn run( ctx: &CliContext, args: RequestArgs, environment: Option<&str>, cookie_jar_id: Option<&str>, verbose: bool, ) -> i32 { let result = match args.command { RequestCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()), RequestCommands::Show { request_id } => show(ctx, &request_id), RequestCommands::Send { request_id } => { return match send_request_by_id(ctx, &request_id, environment, cookie_jar_id, verbose) .await { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); 1 } }; } RequestCommands::Schema { request_type, pretty } => { return match schema(ctx, request_type, pretty).await { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); 1 } }; } RequestCommands::Create { workspace_id, name, method, url, json } => { create(ctx, workspace_id, name, method, url, json) } RequestCommands::Update { json, json_input } => update(ctx, json, json_input), RequestCommands::Delete { request_id, yes } => delete(ctx, &request_id, yes), }; match result { Ok(()) => 0, Err(error) => { eprintln!("Error: {error}"); 1 } } } fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult { let workspace_id = resolve_workspace_id(ctx, workspace_id, "request list")?; let requests = ctx .db() .list_http_requests(&workspace_id) .map_err(|e| format!("Failed to list requests: {e}"))?; if requests.is_empty() { println!("No requests found in workspace {}", workspace_id); } else { for request in requests { println!("{} - {} {}", request.id, request.method, request.name); } } Ok(()) } async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> 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}"))?, }; enrich_schema_guidance(&mut schema, request_type); append_agent_hints(&mut schema); 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 = if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) } .map_err(|e| format!("Failed to format schema JSON: {e}"))?; println!("{output}"); Ok(()) } fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) { if !matches!(request_type, RequestSchemaType::Http) { return; } let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return; }; if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) { append_description( url_schema, "For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).", ); } } fn append_description(schema: &mut Map, extra: &str) { match schema.get_mut("description") { Some(Value::String(existing)) if !existing.trim().is_empty() => { if !existing.ends_with(' ') { existing.push(' '); } existing.push_str(extra); } _ => { schema.insert("description".to_string(), Value::String(extra.to_string())); } } } async fn merge_auth_schema_from_plugins( ctx: &CliContext, schema: &mut Value, ) -> Result<(), String> { let plugin_context = PluginContext::new_empty(); let plugin_manager = ctx.plugin_manager(); let summaries = plugin_manager .get_http_authentication_summaries(&plugin_context) .await .map_err(|e| e.to_string())?; let mut auth_variants = Vec::new(); for (_, summary) in summaries { let config = match plugin_manager .get_http_authentication_config( &plugin_context, &summary.name, HashMap::::new(), "yaakcli_request_schema", ) .await { Ok(config) => config, Err(error) => { eprintln!( "Warning: Failed to load auth config for strategy '{}': {}", summary.name, error ); continue; } }; auth_variants.push(auth_variant_schema(&summary.name, &summary.label, &config.args)); } let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return Ok(()); }; let Some(auth_schema) = properties.get_mut("authentication") else { return Ok(()); }; if !auth_variants.is_empty() { let mut one_of = vec![auth_schema.clone()]; one_of.extend(auth_variants); *auth_schema = json!({ "oneOf": one_of }); } Ok(()) } fn auth_variant_schema(auth_name: &str, auth_label: &str, args: &[FormInput]) -> Value { let mut properties = Map::new(); let mut required = Vec::new(); for input in args { add_input_schema(input, &mut properties, &mut required); } let mut schema = json!({ "title": auth_label, "description": format!("Authentication values for strategy '{}'", auth_name), "type": "object", "properties": properties, "additionalProperties": true }); if !required.is_empty() { schema["required"] = json!(required); } schema } fn add_input_schema( input: &FormInput, properties: &mut Map, required: &mut Vec, ) { match input { FormInput::Text(v) => add_base_schema( &v.base, json!({ "type": "string", "writeOnly": v.password.unwrap_or(false), }), properties, required, ), FormInput::Editor(v) => add_base_schema( &v.base, json!({ "type": "string", "x-editorLanguage": v.language.clone(), }), properties, required, ), FormInput::Select(v) => { let options: Vec = v.options.iter().map(|o| Value::String(o.value.clone())).collect(); add_base_schema( &v.base, json!({ "type": "string", "enum": options, }), properties, required, ); } FormInput::Checkbox(v) => { add_base_schema(&v.base, json!({ "type": "boolean" }), properties, required); } FormInput::File(v) => { if v.multiple.unwrap_or(false) { add_base_schema( &v.base, json!({ "type": "array", "items": { "type": "string" }, }), properties, required, ); } else { add_base_schema(&v.base, json!({ "type": "string" }), properties, required); } } FormInput::HttpRequest(v) => { add_base_schema(&v.base, json!({ "type": "string" }), properties, required); } FormInput::KeyValue(v) => { add_base_schema( &v.base, json!({ "type": "object", "additionalProperties": true, }), properties, required, ); } FormInput::Accordion(v) => { if let Some(children) = &v.inputs { for child in children { add_input_schema(child, properties, required); } } } FormInput::HStack(v) => { if let Some(children) = &v.inputs { for child in children { add_input_schema(child, properties, required); } } } FormInput::Banner(v) => { if let Some(children) = &v.inputs { for child in children { add_input_schema(child, properties, required); } } } FormInput::Markdown(_) => {} } } fn add_base_schema( base: &FormInputBase, mut schema: Value, properties: &mut Map, required: &mut Vec, ) { if base.hidden.unwrap_or(false) || base.name.trim().is_empty() { return; } if let Some(description) = &base.description { schema["description"] = Value::String(description.clone()); } if let Some(label) = &base.label { schema["title"] = Value::String(label.clone()); } if let Some(default_value) = &base.default_value { schema["default"] = Value::String(default_value.clone()); } let name = base.name.clone(); properties.insert(name.clone(), schema); if !base.optional.unwrap_or(false) { required.push(name); } } fn create( ctx: &CliContext, workspace_id: Option, name: Option, method: Option, url: Option, json: Option, ) -> CommandResult { let json_shorthand = workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned); let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v)); let payload = parse_optional_json(json, json_shorthand, "request create")?; if let Some(payload) = payload { if name.is_some() || method.is_some() || url.is_some() { return Err("request create cannot combine simple flags with JSON payload".to_string()); } validate_create_id(&payload, "request")?; let mut request: HttpRequest = serde_json::from_value(payload) .map_err(|e| format!("Failed to parse request create JSON: {e}"))?; let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty() { Some(resolve_workspace_id(ctx, None, "request create")?) } else { None }; merge_workspace_id_arg( workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()), &mut request.workspace_id, "request create", )?; let created = ctx .db() .upsert_http_request(&request, &UpdateSource::Sync) .map_err(|e| format!("Failed to create request: {e}"))?; println!("Created request: {}", created.id); return Ok(()); } let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?; let name = name.unwrap_or_default(); let url = url.unwrap_or_default(); let method = method.unwrap_or_else(|| "GET".to_string()); let request = HttpRequest { workspace_id, name, method: method.to_uppercase(), url, ..Default::default() }; let created = ctx .db() .upsert_http_request(&request, &UpdateSource::Sync) .map_err(|e| format!("Failed to create request: {e}"))?; println!("Created request: {}", created.id); Ok(()) } fn update(ctx: &CliContext, json: Option, json_input: Option) -> CommandResult { let patch = parse_required_json(json, json_input, "request update")?; let id = require_id(&patch, "request update")?; let existing = ctx .db() .get_http_request(&id) .map_err(|e| format!("Failed to get request for update: {e}"))?; let updated = apply_merge_patch(&existing, &patch, &id, "request update")?; let saved = ctx .db() .upsert_http_request(&updated, &UpdateSource::Sync) .map_err(|e| format!("Failed to update request: {e}"))?; println!("Updated request: {}", saved.id); Ok(()) } fn show(ctx: &CliContext, request_id: &str) -> CommandResult { let request = ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?; let output = serde_json::to_string_pretty(&request) .map_err(|e| format!("Failed to serialize request: {e}"))?; println!("{output}"); Ok(()) } fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult { if !yes && !confirm_delete("request", request_id) { println!("Aborted"); return Ok(()); } let deleted = ctx .db() .delete_http_request_by_id(request_id, &UpdateSource::Sync) .map_err(|e| format!("Failed to delete request: {e}"))?; println!("Deleted request: {}", deleted.id); Ok(()) } /// Send a request by ID and print response in the same format as legacy `send`. pub async fn send_request_by_id( ctx: &CliContext, request_id: &str, environment: Option<&str>, cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { let request = 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, cookie_jar_id, 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()) } } } async fn send_http_request_by_id( ctx: &CliContext, request_id: &str, workspace_id: &str, environment: Option<&str>, cookie_jar_id: Option<&str>, verbose: bool, ) -> Result<(), String> { let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?; let plugin_context = PluginContext::new(Some("cli".to_string()), Some(workspace_id.to_string())); let (event_tx, mut event_rx) = mpsc::channel::(100); let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::>(); let event_handle = tokio::spawn(async move { while let Some(event) = event_rx.recv().await { if verbose && !matches!(event, SenderHttpResponseEvent::ChunkReceived { .. }) { println!("{}", event); } } }); let body_handle = tokio::task::spawn_blocking(move || { let mut stdout = std::io::stdout(); while let Some(chunk) = body_chunk_rx.blocking_recv() { if stdout.write_all(&chunk).is_err() { break; } let _ = stdout.flush(); } }); let response_dir = ctx.data_dir().join("responses"); let result = send_http_request_by_id_with_plugins(SendHttpRequestByIdWithPluginsParams { query_manager: ctx.query_manager(), blob_manager: ctx.blob_manager(), request_id, environment_id: environment, update_source: UpdateSource::Sync, cookie_jar_id, response_dir: &response_dir, emit_events_to: Some(event_tx), emit_response_body_chunks_to: Some(body_chunk_tx), plugin_manager: ctx.plugin_manager(), encryption_manager: ctx.encryption_manager.clone(), plugin_context: &plugin_context, cancelled_rx: None, connection_manager: None, }) .await; let _ = event_handle.await; let _ = body_handle.await; result.map_err(|e| e.to_string())?; Ok(()) } pub(crate) fn resolve_cookie_jar_id( ctx: &CliContext, workspace_id: &str, explicit_cookie_jar_id: Option<&str>, ) -> Result, String> { if let Some(cookie_jar_id) = explicit_cookie_jar_id { return Ok(Some(cookie_jar_id.to_string())); } let default_cookie_jar = ctx .db() .list_cookie_jars(workspace_id) .map_err(|e| format!("Failed to list cookie jars: {e}"))? .into_iter() .min_by_key(|jar| jar.created_at) .map(|jar| jar.id); Ok(default_cookie_jar) }