diff --git a/rust/src/application/client.rs b/rust/src/application/client.rs index f6d99b34..af727d92 100644 --- a/rust/src/application/client.rs +++ b/rust/src/application/client.rs @@ -240,7 +240,7 @@ impl ApplicationClient { pub async fn get_application_with_releases(&self, name: &str) -> Result { self.query(json!({ - "query": "query ApplicationWithLatestRelease($name: String!) { application(name: $name) { id label name description status url proxyUrl authorizationScopes releases(first: 1, orderBy: { createdAt: DESC }) { edges { node { id version description createdAt } } } } }", + "query": "query ApplicationWithLatestRelease($name: String!) { application(name: $name) { id label name description status url proxyUrl authorizationScopes clientId releases(first: 1, orderBy: { createdAt: DESC }) { edges { node { id version description createdAt subscriptions { name url events } } } } } }", "variables": { "name": name } })) .await @@ -597,6 +597,55 @@ mod tests { ); } + #[tokio::test] + async fn get_application_with_releases_selects_client_id_and_subscriptions() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .is_true(|req| { + let body = req.body_string(); + body.contains("ApplicationWithLatestRelease") + && body.contains("clientId") + && body.contains("subscriptions { name url events }") + }); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-1", + "clientId": "client-1", + "releases": { + "edges": [{ + "node": { + "id": "rel-1", + "subscriptions": [{ + "name": "order-notifications", + "url": "https://proxy.example.com/webhooks/orders", + "events": ["commerce.order.created"] + }] + } + }] + } + } + } + })); + }) + .await; + + let data = ApplicationClient::new(server.base_url(), "test-token") + .get_application_with_releases("test-app") + .await + .expect("get application with releases"); + + mock.assert_async().await; + assert_eq!(data["application"]["clientId"], "client-1"); + assert_eq!( + data["application"]["releases"]["edges"][0]["node"]["subscriptions"][0]["name"], + "order-notifications" + ); + } + #[tokio::test] async fn update_application_sends_non_lifecycle_fields() { let server = MockServer::start_async().await; diff --git a/rust/src/application/commands/init.rs b/rust/src/application/commands/init.rs index bb95f920..775c0931 100644 --- a/rust/src/application/commands/init.rs +++ b/rust/src/application/commands/init.rs @@ -3,7 +3,7 @@ use cli_engine::{ CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, }; -use serde_json::json; +use serde_json::{Value, json}; use super::schemas::ApplicationInit; use crate::next_action::{next_action, required_value}; @@ -43,6 +43,281 @@ struct InitArgs { /// is still pending (required for non-TTY). #[arg(long)] accept_agreements: bool, + + /// Fetch an already-registered application's remote config and its + /// latest release's webhook subscriptions instead of creating a new + /// application. --description, --url, --proxy-url, and --scopes + /// override the corresponding fetched value if also provided. + #[arg( + long, + value_name = "NAME", + conflicts_with_all = ["accept_agreements", "name", "config"] + )] + from_existing: Option, + + /// With --from-existing, skip the confirmation/abort when the local + /// godaddy.toml has webhook subscriptions not present in the + /// application's latest published release + #[arg(long, requires = "from_existing")] + force: bool, +} + +/// The `releases(first: 1, orderBy: { createdAt: DESC })` node selected by +/// `ApplicationClient::get_application_with_releases` e.g. the +/// application's latest release, if it has one. +fn latest_release(app: &Value) -> Option<&Value> { + app["releases"]["edges"] + .as_array()? + .first() + .map(|edge| &edge["node"]) +} + +/// Maps the latest release's `subscriptions` into local `SubscriptionConfig` +/// entries, relativizing each webhook URL against `proxy_url` so it matches +/// the `/webhooks/...` shape hand-authored entries use. +fn subscriptions_from_latest_release( + app: &Value, + proxy_url: &str, +) -> Vec { + latest_release(app) + .and_then(|node| node["subscriptions"].as_array()) + .map(|subs| { + subs.iter() + .map(|sub| crate::config::SubscriptionConfig { + name: sub["name"].as_str().unwrap_or("").to_owned(), + events: sub["events"] + .as_array() + .map(|events| { + events + .iter() + .filter_map(|e| e.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(), + url: crate::config::relativize_webhook_url( + sub["url"].as_str().unwrap_or(""), + proxy_url, + ), + }) + .collect() + }) + .unwrap_or_default() +} + +/// A subscription's `(name, url, events)` reduced to a comparable signature, +/// with `events` order-normalized. So two lists that differ only in +/// subscription/event ordering aren't reported as diverging. +fn subscription_signature(sub: &crate::config::SubscriptionConfig) -> String { + let mut events = sub.events.clone(); + events.sort(); + format!("{}\u{0}{}\u{0}{}", sub.name, sub.url, events.join(",")) +} + +/// Names of `local` subscriptions with no identical counterpart in `remote` +/// e.g. local edits (via `add subscription`) that were never published +/// with `release`, and that a `--from-existing` pull is about to discard by +/// replacing `subscriptions.webhook` with the latest release's list. +fn subscriptions_at_risk( + local: &[crate::config::SubscriptionConfig], + remote: &[crate::config::SubscriptionConfig], +) -> Vec { + let remote_signatures: std::collections::BTreeSet = + remote.iter().map(subscription_signature).collect(); + local + .iter() + .filter(|sub| !remote_signatures.contains(&subscription_signature(sub))) + .map(|sub| sub.name.clone()) + .collect() +} + +/// Gate for overwriting local webhook subscriptions that aren't in the +/// application's latest published release. +fn confirm_overwrite_or_abort( + ctx: &cli_engine::CommandContext, + name: &str, + at_risk: &[String], +) -> cli_engine::Result<()> { + let subscriptions = at_risk.join(", "); + if ctx.is_interactive() { + let message = format!( + "Local subscriptions.webhook has unpublished changes not present in \ + {name}'s latest release and will be lost: {subscriptions}. Overwrite \ + local godaddy.toml anyway?" + ); + if cli_engine::prompt::prompt_confirm(&message, false)? { + return Ok(()); + } + return Err(cli_engine::CliCoreError::message( + "aborted: local webhook subscription changes were not overwritten", + )); + } + Err(cli_engine::CliCoreError::message(format!( + "local subscriptions.webhook has unpublished changes not present in {name}'s \ + latest release and would be overwritten: {subscriptions}. Run `gddy platform app \ + release` first to publish them, or re-run with --force to discard them." + ))) +} + +/// `init --from-existing `: pull a registered application's remote +/// config and its latest release's webhook subscriptions into a local +/// godaddy.toml, instead of registering a new application. Read-only against +/// the API (no `createApplication` mutation, no `.env` write so it's safe +/// to re-run to re-sync subscriptions after a new release. +async fn handle_from_existing( + ctx: &cli_engine::CommandContext, + config_path: &std::path::Path, + name: String, + args: InitArgs, +) -> cli_engine::Result { + let client = super::make_client(ctx).await?; + let data = client + .get_application_with_releases(&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 client_id = app["clientId"].as_str().unwrap_or("").to_owned(); + let description = args + .description + .or_else(|| app["description"].as_str().map(str::to_owned)) + .unwrap_or_default(); + let url = args + .url + .or_else(|| app["url"].as_str().map(str::to_owned)) + .unwrap_or_default(); + let proxy_url = args + .proxy_url + .or_else(|| app["proxyUrl"].as_str().map(str::to_owned)) + .unwrap_or_default(); + let scopes: Vec = args + .scopes + .map(|s| { + s.split(',') + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_else(|| { + app["authorizationScopes"] + .as_array() + .map(|scopes| { + scopes + .iter() + .filter_map(|s| s.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + }); + + for (field, u) in [("url", &url), ("proxyUrl", &proxy_url)] { + if !crate::application::public_url::is_public_routable_url(u) { + return Err(cli_engine::CliCoreError::message(format!( + "Invalid application configuration: {field} must be a publicly-resolvable \ + http(s) URL (localhost, loopback, and private IPs are not allowed)" + ))); + } + } + + let webhook_subscriptions = subscriptions_from_latest_release(app, &proxy_url); + + // Preserve locally-authored fields the API doesn't track (actions, + // dependencies, extensions, settings), if a godaddy.toml already exists; + // this command only syncs identity, version, and webhook subscriptions, + // not the whole manifest. + let existing = crate::config::read_config(config_path).ok(); + + if let Some(existing_cfg) = &existing { + let local_webhooks = existing_cfg + .subscriptions + .as_ref() + .map(|s| s.webhook.as_slice()) + .unwrap_or_default(); + let at_risk = subscriptions_at_risk(local_webhooks, &webhook_subscriptions); + if !at_risk.is_empty() && !args.force { + confirm_overwrite_or_abort(ctx, &name, &at_risk)?; + } + } + + // Get latest release version from app; fall back to the local manifest's + // version (e.g. an app with no release yet), then to a fresh-manifest default. + let version = latest_release(app) + .and_then(|node| node["version"].as_str()) + .map(str::to_owned) + .or_else(|| existing.as_ref().map(|c| c.version.clone())) + .unwrap_or_else(|| "0.0.0".to_owned()); + let actions = existing + .as_ref() + .map(|c| c.actions.clone()) + .unwrap_or_default(); + let dependencies = existing + .as_ref() + .map(|c| c.dependencies.clone()) + .unwrap_or_default(); + let settings = existing + .as_ref() + .map(|c| c.settings.clone()) + .unwrap_or_default(); + let extensions = existing.and_then(|c| c.extensions); + + let config = crate::config::Config { + name: name.clone(), + client_id, + description: Some(description), + version, + url: url.clone(), + proxy_url: proxy_url.clone(), + authorization_scopes: scopes.clone(), + actions, + subscriptions: Some(crate::config::SubscriptionsConfig { + webhook: webhook_subscriptions.clone(), + }), + dependencies, + extensions, + settings, + }; + + crate::config::write_config(config_path, &config).map_err(|e| { + crate::error::GddyError::config(format!("failed to write config: {e}")).into_cli_error() + })?; + + let cwd = std::env::current_dir().unwrap_or_default(); + let subscriptions_json: Vec<_> = webhook_subscriptions + .iter() + .map(|s| json!({ "name": s.name, "url": s.url, "events": s.events })) + .collect(); + + Ok(CommandResult::new(json!({ + "id": app["id"].as_str().unwrap_or("").to_owned(), + "name": name, + "status": app["status"].as_str().unwrap_or("").to_owned(), + "clientId": config.client_id, + "url": url, + "proxyUrl": proxy_url, + "authorizationScopes": scopes, + "subscriptions": subscriptions_json, + "filesWritten": { + "config": cwd.join(config_path).display().to_string(), + }, + })) + .with_next_actions(vec![ + next_action( + "platform app validate ", + "Validate the remote application state", + ) + .with_param("name", required_value(&name)), + next_action( + "platform app info --name ", + "Inspect application details", + ) + .with_param("name", required_value(&name)), + ])) } /// `filesWritten` is a small path-by-kind object (`config`/`env`), so it @@ -84,6 +359,11 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx, args: InitArgs| async move { let env = ctx.middleware.env.clone(); let config_path = crate::config::config_path(Some(&env)); + + if let Some(name) = args.from_existing.clone() { + return handle_from_existing(&ctx, &config_path, name, args).await; + } + let accept_agreements = args.accept_agreements; // Seed defaults only from an explicit --config; a bad/missing --config is fatal. @@ -284,7 +564,131 @@ pub(super) fn command() -> RuntimeCommandSpec { mod tests { use serde_json::json; - use super::init_view_columns; + use super::{ + init_view_columns, latest_release, subscriptions_at_risk, subscriptions_from_latest_release, + }; + use crate::config::SubscriptionConfig; + + fn init_clap_command() -> clap::Command { + super::command().spec.clap_command() + } + + fn sub(name: &str, url: &str, events: &[&str]) -> SubscriptionConfig { + SubscriptionConfig { + name: name.to_owned(), + url: url.to_owned(), + events: events.iter().map(|e| (*e).to_owned()).collect(), + } + } + + #[test] + fn from_existing_is_accepted_standalone_without_creation_flags() { + init_clap_command() + .try_get_matches_from(["init", "--from-existing", "my-app"]) + .expect("--from-existing should not require --name/--url/etc."); + } + + #[test] + fn from_existing_conflicts_with_accept_agreements() { + let err = init_clap_command() + .try_get_matches_from(["init", "--from-existing", "my-app", "--accept-agreements"]) + .expect_err("--from-existing and --accept-agreements should conflict"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn force_requires_from_existing() { + let err = init_clap_command() + .try_get_matches_from(["init", "--force"]) + .expect_err("--force without --from-existing should be rejected"); + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn force_is_accepted_alongside_from_existing() { + init_clap_command() + .try_get_matches_from(["init", "--from-existing", "my-app", "--force"]) + .expect("--force should be accepted with --from-existing"); + } + + #[test] + fn subscriptions_at_risk_is_empty_when_lists_match_ignoring_order() { + let local = vec![ + sub("a", "/a", &["evt.a", "evt.b"]), + sub("b", "/b", &["evt.c"]), + ]; + // Same content, different subscription order and different event order. + let remote = vec![ + sub("b", "/b", &["evt.c"]), + sub("a", "/a", &["evt.b", "evt.a"]), + ]; + assert!(subscriptions_at_risk(&local, &remote).is_empty()); + } + + #[test] + fn subscriptions_at_risk_flags_local_only_entries() { + let local = vec![ + sub("a", "/a", &["evt.a"]), + sub("unpublished", "/u", &["evt.z"]), + ]; + let remote = vec![sub("a", "/a", &["evt.a"])]; + assert_eq!(subscriptions_at_risk(&local, &remote), vec!["unpublished"]); + } + + #[test] + fn subscriptions_at_risk_flags_a_modified_entry() { + let local = vec![sub("a", "/a-new", &["evt.a"])]; + let remote = vec![sub("a", "/a-old", &["evt.a"])]; + assert_eq!(subscriptions_at_risk(&local, &remote), vec!["a"]); + } + + #[test] + fn subscriptions_from_latest_release_relativizes_urls() { + let app = json!({ + "releases": { + "edges": [{ + "node": { + "subscriptions": [{ + "name": "order-notifications", + "url": "https://proxy.example.com/webhooks/orders", + "events": ["commerce.order.created", "commerce.order.updated"], + }] + } + }] + } + }); + let subs = subscriptions_from_latest_release(&app, "https://proxy.example.com"); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].name, "order-notifications"); + assert_eq!(subs[0].url, "/webhooks/orders"); + assert_eq!( + subs[0].events, + vec!["commerce.order.created", "commerce.order.updated"] + ); + } + + #[test] + fn subscriptions_from_latest_release_is_empty_without_releases() { + let app = json!({ "releases": { "edges": [] } }); + assert!(subscriptions_from_latest_release(&app, "https://proxy.example.com").is_empty()); + } + + #[test] + fn latest_release_exposes_the_release_version() { + let app = json!({ + "releases": { "edges": [{ "node": { "version": "1.4.2" } }] } + }); + assert_eq!( + latest_release(&app).and_then(|node| node["version"].as_str()), + Some("1.4.2") + ); + } + + #[test] + fn latest_release_is_none_without_releases() { + let app = json!({ "releases": { "edges": [] } }); + assert!(latest_release(&app).is_none()); + } /// Proves `init_view_columns()` renders a `filesWritten` shaped like what /// the `init` handler actually builds (`config`/`env` paths, confirmed by diff --git a/rust/src/application/commands/schemas.rs b/rust/src/application/commands/schemas.rs index 2169b230..177adb16 100644 --- a/rust/src/application/commands/schemas.rs +++ b/rust/src/application/commands/schemas.rs @@ -17,11 +17,12 @@ output_schema!(ApplicationInit { "name": "string"; "status": "string"; "clientId": "string"; - "orgId": "string"; + "orgId": "string", optional; "url": "string"; "proxyUrl": "string"; "authorizationScopes": "[]string"; - "oauthGrantTypes": "[]string"; + "oauthGrantTypes": "[]string", optional; + "subscriptions": "[]object", optional; "filesWritten": "object"; }); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index ea6b8838..38e99f1e 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -183,6 +183,30 @@ fn is_endpoint_url(endpoint: &str, proxy_url: &str) -> bool { .is_ok_and(|url| matches!(url.scheme(), "http" | "https")) } +/// Reduce `full_url` to a path relative to `proxy_url` when they share a +/// scheme, host, and port; otherwise return `full_url` unchanged +pub fn relativize_webhook_url(full_url: &str, proxy_url: &str) -> String { + let (Ok(full), Ok(base)) = (url::Url::parse(full_url), url::Url::parse(proxy_url)) else { + return full_url.to_owned(); + }; + if full.scheme() != base.scheme() + || full.host_str() != base.host_str() + || full.port_or_known_default() != base.port_or_known_default() + { + return full_url.to_owned(); + } + let mut relative = full.path().to_owned(); + if let Some(query) = full.query() { + relative.push('?'); + relative.push_str(query); + } + if let Some(fragment) = full.fragment() { + relative.push('#'); + relative.push_str(fragment); + } + relative +} + fn validate_action(errors: &mut Vec, path: &str, action: &ActionConfig, proxy_url: &str) { require_min_len(errors, &format!("{path}.name"), &action.name, MIN_IDENT_LEN); if !is_endpoint_url(&action.url, proxy_url) { @@ -467,6 +491,47 @@ mod tests { } } + #[test] + fn relativize_webhook_url_reduces_same_host_url_to_a_path() { + assert_eq!( + relativize_webhook_url( + "https://proxy.example.com/webhooks/orders", + "https://proxy.example.com" + ), + "/webhooks/orders" + ); + } + + #[test] + fn relativize_webhook_url_keeps_query_and_fragment() { + assert_eq!( + relativize_webhook_url( + "https://proxy.example.com/webhooks/orders?x=1#frag", + "https://proxy.example.com" + ), + "/webhooks/orders?x=1#frag" + ); + } + + #[test] + fn relativize_webhook_url_leaves_cross_host_url_unchanged() { + assert_eq!( + relativize_webhook_url( + "https://elsewhere.example.com/webhooks/orders", + "https://proxy.example.com" + ), + "https://elsewhere.example.com/webhooks/orders" + ); + } + + #[test] + fn relativize_webhook_url_leaves_unparsable_url_unchanged() { + assert_eq!( + relativize_webhook_url("not a url", "https://proxy.example.com"), + "not a url" + ); + } + #[test] fn env_path_matches_convention() { use std::path::Path;