CLI command architecture and DB-backed model update syncing (#397)

This commit is contained in:
Gregory Schier
2026-02-17 08:20:31 -08:00
committed by GitHub
parent 0a4ffde319
commit e1580210dc
48 changed files with 3818 additions and 1180 deletions

View File

@@ -0,0 +1,159 @@
use crate::cli::{EnvironmentArgs, EnvironmentCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id,
validate_create_id,
};
use yaak_models::models::Environment;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {
let result = match args.command {
EnvironmentCommands::List { workspace_id } => list(ctx, &workspace_id),
EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id),
EnvironmentCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
}
EnvironmentCommands::Update { json, json_input } => update(ctx, json, json_input),
EnvironmentCommands::Delete { environment_id, yes } => delete(ctx, &environment_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
let environments = ctx
.db()
.list_environments_ensure_base(workspace_id)
.map_err(|e| format!("Failed to list environments: {e}"))?;
if environments.is_empty() {
println!("No environments found in workspace {}", workspace_id);
} else {
for environment in environments {
println!("{} - {} ({})", environment.id, environment.name, environment.parent_model);
}
}
Ok(())
}
fn show(ctx: &CliContext, environment_id: &str) -> CommandResult {
let environment = ctx
.db()
.get_environment(environment_id)
.map_err(|e| format!("Failed to get environment: {e}"))?;
let output =
serde_json::to_string_pretty(&environment).map_err(|e| format!("Failed to serialize environment: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err(
"environment create cannot combine workspace_id with --json payload".to_string()
);
}
let payload = parse_optional_json(
json,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"environment create",
)?;
if let Some(payload) = payload {
if name.is_some() {
return Err("environment create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "environment")?;
let mut environment: Environment =
serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
if environment.workspace_id.is_empty() {
return Err("environment create JSON requires non-empty \"workspaceId\"".to_string());
}
if environment.parent_model.is_empty() {
environment.parent_model = "environment".to_string();
}
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
return Ok(());
}
let workspace_id = workspace_id.ok_or_else(|| {
"environment create requires workspace_id unless JSON payload is provided".to_string()
})?;
let name = name
.ok_or_else(|| "environment create requires --name unless JSON payload is provided".to_string())?;
let environment = Environment {
workspace_id,
name,
parent_model: "environment".to_string(),
..Default::default()
};
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "environment update")?;
let id = require_id(&patch, "environment update")?;
let existing = ctx
.db()
.get_environment(&id)
.map_err(|e| format!("Failed to get environment for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
let saved = ctx
.db()
.upsert_environment(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update environment: {e}"))?;
println!("Updated environment: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("environment", environment_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete environment: {e}"))?;
println!("Deleted environment: {}", deleted.id);
Ok(())
}

View File

@@ -0,0 +1,139 @@
use crate::cli::{FolderArgs, FolderCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id,
validate_create_id,
};
use yaak_models::models::Folder;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
let result = match args.command {
FolderCommands::List { workspace_id } => list(ctx, &workspace_id),
FolderCommands::Show { folder_id } => show(ctx, &folder_id),
FolderCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
}
FolderCommands::Update { json, json_input } => update(ctx, json, json_input),
FolderCommands::Delete { folder_id, yes } => delete(ctx, &folder_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
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}"))?;
if folders.is_empty() {
println!("No folders found in workspace {}", workspace_id);
} else {
for folder in folders {
println!("{} - {}", folder.id, folder.name);
}
}
Ok(())
}
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 output =
serde_json::to_string_pretty(&folder).map_err(|e| format!("Failed to serialize folder: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err("folder create cannot combine workspace_id with --json payload".to_string());
}
let payload = parse_optional_json(
json,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"folder create",
)?;
if let Some(payload) = payload {
if name.is_some() {
return Err("folder create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "folder")?;
let folder: Folder =
serde_json::from_value(payload).map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
if folder.workspace_id.is_empty() {
return Err("folder create JSON requires non-empty \"workspaceId\"".to_string());
}
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
return Ok(());
}
let workspace_id = workspace_id
.ok_or_else(|| "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 folder = Folder { workspace_id, name, ..Default::default() };
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "folder update")?;
let id = require_id(&patch, "folder update")?;
let existing = ctx
.db()
.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 saved = ctx
.db()
.upsert_folder(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update folder: {e}"))?;
println!("Updated folder: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("folder", folder_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete folder: {e}"))?;
println!("Deleted folder: {}", deleted.id);
Ok(())
}

View File

@@ -0,0 +1,5 @@
pub mod environment;
pub mod folder;
pub mod request;
pub mod send;
pub mod workspace;

View File

@@ -0,0 +1,233 @@
use crate::cli::{RequestArgs, RequestCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id,
validate_create_id,
};
use tokio::sync::mpsc;
use yaak::send::{SendHttpRequestByIdWithPluginsParams, send_http_request_by_id_with_plugins};
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run(
ctx: &CliContext,
args: RequestArgs,
environment: Option<&str>,
verbose: bool,
) -> i32 {
let result = match args.command {
RequestCommands::List { workspace_id } => list(ctx, &workspace_id),
RequestCommands::Show { request_id } => show(ctx, &request_id),
RequestCommands::Send { request_id } => {
return match send_request_by_id(ctx, &request_id, environment, verbose).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: &str) -> CommandResult {
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(())
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
method: Option<String>,
url: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err("request create cannot combine workspace_id with --json payload".to_string());
}
let payload = parse_optional_json(
json,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"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 request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
if request.workspace_id.is_empty() {
return Err("request create JSON requires non-empty \"workspaceId\"".to_string());
}
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 = workspace_id.ok_or_else(|| {
"request create requires workspace_id unless JSON payload is provided".to_string()
})?;
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<String>, json_input: Option<String>) -> 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>,
verbose: bool,
) -> Result<(), String> {
let request =
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let (event_tx, mut event_rx) = mpsc::channel(100);
let event_handle = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if verbose {
println!("{}", event);
}
}
});
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: None,
response_dir: &response_dir,
emit_events_to: Some(event_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 result = result.map_err(|e| e.to_string())?;
if verbose {
println!();
}
println!(
"HTTP {} {}",
result.response.status,
result.response.status_reason.as_deref().unwrap_or("")
);
if verbose {
for header in &result.response.headers {
println!("{}: {}", header.name, header.value);
}
println!();
}
let body = String::from_utf8(result.response_body)
.map_err(|e| format!("Failed to read response body: {e}"))?;
println!("{}", body);
Ok(())
}

View File

@@ -0,0 +1,18 @@
use crate::cli::SendArgs;
use crate::commands::request;
use crate::context::CliContext;
pub async fn run(
ctx: &CliContext,
args: SendArgs,
environment: Option<&str>,
verbose: bool,
) -> i32 {
match request::send_request_by_id(ctx, &args.request_id, environment, verbose).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}

View File

@@ -0,0 +1,121 @@
use crate::cli::{WorkspaceArgs, WorkspaceCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, parse_optional_json, parse_required_json, require_id, validate_create_id,
};
use yaak_models::models::Workspace;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
let result = match args.command {
WorkspaceCommands::List => list(ctx),
WorkspaceCommands::Show { workspace_id } => show(ctx, &workspace_id),
WorkspaceCommands::Create { name, json, json_input } => create(ctx, name, json, json_input),
WorkspaceCommands::Update { json, json_input } => update(ctx, json, json_input),
WorkspaceCommands::Delete { workspace_id, yes } => delete(ctx, &workspace_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn list(ctx: &CliContext) -> CommandResult {
let workspaces = ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
println!("No workspaces found");
} else {
for workspace in workspaces {
println!("{} - {}", workspace.id, workspace.name);
}
}
Ok(())
}
fn show(ctx: &CliContext, workspace_id: &str) -> CommandResult {
let workspace = ctx
.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace: {e}"))?;
let output = serde_json::to_string_pretty(&workspace)
.map_err(|e| format!("Failed to serialize workspace: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
name: Option<String>,
json: Option<String>,
json_input: Option<String>,
) -> CommandResult {
let payload = parse_optional_json(json, json_input, "workspace create")?;
if let Some(payload) = payload {
if name.is_some() {
return Err("workspace create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "workspace")?;
let workspace: Workspace = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?;
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
return Ok(());
}
let name =
name.ok_or_else(|| "workspace create requires --name unless JSON payload is provided".to_string())?;
let workspace = Workspace { name, ..Default::default() };
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "workspace update")?;
let id = require_id(&patch, "workspace update")?;
let existing = ctx
.db()
.get_workspace(&id)
.map_err(|e| format!("Failed to get workspace for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
let saved = ctx
.db()
.upsert_workspace(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update workspace: {e}"))?;
println!("Updated workspace: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("workspace", workspace_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
Ok(())
}