diff --git a/Cargo.lock b/Cargo.lock index 2666ac6f..aa08505f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8320,6 +8320,7 @@ dependencies = [ "env_logger", "log 0.4.29", "predicates", + "serde", "serde_json", "tempfile", "tokio", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index 0e61a8d8..919c0d60 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -13,6 +13,7 @@ clap = { version = "4", features = ["derive"] } dirs = "6" env_logger = "0.11" log = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } yaak-crypto = { workspace = true } diff --git a/crates-cli/yaak-cli/PLAN.md b/crates-cli/yaak-cli/PLAN.md index 604f6d8e..61d9ecfb 100644 --- a/crates-cli/yaak-cli/PLAN.md +++ b/crates-cli/yaak-cli/PLAN.md @@ -11,21 +11,20 @@ Current branch state: - Modular CLI structure with command modules and shared `CliContext` - Resource/action hierarchy in place for: - - `workspace list` - - `request list` - - `request send` - - `request create` + - `workspace list|show|create|update|delete` + - `request list|show|create|update|send|delete` + - `folder list|show|create|update|delete` + - `environment list|show|create|update|delete` - Top-level `send` exists as a request-send shortcut (not yet flexible request/folder/workspace resolution) - Legacy `get` command removed -- No folder/workspace/environment CRUD surface yet -- No JSON merge/update flow yet +- JSON create/update flow implemented (`--json` and positional JSON shorthand) - No `request schema` command yet Progress checklist: - [x] Phase 1 complete -- [ ] Phase 2 complete -- [ ] Phase 3 complete +- [x] Phase 2 complete +- [x] Phase 3 complete - [ ] Phase 4 complete - [ ] Phase 5 complete - [ ] Phase 6 complete @@ -145,7 +144,7 @@ Existing behavior stays the same, just reorganized. Remove the `get` command. ### Phase 2: Add missing CRUD commands -Status: in progress (`show`/`create`/`delete` implemented for workspace, request, folder, environment; JSON update flow pending) +Status: complete 1. `workspace show ` 2. `workspace create --name ` (and `--json`) diff --git a/crates-cli/yaak-cli/README.md b/crates-cli/yaak-cli/README.md index 6ee8eed8..a262d4d6 100644 --- a/crates-cli/yaak-cli/README.md +++ b/crates-cli/yaak-cli/README.md @@ -11,19 +11,31 @@ yaakcli send yaakcli workspace list yaakcli workspace show yaakcli workspace create --name +yaakcli workspace create --json '{"name":"My Workspace"}' +yaakcli workspace create '{"name":"My Workspace"}' +yaakcli workspace update --json '{"id":"wk_abc","description":"Updated"}' yaakcli workspace delete [--yes] yaakcli request list yaakcli request show yaakcli request send yaakcli request create --name --url [--method GET] +yaakcli request create --json '{"workspaceId":"wk_abc","name":"Users","url":"https://api.example.com/users"}' +yaakcli request create '{"workspaceId":"wk_abc","name":"Users","url":"https://api.example.com/users"}' +yaakcli request update --json '{"id":"rq_abc","name":"Users v2"}' yaakcli request delete [--yes] yaakcli folder list yaakcli folder show yaakcli folder create --name +yaakcli folder create --json '{"workspaceId":"wk_abc","name":"Auth"}' +yaakcli folder create '{"workspaceId":"wk_abc","name":"Auth"}' +yaakcli folder update --json '{"id":"fl_abc","name":"Auth v2"}' yaakcli folder delete [--yes] yaakcli environment list yaakcli environment show yaakcli environment create --name +yaakcli environment create --json '{"workspaceId":"wk_abc","name":"Production"}' +yaakcli environment create '{"workspaceId":"wk_abc","name":"Production"}' +yaakcli environment update --json '{"id":"ev_abc","color":"#00ff00"}' yaakcli environment delete [--yes] ``` @@ -38,6 +50,8 @@ Notes: - `send` is currently a shortcut for sending an HTTP request ID. - `delete` commands prompt for confirmation unless `--yes` is provided. - In non-interactive mode, `delete` commands require `--yes`. +- `create` and `update` commands support `--json` and positional JSON shorthand. +- `update` uses JSON Merge Patch semantics (RFC 7386) for partial updates. ## Examples @@ -45,18 +59,22 @@ Notes: yaakcli workspace list yaakcli workspace create --name "My Workspace" yaakcli workspace show wk_abc +yaakcli workspace update --json '{"id":"wk_abc","description":"Team workspace"}' yaakcli request list wk_abc yaakcli request show rq_abc yaakcli request create wk_abc --name "Users" --url "https://api.example.com/users" +yaakcli request update --json '{"id":"rq_abc","name":"Users v2"}' yaakcli request send rq_abc -e ev_abc yaakcli request delete rq_abc --yes yaakcli folder create wk_abc --name "Auth" +yaakcli folder update --json '{"id":"fl_abc","name":"Auth v2"}' yaakcli environment create wk_abc --name "Production" +yaakcli environment update --json '{"id":"ev_abc","color":"#00ff00"}' ``` ## Roadmap -Planned command expansion (JSON create/update, request schema, and polymorphic send) is tracked in `PLAN.md`. +Planned command expansion (request schema and polymorphic send) is tracked in `PLAN.md`. When command behavior changes, update this README and verify with: diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index b942a08f..793d3215 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -66,7 +66,26 @@ pub enum WorkspaceCommands { Create { /// Workspace name #[arg(short, long)] - name: String, + name: Option, + + /// JSON payload + #[arg(long, conflicts_with = "json_input")] + json: Option, + + /// JSON payload shorthand + #[arg(value_name = "JSON", conflicts_with = "json")] + json_input: Option, + }, + + /// Update a workspace + Update { + /// JSON payload + #[arg(long, conflicts_with = "json_input")] + json: Option, + + /// JSON payload shorthand + #[arg(value_name = "JSON", conflicts_with = "json")] + json_input: Option, }, /// Delete a workspace @@ -108,20 +127,35 @@ pub enum RequestCommands { /// Create a new HTTP request Create { - /// Workspace ID - workspace_id: String, + /// Workspace ID (or positional JSON payload shorthand) + workspace_id: Option, /// Request name #[arg(short, long)] - name: String, + name: Option, /// HTTP method - #[arg(short, long, default_value = "GET")] - method: String, + #[arg(short, long)] + method: Option, /// URL #[arg(short, long)] - url: String, + url: Option, + + /// JSON payload + #[arg(long)] + json: Option, + }, + + /// Update an HTTP request + Update { + /// JSON payload + #[arg(long, conflicts_with = "json_input")] + json: Option, + + /// JSON payload shorthand + #[arg(value_name = "JSON", conflicts_with = "json")] + json_input: Option, }, /// Delete a request @@ -157,12 +191,27 @@ pub enum FolderCommands { /// Create a folder Create { - /// Workspace ID - workspace_id: String, + /// Workspace ID (or positional JSON payload shorthand) + workspace_id: Option, /// Folder name #[arg(short, long)] - name: String, + name: Option, + + /// JSON payload + #[arg(long)] + json: Option, + }, + + /// Update a folder + Update { + /// JSON payload + #[arg(long, conflicts_with = "json_input")] + json: Option, + + /// JSON payload shorthand + #[arg(value_name = "JSON", conflicts_with = "json")] + json_input: Option, }, /// Delete a folder @@ -198,12 +247,27 @@ pub enum EnvironmentCommands { /// Create an environment Create { - /// Workspace ID - workspace_id: String, + /// Workspace ID (or positional JSON payload shorthand) + workspace_id: Option, /// Environment name #[arg(short, long)] - name: String, + name: Option, + + /// JSON payload + #[arg(long)] + json: Option, + }, + + /// Update an environment + Update { + /// JSON payload + #[arg(long, conflicts_with = "json_input")] + json: Option, + + /// JSON payload shorthand + #[arg(value_name = "JSON", conflicts_with = "json")] + json_input: Option, }, /// Delete an environment diff --git a/crates-cli/yaak-cli/src/commands/environment.rs b/crates-cli/yaak-cli/src/commands/environment.rs index a6f0414e..41153cd0 100644 --- a/crates-cli/yaak-cli/src/commands/environment.rs +++ b/crates-cli/yaak-cli/src/commands/environment.rs @@ -1,5 +1,9 @@ use crate::cli::{EnvironmentArgs, EnvironmentCommands}; use crate::commands::confirm::confirm_delete; +use crate::commands::json::{ + apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id, + validate_create_id, +}; use crate::context::CliContext; use yaak_models::models::Environment; use yaak_models::util::UpdateSource; @@ -8,7 +12,10 @@ pub fn run(ctx: &CliContext, args: EnvironmentArgs) { match args.command { EnvironmentCommands::List { workspace_id } => list(ctx, &workspace_id), EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id), - EnvironmentCommands::Create { workspace_id, name } => create(ctx, workspace_id, name), + 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), } } @@ -33,7 +40,55 @@ fn show(ctx: &CliContext, environment_id: &str) { println!("{output}"); } -fn create(ctx: &CliContext, workspace_id: String, name: String) { +fn create( + ctx: &CliContext, + workspace_id: Option, + name: Option, + json: Option, +) { + if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) { + panic!("environment create cannot combine workspace_id with --json payload"); + } + + 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() { + panic!("environment create cannot combine --name with JSON payload"); + } + + validate_create_id(&payload, "environment"); + let mut environment: Environment = + serde_json::from_value(payload).expect("Failed to parse environment create JSON"); + + if environment.workspace_id.is_empty() { + panic!("environment create JSON requires non-empty \"workspaceId\""); + } + + if environment.parent_model.is_empty() { + environment.parent_model = "environment".to_string(); + } + + let created = ctx + .db() + .upsert_environment(&environment, &UpdateSource::Sync) + .expect("Failed to create environment"); + + println!("Created environment: {}", created.id); + return; + } + + let workspace_id = workspace_id.unwrap_or_else(|| { + panic!("environment create requires workspace_id unless JSON payload is provided") + }); + let name = name.unwrap_or_else(|| { + panic!("environment create requires --name unless JSON payload is provided") + }); + let environment = Environment { workspace_id, name, @@ -49,6 +104,21 @@ fn create(ctx: &CliContext, workspace_id: String, name: String) { println!("Created environment: {}", created.id); } +fn update(ctx: &CliContext, json: Option, json_input: Option) { + let patch = parse_required_json(json, json_input, "environment update"); + let id = require_id(&patch, "environment update"); + + let existing = ctx.db().get_environment(&id).expect("Failed to get environment for update"); + let updated = apply_merge_patch(&existing, &patch, &id, "environment update"); + + let saved = ctx + .db() + .upsert_environment(&updated, &UpdateSource::Sync) + .expect("Failed to update environment"); + + println!("Updated environment: {}", saved.id); +} + fn delete(ctx: &CliContext, environment_id: &str, yes: bool) { if !yes && !confirm_delete("environment", environment_id) { println!("Aborted"); diff --git a/crates-cli/yaak-cli/src/commands/folder.rs b/crates-cli/yaak-cli/src/commands/folder.rs index 2d12b64b..3d102099 100644 --- a/crates-cli/yaak-cli/src/commands/folder.rs +++ b/crates-cli/yaak-cli/src/commands/folder.rs @@ -1,5 +1,9 @@ use crate::cli::{FolderArgs, FolderCommands}; use crate::commands::confirm::confirm_delete; +use crate::commands::json::{ + apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id, + validate_create_id, +}; use crate::context::CliContext; use yaak_models::models::Folder; use yaak_models::util::UpdateSource; @@ -8,7 +12,10 @@ pub fn run(ctx: &CliContext, args: FolderArgs) { match args.command { FolderCommands::List { workspace_id } => list(ctx, &workspace_id), FolderCommands::Show { folder_id } => show(ctx, &folder_id), - FolderCommands::Create { workspace_id, name } => create(ctx, workspace_id, name), + 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), } } @@ -30,7 +37,48 @@ fn show(ctx: &CliContext, folder_id: &str) { println!("{output}"); } -fn create(ctx: &CliContext, workspace_id: String, name: String) { +fn create( + ctx: &CliContext, + workspace_id: Option, + name: Option, + json: Option, +) { + if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) { + panic!("folder create cannot combine workspace_id with --json payload"); + } + + 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() { + panic!("folder create cannot combine --name with JSON payload"); + } + + validate_create_id(&payload, "folder"); + let folder: Folder = + serde_json::from_value(payload).expect("Failed to parse folder create JSON"); + + if folder.workspace_id.is_empty() { + panic!("folder create JSON requires non-empty \"workspaceId\""); + } + + let created = + ctx.db().upsert_folder(&folder, &UpdateSource::Sync).expect("Failed to create folder"); + + println!("Created folder: {}", created.id); + return; + } + + let workspace_id = workspace_id.unwrap_or_else(|| { + panic!("folder create requires workspace_id unless JSON payload is provided") + }); + let name = name + .unwrap_or_else(|| panic!("folder create requires --name unless JSON payload is provided")); + let folder = Folder { workspace_id, name, ..Default::default() }; let created = @@ -39,6 +87,19 @@ fn create(ctx: &CliContext, workspace_id: String, name: String) { println!("Created folder: {}", created.id); } +fn update(ctx: &CliContext, json: Option, json_input: Option) { + let patch = parse_required_json(json, json_input, "folder update"); + let id = require_id(&patch, "folder update"); + + let existing = ctx.db().get_folder(&id).expect("Failed to get folder for update"); + let updated = apply_merge_patch(&existing, &patch, &id, "folder update"); + + let saved = + ctx.db().upsert_folder(&updated, &UpdateSource::Sync).expect("Failed to update folder"); + + println!("Updated folder: {}", saved.id); +} + fn delete(ctx: &CliContext, folder_id: &str, yes: bool) { if !yes && !confirm_delete("folder", folder_id) { println!("Aborted"); diff --git a/crates-cli/yaak-cli/src/commands/json.rs b/crates-cli/yaak-cli/src/commands/json.rs new file mode 100644 index 00000000..f70d4435 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/json.rs @@ -0,0 +1,108 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +pub fn is_json_shorthand(input: &str) -> bool { + input.trim_start().starts_with('{') +} + +pub fn parse_json_object(raw: &str, context: &str) -> Value { + let value: Value = serde_json::from_str(raw) + .unwrap_or_else(|error| panic!("Invalid JSON for {context}: {error}")); + + if !value.is_object() { + panic!("JSON payload for {context} must be an object"); + } + + value +} + +pub fn parse_optional_json( + json_flag: Option, + json_shorthand: Option, + context: &str, +) -> Option { + match (json_flag, json_shorthand) { + (Some(_), Some(_)) => { + panic!("Cannot provide both --json and positional JSON for {context}") + } + (Some(raw), None) => Some(parse_json_object(&raw, context)), + (None, Some(raw)) => Some(parse_json_object(&raw, context)), + (None, None) => None, + } +} + +pub fn parse_required_json( + json_flag: Option, + json_shorthand: Option, + context: &str, +) -> Value { + parse_optional_json(json_flag, json_shorthand, context).unwrap_or_else(|| { + panic!("Missing JSON payload for {context}. Use --json or positional JSON") + }) +} + +pub fn require_id(payload: &Value, context: &str) -> String { + payload + .get("id") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| panic!("{context} requires a non-empty \"id\" field")) +} + +pub fn validate_create_id(payload: &Value, context: &str) { + let Some(id_value) = payload.get("id") else { + return; + }; + + match id_value { + Value::String(id) if id.is_empty() => {} + _ => panic!("{context} create JSON must omit \"id\" or set it to an empty string"), + } +} + +pub fn apply_merge_patch(existing: &T, patch: &Value, id: &str, context: &str) -> T +where + T: Serialize + DeserializeOwned, +{ + let mut base = serde_json::to_value(existing).unwrap_or_else(|error| { + panic!("Failed to serialize existing model for {context}: {error}") + }); + merge_patch(&mut base, patch); + + let Some(base_object) = base.as_object_mut() else { + panic!("Merged payload for {context} must be an object"); + }; + base_object.insert("id".to_string(), Value::String(id.to_string())); + + serde_json::from_value(base).unwrap_or_else(|error| { + panic!("Failed to deserialize merged payload for {context}: {error}") + }) +} + +fn merge_patch(target: &mut Value, patch: &Value) { + match patch { + Value::Object(patch_map) => { + if !target.is_object() { + *target = Value::Object(Map::new()); + } + + let target_map = + target.as_object_mut().expect("merge_patch target expected to be object"); + + for (key, patch_value) in patch_map { + if patch_value.is_null() { + target_map.remove(key); + continue; + } + + let target_entry = target_map.entry(key.clone()).or_insert(Value::Null); + merge_patch(target_entry, patch_value); + } + } + _ => { + *target = patch.clone(); + } + } +} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs index 6c9e2a77..502e92e6 100644 --- a/crates-cli/yaak-cli/src/commands/mod.rs +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod confirm; pub mod environment; pub mod folder; +pub mod json; pub mod request; pub mod send; pub mod workspace; diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index 15a13f29..bf508c2f 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -1,5 +1,9 @@ use crate::cli::{RequestArgs, RequestCommands}; use crate::commands::confirm::confirm_delete; +use crate::commands::json::{ + apply_merge_patch, is_json_shorthand, parse_optional_json, parse_required_json, require_id, + validate_create_id, +}; use crate::context::CliContext; use log::info; use serde_json::Value; @@ -22,9 +26,10 @@ pub async fn run(ctx: &CliContext, args: RequestArgs, environment: Option<&str>, RequestCommands::Send { request_id } => { send_request_by_id(ctx, &request_id, environment, verbose).await; } - RequestCommands::Create { workspace_id, name, method, url } => { - create(ctx, workspace_id, name, method, url) + 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), } } @@ -40,7 +45,56 @@ fn list(ctx: &CliContext, workspace_id: &str) { } } -fn create(ctx: &CliContext, workspace_id: String, name: String, method: String, url: String) { +fn create( + ctx: &CliContext, + workspace_id: Option, + name: Option, + method: Option, + url: Option, + json: Option, +) { + if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) { + panic!("request create cannot combine workspace_id with --json payload"); + } + + 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() { + panic!("request create cannot combine simple flags with JSON payload"); + } + + validate_create_id(&payload, "request"); + let request: HttpRequest = + serde_json::from_value(payload).expect("Failed to parse request create JSON"); + + if request.workspace_id.is_empty() { + panic!("request create JSON requires non-empty \"workspaceId\""); + } + + let created = ctx + .db() + .upsert_http_request(&request, &UpdateSource::Sync) + .expect("Failed to create request"); + + println!("Created request: {}", created.id); + return; + } + + let workspace_id = workspace_id.unwrap_or_else(|| { + panic!("request create requires workspace_id unless JSON payload is provided") + }); + let name = name.unwrap_or_else(|| { + panic!("request create requires --name unless JSON payload is provided") + }); + let url = url + .unwrap_or_else(|| panic!("request create requires --url unless JSON payload is provided")); + let method = method.unwrap_or_else(|| "GET".to_string()); + let request = HttpRequest { workspace_id, name, @@ -57,6 +111,21 @@ fn create(ctx: &CliContext, workspace_id: String, name: String, method: String, println!("Created request: {}", created.id); } +fn update(ctx: &CliContext, json: Option, json_input: Option) { + 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).expect("Failed to get request for update"); + let updated = apply_merge_patch(&existing, &patch, &id, "request update"); + + let saved = ctx + .db() + .upsert_http_request(&updated, &UpdateSource::Sync) + .expect("Failed to update request"); + + println!("Updated request: {}", saved.id); +} + fn show(ctx: &CliContext, request_id: &str) { let request = ctx.db().get_http_request(request_id).expect("Failed to get request"); let output = serde_json::to_string_pretty(&request).expect("Failed to serialize request"); diff --git a/crates-cli/yaak-cli/src/commands/workspace.rs b/crates-cli/yaak-cli/src/commands/workspace.rs index 160ae212..f601b291 100644 --- a/crates-cli/yaak-cli/src/commands/workspace.rs +++ b/crates-cli/yaak-cli/src/commands/workspace.rs @@ -1,5 +1,8 @@ use crate::cli::{WorkspaceArgs, WorkspaceCommands}; use crate::commands::confirm::confirm_delete; +use crate::commands::json::{ + apply_merge_patch, parse_optional_json, parse_required_json, require_id, validate_create_id, +}; use crate::context::CliContext; use yaak_models::models::Workspace; use yaak_models::util::UpdateSource; @@ -8,7 +11,8 @@ pub fn run(ctx: &CliContext, args: WorkspaceArgs) { match args.command { WorkspaceCommands::List => list(ctx), WorkspaceCommands::Show { workspace_id } => show(ctx, &workspace_id), - WorkspaceCommands::Create { name } => create(ctx, name), + 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), } } @@ -30,7 +34,35 @@ fn show(ctx: &CliContext, workspace_id: &str) { println!("{output}"); } -fn create(ctx: &CliContext, name: String) { +fn create( + ctx: &CliContext, + name: Option, + json: Option, + json_input: Option, +) { + let payload = parse_optional_json(json, json_input, "workspace create"); + + if let Some(payload) = payload { + if name.is_some() { + panic!("workspace create cannot combine --name with JSON payload"); + } + + validate_create_id(&payload, "workspace"); + let workspace: Workspace = + serde_json::from_value(payload).expect("Failed to parse workspace create JSON"); + + let created = ctx + .db() + .upsert_workspace(&workspace, &UpdateSource::Sync) + .expect("Failed to create workspace"); + println!("Created workspace: {}", created.id); + return; + } + + let name = name.unwrap_or_else(|| { + panic!("workspace create requires --name unless JSON payload is provided") + }); + let workspace = Workspace { name, ..Default::default() }; let created = ctx .db() @@ -39,6 +71,21 @@ fn create(ctx: &CliContext, name: String) { println!("Created workspace: {}", created.id); } +fn update(ctx: &CliContext, json: Option, json_input: Option) { + let patch = parse_required_json(json, json_input, "workspace update"); + let id = require_id(&patch, "workspace update"); + + let existing = ctx.db().get_workspace(&id).expect("Failed to get workspace for update"); + let updated = apply_merge_patch(&existing, &patch, &id, "workspace update"); + + let saved = ctx + .db() + .upsert_workspace(&updated, &UpdateSource::Sync) + .expect("Failed to update workspace"); + + println!("Updated workspace: {}", saved.id); +} + fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) { if !yes && !confirm_delete("workspace", workspace_id) { println!("Aborted"); diff --git a/crates-cli/yaak-cli/tests/environment_commands.rs b/crates-cli/yaak-cli/tests/environment_commands.rs index 8552a777..c632c569 100644 --- a/crates-cli/yaak-cli/tests/environment_commands.rs +++ b/crates-cli/yaak-cli/tests/environment_commands.rs @@ -20,8 +20,7 @@ fn create_list_show_delete_round_trip() { .args(["environment", "create", "wk_test", "--name", "Production"]) .assert() .success(); - let environment_id = - parse_created_id(&create_assert.get_output().stdout, "environment create"); + let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create"); cli_cmd(data_dir) .args(["environment", "list", "wk_test"]) @@ -43,8 +42,39 @@ fn create_list_show_delete_round_trip() { .success() .stdout(contains(format!("Deleted environment: {environment_id}"))); - assert!(query_manager(data_dir) - .connect() - .get_environment(&environment_id) - .is_err()); + assert!(query_manager(data_dir).connect().get_environment(&environment_id).is_err()); +} + +#[test] +fn json_create_and_update_merge_patch_round_trip() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + + let create_assert = cli_cmd(data_dir) + .args([ + "environment", + "create", + r#"{"workspaceId":"wk_test","name":"Json Environment"}"#, + ]) + .assert() + .success(); + let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create"); + + cli_cmd(data_dir) + .args([ + "environment", + "update", + &format!(r##"{{"id":"{}","color":"#00ff00"}}"##, environment_id), + ]) + .assert() + .success() + .stdout(contains(format!("Updated environment: {environment_id}"))); + + cli_cmd(data_dir) + .args(["environment", "show", &environment_id]) + .assert() + .success() + .stdout(contains("\"name\": \"Json Environment\"")) + .stdout(contains("\"color\": \"#00ff00\"")); } diff --git a/crates-cli/yaak-cli/tests/folder_commands.rs b/crates-cli/yaak-cli/tests/folder_commands.rs index 9fd07221..559beb16 100644 --- a/crates-cli/yaak-cli/tests/folder_commands.rs +++ b/crates-cli/yaak-cli/tests/folder_commands.rs @@ -38,3 +38,37 @@ fn create_list_show_delete_round_trip() { assert!(query_manager(data_dir).connect().get_folder(&folder_id).is_err()); } + +#[test] +fn json_create_and_update_merge_patch_round_trip() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + + let create_assert = cli_cmd(data_dir) + .args([ + "folder", + "create", + r#"{"workspaceId":"wk_test","name":"Json Folder"}"#, + ]) + .assert() + .success(); + let folder_id = parse_created_id(&create_assert.get_output().stdout, "folder create"); + + cli_cmd(data_dir) + .args([ + "folder", + "update", + &format!(r#"{{"id":"{}","description":"Folder Description"}}"#, folder_id), + ]) + .assert() + .success() + .stdout(contains(format!("Updated folder: {folder_id}"))); + + cli_cmd(data_dir) + .args(["folder", "show", &folder_id]) + .assert() + .success() + .stdout(contains("\"name\": \"Json Folder\"")) + .stdout(contains("\"description\": \"Folder Description\"")); +} diff --git a/crates-cli/yaak-cli/tests/request_commands.rs b/crates-cli/yaak-cli/tests/request_commands.rs index 5f829566..d2386a56 100644 --- a/crates-cli/yaak-cli/tests/request_commands.rs +++ b/crates-cli/yaak-cli/tests/request_commands.rs @@ -55,8 +55,53 @@ fn delete_without_yes_fails_in_non_interactive_mode() { .code(1) .stderr(contains("Refusing to delete in non-interactive mode without --yes")); - assert!(query_manager(data_dir) - .connect() - .get_http_request("rq_seed_delete_noninteractive") - .is_ok()); + assert!( + query_manager(data_dir).connect().get_http_request("rq_seed_delete_noninteractive").is_ok() + ); +} + +#[test] +fn json_create_and_update_merge_patch_round_trip() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + seed_workspace(data_dir, "wk_test"); + + let create_assert = cli_cmd(data_dir) + .args([ + "request", + "create", + r#"{"workspaceId":"wk_test","name":"Json Request","url":"https://example.com"}"#, + ]) + .assert() + .success(); + let request_id = parse_created_id(&create_assert.get_output().stdout, "request create"); + + cli_cmd(data_dir) + .args([ + "request", + "update", + &format!(r#"{{"id":"{}","name":"Renamed Request"}}"#, request_id), + ]) + .assert() + .success() + .stdout(contains(format!("Updated request: {request_id}"))); + + cli_cmd(data_dir) + .args(["request", "show", &request_id]) + .assert() + .success() + .stdout(contains("\"name\": \"Renamed Request\"")) + .stdout(contains("\"url\": \"https://example.com\"")); +} + +#[test] +fn update_requires_id_in_json_payload() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + + cli_cmd(data_dir) + .args(["request", "update", r#"{"name":"No ID"}"#]) + .assert() + .failure() + .stderr(contains("request update requires a non-empty \"id\" field")); } diff --git a/crates-cli/yaak-cli/tests/workspace_commands.rs b/crates-cli/yaak-cli/tests/workspace_commands.rs index 7a06e045..f888beda 100644 --- a/crates-cli/yaak-cli/tests/workspace_commands.rs +++ b/crates-cli/yaak-cli/tests/workspace_commands.rs @@ -9,10 +9,8 @@ fn create_show_delete_round_trip() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let data_dir = temp_dir.path(); - let create_assert = cli_cmd(data_dir) - .args(["workspace", "create", "--name", "WS One"]) - .assert() - .success(); + let create_assert = + cli_cmd(data_dir).args(["workspace", "create", "--name", "WS One"]).assert().success(); let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create"); cli_cmd(data_dir) @@ -28,8 +26,34 @@ fn create_show_delete_round_trip() { .success() .stdout(contains(format!("Deleted workspace: {workspace_id}"))); - assert!(query_manager(data_dir) - .connect() - .get_workspace(&workspace_id) - .is_err()); + assert!(query_manager(data_dir).connect().get_workspace(&workspace_id).is_err()); +} + +#[test] +fn json_create_and_update_merge_patch_round_trip() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let data_dir = temp_dir.path(); + + let create_assert = cli_cmd(data_dir) + .args(["workspace", "create", r#"{"name":"Json Workspace"}"#]) + .assert() + .success(); + let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create"); + + cli_cmd(data_dir) + .args([ + "workspace", + "update", + &format!(r#"{{"id":"{}","description":"Updated via JSON"}}"#, workspace_id), + ]) + .assert() + .success() + .stdout(contains(format!("Updated workspace: {workspace_id}"))); + + cli_cmd(data_dir) + .args(["workspace", "show", &workspace_id]) + .assert() + .success() + .stdout(contains("\"name\": \"Json Workspace\"")) + .stdout(contains("\"description\": \"Updated via JSON\"")); }