diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3eeab2c2..e4713ed6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1512,6 +1512,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "phonenumber", + "platform-app-client", "progenitor-client", "regex", "reqwest 0.13.4", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 23114848..b27da453 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -35,6 +35,7 @@ domains-client = { path = "domains-client" } email-client = { path = "email-client" } generated-client-support = { path = "generated-client-support" } hosting-client = { path = "hosting-client" } +platform-app-client = { path = "platform-app-client" } shopping-client = { path = "shopping-client" } fancy-regex = "0.14" flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] } diff --git a/rust/examples/smoke_mock_server.rs b/rust/examples/smoke_mock_server.rs index 958f8347..c1bdc378 100644 --- a/rust/examples/smoke_mock_server.rs +++ b/rust/examples/smoke_mock_server.rs @@ -63,13 +63,63 @@ fn create_release_response(req: &HttpMockRequest) -> HttpMockResponse { } _ => {} } + + let settings: Vec = input["settings"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(i, mut setting)| { + let obj = setting.as_object_mut().expect("setting is an object"); + obj.entry("id") + .or_insert_with(|| json!(format!("smoke-setting-{i}"))); + obj.entry("capabilities").or_insert_with(|| json!([])); + if obj.get("capabilities") == Some(&Value::Null) { + obj.insert("capabilities".to_owned(), json!([])); + } + obj.entry("order").or_insert_with(|| json!(0)); + if obj.get("order") == Some(&Value::Null) { + obj.insert("order".to_owned(), json!(0)); + } + setting + }) + .collect(); + let ui_extensions: Vec = input["uiExtensions"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(i, mut extension)| { + let obj = extension.as_object_mut().expect("extension is an object"); + obj.entry("id") + .or_insert_with(|| json!(format!("smoke-ui-extension-{i}"))); + obj.entry("type").or_insert_with(|| json!("embed")); + extension + }) + .collect(); + let native_extensions: Vec = input["nativeExtensions"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(i, mut extension)| { + let obj = extension.as_object_mut().expect("extension is an object"); + obj.entry("id") + .or_insert_with(|| json!(format!("smoke-native-extension-{i}"))); + extension + }) + .collect(); let release = json!({ "id": "smoke-release-id", "version": input.get("version").cloned().unwrap_or(json!("0.0.0")), "description": input.get("description").cloned().unwrap_or(Value::Null), "createdAt": "2026-01-01T00:00:00Z", - "uiExtensions": input.get("uiExtensions").cloned().unwrap_or(json!([])), - "settings": input.get("settings").cloned().unwrap_or(json!([])), + "uiExtensions": ui_extensions, + "nativeExtensions": native_extensions, + "settings": settings, }); HttpMockResponse::builder() .status(200) diff --git a/rust/src/platform/app/client.rs b/rust/src/platform/app/client.rs index d143792d..9d8e4d98 100644 --- a/rust/src/platform/app/client.rs +++ b/rust/src/platform/app/client.rs @@ -1,10 +1,18 @@ use bytes::Bytes; +use platform_app_client::{ + ActivateRelease, Application, ApplicationWithLatestRelease, ApplicationsList, + ArchiveApplication, CreateApplication, CreateRelease, DisableApplication, EnableApplication, + EnabledStoreApplications, GenerateReleaseUploadUrl, UpdateApplication, activate_release, + application, application_with_latest_release, applications_list, archive_application, + create_application, create_release, disable_application, enable_application, + enabled_store_applications, generate_release_upload_url, update_application, +}; use reqwest::Client; -use serde_json::{Value, json}; +use serde_json::Value; use crate::http::make_http_client; -const GRAPHQL_PATH: &str = "/v1/apps/app-registry-subgraph"; +const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); /// App Registry `ApplicationStatus` enum values (see app-registry-api GraphQL schema). /// @@ -25,12 +33,28 @@ pub enum ClientError { Network(#[from] reqwest::Error), #[error("GraphQL errors: {0}")] GraphQL(String), + #[error("failed to construct App Registry client: {0}")] + Build(#[from] platform_app_client::BuildError), #[error("file size {size} bytes exceeds maximum allowed ({max} bytes)")] TooLarge { size: u64, max: u64 }, #[error("invalid upload header {0}")] InvalidHeader(String), } +/// Reuses the existing `Http`/`Network`/`GraphQL` variants rather than +/// wrapping `platform_app_client::ClientError` in a new one — every caller +/// (and `impl From for GddyError` below) already matches on +/// those three shapes, and this keeps that mapping unchanged. +impl From for ClientError { + fn from(value: platform_app_client::ClientError) -> Self { + match value { + platform_app_client::ClientError::Http { status, body } => Self::Http { status, body }, + platform_app_client::ClientError::Network(e) => Self::Network(e), + platform_app_client::ClientError::GraphQL(msg) => Self::GraphQL(msg), + } + } +} + impl From for crate::error::GddyError { fn from(value: ClientError) -> Self { match value { @@ -41,6 +65,10 @@ impl From for crate::error::GddyError { ClientError::GraphQL(msg) => { Self::from_graphql(format!("GraphQL errors: {msg}"), "applications") } + ClientError::Build(error) => { + Self::config(format!("failed to construct App Registry client: {error}")) + .with_system("applications") + } ClientError::TooLarge { size, max } => Self::validation(format!( "file size {size} bytes exceeds maximum allowed ({max} bytes)" )) @@ -79,7 +107,7 @@ impl Default for UploadOptions { } pub struct ApplicationClient { - client: Client, + http: Client, base_url: String, token: String, } @@ -88,48 +116,19 @@ pub struct ApplicationClient { impl ApplicationClient { pub fn new(base_url: impl Into, token: impl Into) -> Self { Self { - client: make_http_client(), + http: make_http_client(), base_url: base_url.into(), token: token.into(), } } - async fn query(&self, body: Value) -> Result { - let url = format!("{}{}", self.base_url, GRAPHQL_PATH); - let request = self - .client - .post(&url) - .bearer_auth(&self.token) - .header("x-request-id", new_request_id()) - .json(&body) - .build()?; - cli_engine::transport::debug_log_reqwest_request(&request); - let resp = self.client.execute(request).await?; - - let status = resp.status(); - let headers = resp.headers().clone(); - let bytes = resp.bytes().await?; - cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); - - if !status.is_success() { - let text = String::from_utf8_lossy(&bytes).into_owned(); - return Err(ClientError::Http { - status: status.as_u16(), - body: text, - }); - } - - let payload: Value = serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { - status: status.as_u16(), - body: format!( - "invalid JSON response: {e} (body: {})", - String::from_utf8_lossy(&bytes) - ), - })?; - if let Some(errors) = payload.get("errors") { - return Err(ClientError::GraphQL(errors.to_string())); - } - Ok(payload["data"].clone()) + fn gql(&self) -> Result { + Ok(platform_app_client::client_with_auth( + &self.base_url, + &format!("Bearer {}", self.token), + USER_AGENT, + &new_request_id(), + )?) } /// Lists applications across every App Registry status. @@ -137,135 +136,189 @@ impl ApplicationClient { /// Always sends an explicit `status.in` of [`APPLICATION_STATUSES`]. Omitting /// the GraphQL `status` argument is *not* equivalent: the API defaults to /// ACTIVE-only. - pub async fn list_applications(&self) -> Result { - let data = self - .query(json!({ - "query": "query ApplicationsList($status: ApplicationStatusFilter) { applications(status: $status) { edges { node { id label name description status url proxyUrl } } } }", - "variables": { "status": { "in": APPLICATION_STATUSES } } - })) - .await?; - let nodes: Vec = data["applications"]["edges"] - .as_array() - .map(|edges| { - edges - .iter() - .filter_map(|e| { - let node = &e["node"]; - if node.is_null() { - None - } else { - Some(node.clone()) - } - }) - .collect() + pub async fn list_applications( + &self, + ) -> Result, ClientError> { + use applications_list::ApplicationStatus; + let statuses: Vec = APPLICATION_STATUSES + .iter() + .map(|s| match *s { + "ACTIVE" => ApplicationStatus::ACTIVE, + "ARCHIVED" => ApplicationStatus::ARCHIVED, + "BLOCKED" => ApplicationStatus::BLOCKED, + "INACTIVE" => ApplicationStatus::INACTIVE, + "VERIFYING" => ApplicationStatus::VERIFYING, + other => ApplicationStatus::Other(other.to_owned()), + }) + .collect(); + let response = self + .gql()? + .send::(applications_list::Variables { + status: Some(applications_list::ApplicationStatusFilter { + eq: None, + in_: Some(statuses), + ne: None, + }), }) - .unwrap_or_default(); - Ok(json!(nodes)) + .await?; + Ok(response + .applications + .and_then(|connection| connection.edges) + .into_iter() + .flatten() + .flatten() + .filter_map(|edge| edge.node) + .collect()) } - pub async fn get_application(&self, name: &str) -> Result { - self.query(json!({ - "query": "query Application($name: String!) { application(name: $name) { id label name description status url proxyUrl } }", - "variables": { "name": name } - })) - .await + pub async fn get_application( + &self, + name: &str, + ) -> Result, ClientError> { + let response = self + .gql()? + .send::(application::Variables { + name: name.to_owned(), + }) + .await?; + Ok(response.application) } - pub async fn update_application(&self, id: &str, input: Value) -> Result { - self.query(json!({ - "query": "mutation UpdateApplication($id: String!, $input: MutationUpdateApplicationInput!) { updateApplication(id: $id, input: $input) { id clientId label name description status url proxyUrl authorizationScopes redirectUris } }", - "variables": { "id": id, "input": input } - })) - .await + pub async fn update_application( + &self, + id: &str, + input: update_application::MutationUpdateApplicationInput, + ) -> Result, ClientError> { + let response = self + .gql()? + .send::(update_application::Variables { + id: id.to_owned(), + input, + }) + .await?; + Ok(response.update_application) } - pub async fn create_release(&self, input: Value) -> Result { - self.query(json!({ - "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } nativeExtensions { id name packageName contact platform } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", - "variables": { "input": input } - })) - .await + pub async fn create_release( + &self, + input: create_release::MutationCreateReleaseInput, + ) -> Result, ClientError> { + let response = self + .gql()? + .send::(create_release::Variables { input }) + .await?; + Ok(response.create_release) } pub async fn activate_release( &self, application_id: &str, release_id: &str, - ) -> Result { - self.query(json!({ - "query": "mutation ActivateRelease($applicationId: ID!, $releaseId: ID!) { activateRelease(applicationId: $applicationId, releaseId: $releaseId) { id version description status activatedAt createdAt updatedAt } }", - "variables": { "applicationId": application_id, "releaseId": release_id } - })) - .await + ) -> Result, ClientError> { + let response = self + .gql()? + .send::(activate_release::Variables { + application_id: application_id.to_owned(), + release_id: release_id.to_owned(), + }) + .await?; + Ok(response.activate_release) } - pub async fn enable_application(&self, input: Value) -> Result { - self.query(json!({ - "query": "mutation EnableApplication($input: MutationEnableStoreApplicationInput!) { enableStoreApplication(input: $input) { id } }", - "variables": { "input": input } - })) - .await + pub async fn enable_application( + &self, + input: enable_application::MutationEnableStoreApplicationInput, + ) -> Result, ClientError> + { + let response = self + .gql()? + .send::(enable_application::Variables { input }) + .await?; + Ok(response.enable_store_application) } - pub async fn disable_application(&self, input: Value) -> Result { - self.query(json!({ - "query": "mutation DisableApplication($input: MutationDisableStoreApplicationInput!) { disableStoreApplication(input: $input) { id } }", - "variables": { "input": input } - })) - .await + pub async fn disable_application( + &self, + input: disable_application::MutationDisableStoreApplicationInput, + ) -> Result, ClientError> + { + let response = self + .gql()? + .send::(disable_application::Variables { input }) + .await?; + Ok(response.disable_store_application) } /// Lists applications enabled on a commerce store. /// - /// Returns an empty JSON array when nothing is enabled (not an error). - /// `storeId` is sent as a GraphQL variable only — same pattern as - /// [`Self::enable_application`] / [`Self::disable_application`]. + /// Returns an empty list when nothing is enabled (not an error). pub async fn list_enabled_store_applications( &self, store_id: &str, - ) -> Result { - let data = self - .query(json!({ - "query": "query EnabledStoreApplications($storeId: String!) { enabledStoreApplications(storeId: $storeId) { id name label status release { id version } } }", - "variables": { "storeId": store_id } - })) + ) -> Result< + Vec, + ClientError, + > { + let response = self + .gql()? + .send::(enabled_store_applications::Variables { + store_id: store_id.to_owned(), + }) .await?; - Ok(data - .get("enabledStoreApplications") - .cloned() - .unwrap_or_else(|| json!([]))) + Ok(response.enabled_store_applications.unwrap_or_default()) } - pub async fn archive_application(&self, id: &str) -> Result { - self.query(json!({ - "query": "mutation ArchiveApplication($id: String!) { archiveApplication(id: $id) { id label name status createdAt archivedAt } }", - "variables": { "id": id } - })) - .await + pub async fn archive_application( + &self, + id: &str, + ) -> Result, ClientError> + { + let response = self + .gql()? + .send::(archive_application::Variables { id: id.to_owned() }) + .await?; + Ok(response.archive_application) } - pub async fn create_application(&self, input: Value) -> Result { - self.query(json!({ - "query": "mutation CreateApplication($input: MutationCreateApplicationInput!) { createApplication(input: $input) { id clientId clientSecret label name description status url proxyUrl authorizationScopes redirectUris secret publicKey } }", - "variables": { "input": input } - })) - .await + pub async fn create_application( + &self, + input: create_application::MutationCreateApplicationInput, + ) -> Result, ClientError> { + let response = self + .gql()? + .send::(create_application::Variables { input }) + .await?; + Ok(response.create_application) } - 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 redirectUris clientId releases(first: 1, orderBy: { createdAt: DESC }) { edges { node { id version description createdAt subscriptions { name url events } } } } } }", - "variables": { "name": name } - })) - .await + pub async fn get_application_with_releases( + &self, + name: &str, + ) -> Result< + Option, + ClientError, + > { + let response = self + .gql()? + .send::(application_with_latest_release::Variables { + name: name.to_owned(), + }) + .await?; + Ok(response.application) } - pub async fn generate_upload_url(&self, input: Value) -> Result { - self.query(json!({ - "query": "mutation GenerateReleaseUploadUrl($input: MutationGenerateReleaseUploadUrlInput!) { generateReleaseUploadUrl(input: $input) { uploadId url key expiresAt maxSizeBytes requiredHeaders } }", - "variables": { "input": input } - })) - .await + pub async fn generate_upload_url( + &self, + input: generate_release_upload_url::MutationGenerateReleaseUploadUrlInput, + ) -> Result< + Option, + ClientError, + > { + let response = self + .gql()? + .send::(generate_release_upload_url::Variables { input }) + .await?; + Ok(response.generate_release_upload_url) } /// Upload an artifact to a presigned S3 URL. @@ -274,6 +327,9 @@ impl ApplicationClient { /// header (it is not part of the S3 SigV4 signing string, so sending it can /// break the PUT), and retries transient failures — network errors and 5xx — /// with exponential backoff. 4xx responses are returned immediately. + /// + /// Not a GraphQL operation (a raw REST PUT to a presigned URL), so it stays + /// hand-written here rather than moving into `platform-app-client`. pub async fn upload_artifact( &self, url: &str, @@ -313,14 +369,14 @@ impl ApplicationClient { for attempt in 1..=opts.max_attempts { let request = self - .client + .http .put(url) .body(bytes.clone()) .headers(header_map.clone()) .build()?; cli_engine::transport::debug_log_reqwest_request(&request); - match self.client.execute(request).await { + match self.http.execute(request).await { Ok(resp) => { let status = resp.status(); let resp_headers = resp.headers().clone(); @@ -397,591 +453,5 @@ impl ApplicationClient { } #[cfg(test)] -mod tests { - use httpmock::prelude::*; - - use super::*; - - #[tokio::test] - async fn list_applications_sends_all_app_registry_statuses() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| { - let body = req.body_string(); - body.contains("ApplicationsList") - && body.contains(r#""in":["ACTIVE","ARCHIVED","BLOCKED","INACTIVE","VERIFYING"]"#) - }); - then.status(200).json_body(json!({ - "data": { - "applications": { - "edges": [ - { "node": { "id": "a1", "name": "active-app", "status": "ACTIVE" } }, - { "node": { "id": "a2", "name": "inactive-app", "status": "INACTIVE" } } - ] - } - } - })); - }) - .await; - - let data = ApplicationClient::new(server.base_url(), "test-token") - .list_applications() - .await - .expect("list applications"); - - mock.assert_async().await; - assert_eq!(data.as_array().expect("array").len(), 2); - assert_eq!(data[1]["status"], "INACTIVE"); - } - - #[tokio::test] - async fn activate_release_posts_mutation_with_ids() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| { - let body = req.body_string(); - body.contains("activateRelease") - && body.contains("app-123") - && body.contains("rel-456") - }); - then.status(200).json_body(json!({ - "data": { "activateRelease": { "id": "rel-456", "status": "ACTIVE" } } - })); - }) - .await; - - let data = ApplicationClient::new(server.base_url(), "test-token") - .activate_release("app-123", "rel-456") - .await - .expect("activate release"); - - mock.assert_async().await; - assert_eq!(data["activateRelease"]["status"], "ACTIVE"); - } - - #[tokio::test] - async fn list_enabled_store_applications_sends_store_id_variable() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| { - let body = req.body_string(); - body.contains("EnabledStoreApplications") - && body.contains("enabledStoreApplications") - && body.contains(r#""storeId":"store-abc""#) - && body.contains("label") - }); - then.status(200).json_body(json!({ - "data": { - "enabledStoreApplications": [{ - "id": "app-1", - "name": "my-app", - "label": "My App", - "status": "ACTIVE", - "release": { "id": "rel-1", "version": "1.0.0" } - }] - } - })); - }) - .await; - - let data = ApplicationClient::new(server.base_url(), "test-token") - .list_enabled_store_applications("store-abc") - .await - .expect("list enabled store applications"); - - mock.assert_async().await; - assert_eq!(data.as_array().expect("array").len(), 1); - assert_eq!(data[0]["name"], "my-app"); - assert_eq!(data[0]["label"], "My App"); - assert_eq!(data[0]["release"]["version"], "1.0.0"); - } - - #[tokio::test] - async fn list_enabled_store_applications_empty_list_is_success() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| req.body_string().contains("EnabledStoreApplications")); - then.status(200).json_body(json!({ - "data": { "enabledStoreApplications": [] } - })); - }) - .await; - - let data = ApplicationClient::new(server.base_url(), "test-token") - .list_enabled_store_applications("store-empty") - .await - .expect("empty enablements"); - - mock.assert_async().await; - assert_eq!(data, json!([])); - } - - #[tokio::test] - async fn create_release_sends_settings_input_and_returns_settings() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| { - let body = req.body_string(); - body.contains("CreateRelease") - && body.contains("settings { id groupSlug appSettingSlug entryPath capabilities order title }") - && body.contains(r#""entryPath":"/settings/godaddy-tax""#) - }); - then.status(200).json_body(json!({ - "data": { - "createRelease": { - "id": "rel-1", - "version": "1.0.0", - "settings": [{ - "id": "setting-1", - "groupSlug": "tax-center", - "appSettingSlug": "godaddy-tax", - "entryPath": "/settings/godaddy-tax", - "capabilities": ["read", "write"], - "order": 10, - "title": "GoDaddy Tax" - }] - } - } - })); - }) - .await; - - let input = json!({ - "applicationId": "app-123", - "version": "1.0.0", - "settings": [{ - "groupSlug": "tax-center", - "appSettingSlug": "godaddy-tax", - "entryPath": "/settings/godaddy-tax", - "presentation": { "type": "form", "schemaVersion": "settings-form-v1", "sections": [] } - }] - }); - let data = ApplicationClient::new(server.base_url(), "test-token") - .create_release(input) - .await - .expect("create release"); - - mock.assert_async().await; - assert_eq!( - data["createRelease"]["settings"][0]["entryPath"], - "/settings/godaddy-tax" - ); - } - - #[tokio::test] - async fn create_release_sends_native_extensions_input_and_selects_them() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST) - .path("/v1/apps/app-registry-subgraph") - .header("authorization", "Bearer test-token") - .is_true(|req| { - let body = req.body_string(); - body.contains("CreateRelease") - && body.contains( - "nativeExtensions { id name packageName contact platform }", - ) - && body.contains(r#""platform":"ANDROID""#) - && body.contains(r#""packageName":"com.example.app""#) - }); - then.status(200).json_body(json!({ - "data": { - "createRelease": { - "id": "rel-1", - "version": "1.0.11", - "nativeExtensions": [{ - "id": "ne-1", - "name": "My Display Name", - "packageName": "com.example.app", - "contact": "support@example.com", - "platform": "ANDROID" - }] - } - } - })); - }) - .await; - - let input = json!({ - "applicationId": "app-123", - "version": "1.0.11", - "nativeExtensions": [{ - "platform": "ANDROID", - "name": "My Display Name", - "contact": "support@example.com", - "packageName": "com.example.app" - }] - }); - let data = ApplicationClient::new(server.base_url(), "test-token") - .create_release(input) - .await - .expect("create release"); - - mock.assert_async().await; - assert_eq!(data["createRelease"]["nativeExtensions"][0]["id"], "ne-1"); - assert_eq!( - data["createRelease"]["nativeExtensions"][0]["packageName"], - "com.example.app" - ); - } - - #[tokio::test] - async fn activate_release_surfaces_graphql_errors() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST).path("/v1/apps/app-registry-subgraph"); - // GraphQL reports failures as HTTP 200 with an `errors` array. - then.status(200).json_body(json!({ - "errors": [{ "message": "release not found" }] - })); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .activate_release("app-123", "missing") - .await - .expect_err("graphql errors should surface"); - - mock.assert_async().await; - assert!( - matches!(err, ClientError::GraphQL(msg) if msg.contains("release not found")), - "expected GraphQL error variant" - ); - } - - /// Distinct from `activate_release_surfaces_graphql_errors`: GraphQL - /// reports failures as HTTP 200 with an `errors` array, but the - /// transport itself (auth rejected, gateway down, etc.) can also fail - /// at the HTTP layer with a non-2xx status. That path maps to - /// `ClientError::Http`, not `ClientError::GraphQL`. - #[tokio::test] - async fn query_maps_a_non_2xx_status_to_a_http_client_error() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(POST).path("/v1/apps/app-registry-subgraph"); - then.status(401).body("unauthorized"); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .get_application("test-app") - .await - .expect_err("non-2xx status should surface as an HTTP error"); - - mock.assert_async().await; - assert!( - matches!(err, ClientError::Http { status: 401, ref body } if body.contains("unauthorized")), - "expected Http error variant, got: {err:?}" - ); - } - - #[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; - 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("updateApplication") - && body.contains(r#""label":"Updated app""#) - }); - then.status(200).json_body(json!({ - "data": { "updateApplication": { "id": "app-1", "label": "Updated app" } } - })); - }) - .await; - - let data = ApplicationClient::new(server.base_url(), "test-token") - .update_application("app-1", json!({ "label": "Updated app" })) - .await - .expect("update application"); - - mock.assert_async().await; - assert_eq!(data["updateApplication"]["label"], "Updated app"); - } - - // httpmock can't sequence responses, so retries are verified by hit count - // (exhaustion) rather than a fail-then-succeed sequence. - - #[tokio::test] - async fn upload_rejects_oversize_file_without_uploading() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT).path("/upload"); - then.status(200); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .upload_artifact( - &server.url("/upload"), - "up-1", - &json!({}), - Some(4), // max 4 bytes - Bytes::from_static(b"way too big"), - UploadOptions { - max_attempts: 3, - base_delay_ms: 0, - }, - ) - .await - .expect_err("oversize should fail before uploading"); - - assert!( - matches!(err, ClientError::TooLarge { .. }), - "unexpected: {err}" - ); - assert_eq!(mock.calls_async().await, 0); - } - - #[tokio::test] - async fn upload_does_not_retry_on_4xx() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT).path("/upload"); - then.status(403).body("denied"); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .upload_artifact( - &server.url("/upload"), - "up-1", - &json!({}), - None, - Bytes::from_static(b"data"), - UploadOptions { - max_attempts: 3, - base_delay_ms: 0, - }, - ) - .await - .expect_err("4xx should fail immediately"); - - assert!( - matches!(err, ClientError::Http { status: 403, .. }), - "unexpected: {err}" - ); - assert_eq!(mock.calls_async().await, 1); - } - - #[tokio::test] - async fn upload_retries_on_5xx_until_exhausted() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT).path("/upload"); - then.status(503).body("try later"); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .upload_artifact( - &server.url("/upload"), - "up-1", - &json!({}), - None, - Bytes::from_static(b"data"), - UploadOptions { - max_attempts: 3, - base_delay_ms: 0, - }, - ) - .await - .expect_err("exhausted retries should fail"); - - assert!( - matches!(err, ClientError::Http { status: 503, .. }), - "unexpected: {err}" - ); - assert_eq!(mock.calls_async().await, 3); - } - - #[tokio::test] - async fn upload_strips_meta_upload_id_and_returns_metadata() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT) - .path("/upload") - .header("x-amz-signature", "sig") - // assert x-amz-meta-upload-id was stripped - .is_true(|req| { - !req.headers_vec() - .iter() - .any(|(k, _)| k.eq_ignore_ascii_case("x-amz-meta-upload-id")) - }); - then.status(200).header("etag", "\"abc123\""); - }) - .await; - - let result = ApplicationClient::new(server.base_url(), "test-token") - .upload_artifact( - &server.url("/upload"), - "up-42", - &json!({ - "x-amz-signature": "sig", - "x-amz-meta-upload-id": "should-be-stripped", - }), - None, - Bytes::from_static(b"hello"), - UploadOptions { - max_attempts: 3, - base_delay_ms: 0, - }, - ) - .await - .expect("upload should succeed"); - - mock.assert_async().await; - assert_eq!(result.upload_id, "up-42"); - assert_eq!(result.status, 200); - assert_eq!(result.size_bytes, 5); - assert_eq!(result.etag.as_deref(), Some("\"abc123\"")); - } - - #[tokio::test] - async fn upload_rejects_invalid_header_without_uploading() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT).path("/upload"); - then.status(200); - }) - .await; - - let err = ApplicationClient::new(server.base_url(), "test-token") - .upload_artifact( - &server.url("/upload"), - "up-1", - &json!({ "bad header name": "x" }), // space in name → invalid - None, - Bytes::from_static(b"data"), - UploadOptions { - max_attempts: 3, - base_delay_ms: 0, - }, - ) - .await - .expect_err("invalid header should fail before uploading"); - - assert!( - matches!(err, ClientError::InvalidHeader(_)), - "unexpected: {err}" - ); - assert_eq!(mock.calls_async().await, 0); - } - - #[tokio::test] - async fn upload_artifact_accepts_reusable_shared_payload() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(PUT) - .path("/artifact") - .body("shared extension bundle"); - then.status(200); - }) - .await; - let client = ApplicationClient::new(server.base_url(), "test-token"); - let payload = Bytes::from_static(b"shared extension bundle"); - let first_upload = payload.clone(); - let second_upload = payload.clone(); - assert_eq!(first_upload.as_ptr(), second_upload.as_ptr()); - - for (upload_id, bytes) in [("upload-1", first_upload), ("upload-2", second_upload)] { - client - .upload_artifact( - &server.url("/artifact"), - upload_id, - &json!({}), - None, - bytes, - UploadOptions { - max_attempts: 1, - base_delay_ms: 0, - }, - ) - .await - .expect("upload shared payload"); - } - - mock.assert_calls_async(2).await; - } -} +#[path = "client_tests.rs"] +mod tests; diff --git a/rust/src/platform/app/client_tests.rs b/rust/src/platform/app/client_tests.rs new file mode 100644 index 00000000..83be16dc --- /dev/null +++ b/rust/src/platform/app/client_tests.rs @@ -0,0 +1,921 @@ +use httpmock::prelude::*; +use serde_json::json; + +use super::*; + +#[tokio::test] +async fn list_applications_sends_all_app_registry_statuses() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|req| { + let body = req.body_string(); + body.contains("ApplicationsList") + && body.contains(r#""in":["ACTIVE","ARCHIVED","BLOCKED","INACTIVE","VERIFYING"]"#) + }); + then.status(200).json_body(json!({ + "data": { + "applications": { + "edges": [ + { "node": { "id": "a1", "label": "Active", "name": "active-app", "description": null, "status": "ACTIVE", "url": null, "proxyUrl": null } }, + { "node": { "id": "a2", "label": "Inactive", "name": "inactive-app", "description": null, "status": "INACTIVE", "url": null, "proxyUrl": null } } + ] + } + } + })); + }) + .await; + + let nodes = ApplicationClient::new(server.base_url(), "test-token") + .list_applications() + .await + .expect("list applications"); + + mock.assert_async().await; + assert_eq!(nodes.len(), 2); + assert_eq!( + nodes[1].status, + applications_list::ApplicationStatus::INACTIVE + ); +} + +#[tokio::test] +async fn activate_release_posts_mutation_with_ids() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|req| { + let body = req.body_string(); + body.contains("ActivateRelease") + && body.contains("app-123") + && body.contains("rel-456") + }); + then.status(200).json_body(json!({ + "data": { + "activateRelease": { + "id": "rel-456", + "version": "1.0.0", + "description": null, + "status": "ACTIVE", + "activatedAt": "2026-01-01T00:00:00Z", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + } + })); + }) + .await; + + let release = ApplicationClient::new(server.base_url(), "test-token") + .activate_release("app-123", "rel-456") + .await + .expect("activate release") + .expect("release present"); + + mock.assert_async().await; + assert_eq!( + release.status, + activate_release::ApplicationReleaseStatus::ACTIVE + ); +} + +#[tokio::test] +async fn list_enabled_store_applications_sends_store_id_variable() { + 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("EnabledStoreApplications") + && body.contains(r#""storeId":"store-abc""#) + }); + then.status(200).json_body(json!({ + "data": { + "enabledStoreApplications": [{ + "id": "app-1", + "name": "my-app", + "label": "My App", + "status": "ACTIVE", + "release": { "id": "rel-1", "version": "1.0.0" } + }] + } + })); + }) + .await; + + let apps = ApplicationClient::new(server.base_url(), "test-token") + .list_enabled_store_applications("store-abc") + .await + .expect("list enabled store applications"); + + mock.assert_async().await; + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].name, "my-app"); + assert_eq!(apps[0].label, "My App"); + assert_eq!( + apps[0].release.as_ref().map(|r| r.version.as_str()), + Some("1.0.0") + ); +} + +#[tokio::test] +async fn list_enabled_store_applications_empty_list_is_success() { + 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| req.body_string().contains("EnabledStoreApplications")); + then.status(200).json_body(json!({ + "data": { "enabledStoreApplications": [] } + })); + }) + .await; + + let apps = ApplicationClient::new(server.base_url(), "test-token") + .list_enabled_store_applications("store-empty") + .await + .expect("empty enablements"); + + mock.assert_async().await; + assert!(apps.is_empty()); +} + +#[tokio::test] +async fn create_release_sends_settings_input_and_returns_settings() { + 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("CreateRelease") + && body.contains(r#""entryPath":"/settings/godaddy-tax""#) + && body.contains(r#""groupSlug":"tax-center""#) + && body.contains(r#""packageName":"com.example.app""#) + && body.contains(r#""platform":"ANDROID""#) + }); + then.status(200).json_body(json!({ + "data": { + "createRelease": { + "id": "rel-1", + "version": "1.0.0", + "description": null, + "createdAt": "2026-01-01T00:00:00Z", + "uiExtensions": [], + "nativeExtensions": [{ + "id": "native-1", + "name": "My Android App", + "packageName": "com.example.app", + "contact": null, + "platform": "ANDROID" + }], + "settings": [{ + "id": "setting-1", + "groupSlug": "tax-center", + "appSettingSlug": "godaddy-tax", + "entryPath": "/settings/godaddy-tax", + "capabilities": ["read", "write"], + "order": 10, + "title": "GoDaddy Tax", + "titleKey": null, + "description": null, + "descriptionKey": null, + "iconName": null, + "iconLibrary": null, + "metadata": null, + "presentation": { "type": "form", "schemaVersion": "settings-form-v1", "sections": [] } + }] + } + } + })); + }) + .await; + + let input = create_release::MutationCreateReleaseInput { + application_id: "app-123".to_owned(), + version: "1.0.0".to_owned(), + description: None, + actions: None, + application_dependencies: None, + feature_dependencies: None, + mcp_servers: None, + native_extensions: Some(vec![ + create_release::ApplicationNativeExtensionCreateInput { + contact: None, + name: Some("My Android App".to_owned()), + package_name: Some("com.example.app".to_owned()), + platform: create_release::NativeExtensionPlatform::ANDROID, + }, + ]), + subscriptions: None, + ui_extensions: None, + settings: Some(vec![create_release::ApplicationSettingCreateInput { + group_slug: "tax-center".to_owned(), + app_setting_slug: "godaddy-tax".to_owned(), + entry_path: "/settings/godaddy-tax".to_owned(), + presentation: json!({ "type": "form", "schemaVersion": "settings-form-v1", "sections": [] }), + capabilities: None, + description: None, + description_key: None, + icon_name: None, + icon_library: None, + metadata: None, + order: None, + title: None, + title_key: None, + }]), + }; + let release = ApplicationClient::new(server.base_url(), "test-token") + .create_release(input) + .await + .expect("create release") + .expect("release present"); + + mock.assert_async().await; + assert_eq!(release.settings[0].entry_path, "/settings/godaddy-tax"); + assert_eq!(release.native_extensions.len(), 1); + assert_eq!( + release.native_extensions[0].package_name.as_deref(), + Some("com.example.app") + ); + assert_eq!( + release.native_extensions[0].platform, + create_release::NativeExtensionPlatform::ANDROID + ); +} + +#[tokio::test] +async fn activate_release_surfaces_graphql_errors() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/apps/app-registry-subgraph"); + // GraphQL reports failures as HTTP 200 with an `errors` array. + then.status(200).json_body(json!({ + "errors": [{ "message": "release not found" }] + })); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .activate_release("app-123", "missing") + .await + .expect_err("graphql errors should surface"); + + mock.assert_async().await; + assert!( + matches!(err, ClientError::GraphQL(msg) if msg.contains("release not found")), + "expected GraphQL error variant" + ); +} + +/// Distinct from `activate_release_surfaces_graphql_errors`: GraphQL +/// reports failures as HTTP 200 with an `errors` array, but the +/// transport itself (auth rejected, gateway down, etc.) can also fail +/// at the HTTP layer with a non-2xx status. That path maps to +/// `ClientError::Http`, not `ClientError::GraphQL`. +#[tokio::test] +async fn query_maps_a_non_2xx_status_to_a_http_client_error() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/apps/app-registry-subgraph"); + then.status(401).body("unauthorized"); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .get_application("test-app") + .await + .expect_err("non-2xx status should surface as an HTTP error"); + + mock.assert_async().await; + assert!( + matches!(err, ClientError::Http { status: 401, ref body } if body.contains("unauthorized")), + "expected Http error variant, got: {err:?}" + ); +} + +#[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| req.body_string().contains("ApplicationWithLatestRelease")); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-1", + "label": "My App", + "name": "my-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null, + "authorizationScopes": [], + "redirectUris": ["https://example.com/callback"], + "clientId": "client-1", + "releases": { + "edges": [{ + "node": { + "id": "rel-1", + "version": "1.0.0", + "description": null, + "createdAt": "2026-01-01T00:00:00Z", + "subscriptions": [{ + "name": "order-notifications", + "url": "https://proxy.example.com/webhooks/orders", + "events": ["commerce.order.created"] + }] + } + }] + } + } + } + })); + }) + .await; + + let app = ApplicationClient::new(server.base_url(), "test-token") + .get_application_with_releases("test-app") + .await + .expect("get application with releases") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.client_id.as_deref(), Some("client-1")); + let edges = app + .releases + .expect("releases connection") + .edges + .expect("edges"); + let node = edges[0] + .as_ref() + .expect("edge") + .node + .as_ref() + .expect("node"); + assert_eq!(node.subscriptions[0].name, "order-notifications"); +} + +#[tokio::test] +async fn update_application_sends_non_lifecycle_fields() { + 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("UpdateApplication") && body.contains(r#""label":"Updated app""#) + }); + then.status(200).json_body(json!({ + "data": { + "updateApplication": { + "id": "app-1", + "clientId": null, + "label": "Updated app", + "name": "my-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null, + "authorizationScopes": [], + "redirectUris": ["https://example.com/callback"] + } + } + })); + }) + .await; + + let input = update_application::MutationUpdateApplicationInput { + label: Some("Updated app".to_owned()), + description: None, + authorization_scopes: None, + distribution_type: None, + name: None, + proxy_url: None, + redirect_uris: None, + url: None, + }; + let app = ApplicationClient::new(server.base_url(), "test-token") + .update_application("app-1", input) + .await + .expect("update application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.label, "Updated app"); +} + +/// A GraphQL `null` is not the same as an omitted input field: the service +/// treats omitted fields as "leave unchanged" but an explicit `null` as an +/// instruction to clear. Unset `Option` inputs must therefore be left out of +/// the request entirely. +#[tokio::test] +async fn update_application_omits_unset_input_fields_from_the_request() { + 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: Value = serde_json::from_str(&req.body_string()).expect("json body"); + let input = &body["variables"]["input"]; + input.get("label") == Some(&json!("Updated app")) + && input.as_object().is_some_and(|fields| fields.len() == 1) + }); + then.status(200).json_body(json!({ + "data": { + "updateApplication": { + "id": "app-1", + "clientId": null, + "label": "Updated app", + "name": "my-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null, + "authorizationScopes": [], + "redirectUris": [] + } + } + })); + }) + .await; + + let input = update_application::MutationUpdateApplicationInput { + label: Some("Updated app".to_owned()), + description: None, + authorization_scopes: None, + distribution_type: None, + name: None, + proxy_url: None, + redirect_uris: None, + url: None, + }; + ApplicationClient::new(server.base_url(), "test-token") + .update_application("app-1", input) + .await + .expect("update application"); + + mock.assert_async().await; +} + +#[tokio::test] +async fn get_application_returns_selected_fields() { + 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| req.body_string().contains("Application")); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-1", + "label": "My App", + "name": "my-app", + "description": "desc", + "status": "ACTIVE", + "url": "https://example.com", + "proxyUrl": "https://proxy.example.com" + } + } + })); + }) + .await; + + let app = ApplicationClient::new(server.base_url(), "test-token") + .get_application("my-app") + .await + .expect("get application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.name, "my-app"); + assert_eq!(app.proxy_url.as_deref(), Some("https://proxy.example.com")); +} + +#[tokio::test] +async fn enable_application_sends_application_name_and_store_id() { + 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("EnableApplication") + && body.contains(r#""applicationName":"my-app""#) + && body.contains(r#""storeId":"store-1""#) + }); + then.status(200) + .json_body(json!({ "data": { "enableStoreApplication": { "id": "app-1" } } })); + }) + .await; + + let input = enable_application::MutationEnableStoreApplicationInput { + application_name: "my-app".to_owned(), + store_id: "store-1".to_owned(), + }; + let app = ApplicationClient::new(server.base_url(), "test-token") + .enable_application(input) + .await + .expect("enable application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.id, "app-1"); +} + +#[tokio::test] +async fn disable_application_sends_application_name_and_store_id() { + 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("DisableApplication") + && body.contains(r#""applicationName":"my-app""#) + && body.contains(r#""storeId":"store-1""#) + }); + then.status(200) + .json_body(json!({ "data": { "disableStoreApplication": { "id": "app-1" } } })); + }) + .await; + + let input = disable_application::MutationDisableStoreApplicationInput { + application_name: "my-app".to_owned(), + store_id: "store-1".to_owned(), + }; + let app = ApplicationClient::new(server.base_url(), "test-token") + .disable_application(input) + .await + .expect("disable application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.id, "app-1"); +} + +#[tokio::test] +async fn archive_application_sends_id_and_returns_lifecycle_fields() { + 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("ArchiveApplication") && body.contains(r#""id":"app-1""#) + }); + then.status(200).json_body(json!({ + "data": { + "archiveApplication": { + "id": "app-1", + "label": "My App", + "name": "my-app", + "status": "ARCHIVED", + "createdAt": "2026-01-01T00:00:00Z", + "archivedAt": "2026-02-01T00:00:00Z" + } + } + })); + }) + .await; + + let app = ApplicationClient::new(server.base_url(), "test-token") + .archive_application("app-1") + .await + .expect("archive application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.status, archive_application::ApplicationStatus::ARCHIVED); +} + +#[tokio::test] +async fn create_application_sends_input_and_returns_credentials() { + 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("CreateApplication") && body.contains(r#""name":"my-app""#) + }); + then.status(200).json_body(json!({ + "data": { + "createApplication": { + "id": "app-1", + "clientId": "client-1", + "clientSecret": "secret-1", + "label": "My App", + "name": "my-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null, + "authorizationScopes": [], + "redirectUris": [], + "secret": "webhook-secret", + "publicKey": "public-key" + } + } + })); + }) + .await; + + let input = create_application::MutationCreateApplicationInput { + name: "my-app".to_owned(), + label: "My App".to_owned(), + description: None, + url: None, + proxy_url: None, + organization_id: Some("org-1".to_owned()), + authorization_scopes: Some(vec![]), + distribution_type: None, + redirect_uris: None, + }; + let app = ApplicationClient::new(server.base_url(), "test-token") + .create_application(input) + .await + .expect("create application") + .expect("application present"); + + mock.assert_async().await; + assert_eq!(app.client_id.as_deref(), Some("client-1")); + assert_eq!(app.secret.as_deref(), Some("webhook-secret")); + assert_eq!(app.public_key.as_deref(), Some("public-key")); +} + +#[tokio::test] +async fn generate_upload_url_sends_content_type_enum_and_returns_headers() { + 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("GenerateReleaseUploadUrl") + && body.contains(r#""contentType":"JS""#) + }); + then.status(200).json_body(json!({ + "data": { + "generateReleaseUploadUrl": { + "uploadId": "up-1", + "url": "https://s3.example.com/upload", + "key": "key-1", + "expiresAt": "2026-01-01T00:00:00Z", + "maxSizeBytes": 1024, + "requiredHeaders": ["x-amz-signature:sig"] + } + } + })); + }) + .await; + + let input = generate_release_upload_url::MutationGenerateReleaseUploadUrlInput { + application_id: "app-1".to_owned(), + release_id: "rel-1".to_owned(), + content_type: generate_release_upload_url::UploadContentType::JS, + target: None, + }; + let upload = ApplicationClient::new(server.base_url(), "test-token") + .generate_upload_url(input) + .await + .expect("generate upload url") + .expect("upload response present"); + + mock.assert_async().await; + assert_eq!(upload.upload_id, "up-1"); + assert_eq!(upload.required_headers[0], "x-amz-signature:sig"); +} + +// httpmock can't sequence responses, so retries are verified by hit count +// (exhaustion) rather than a fail-then-succeed sequence. + +#[tokio::test] +async fn upload_rejects_oversize_file_without_uploading() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT).path("/upload"); + then.status(200); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .upload_artifact( + &server.url("/upload"), + "up-1", + &json!({}), + Some(4), // max 4 bytes + Bytes::from_static(b"way too big"), + UploadOptions { + max_attempts: 3, + base_delay_ms: 0, + }, + ) + .await + .expect_err("oversize should fail before uploading"); + + assert!( + matches!(err, ClientError::TooLarge { .. }), + "unexpected: {err}" + ); + assert_eq!(mock.calls_async().await, 0); +} + +#[tokio::test] +async fn upload_does_not_retry_on_4xx() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT).path("/upload"); + then.status(403).body("denied"); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .upload_artifact( + &server.url("/upload"), + "up-1", + &json!({}), + None, + Bytes::from_static(b"data"), + UploadOptions { + max_attempts: 3, + base_delay_ms: 0, + }, + ) + .await + .expect_err("4xx should fail immediately"); + + assert!( + matches!(err, ClientError::Http { status: 403, .. }), + "unexpected: {err}" + ); + assert_eq!(mock.calls_async().await, 1); +} + +#[tokio::test] +async fn upload_retries_on_5xx_until_exhausted() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT).path("/upload"); + then.status(503).body("try later"); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .upload_artifact( + &server.url("/upload"), + "up-1", + &json!({}), + None, + Bytes::from_static(b"data"), + UploadOptions { + max_attempts: 3, + base_delay_ms: 0, + }, + ) + .await + .expect_err("exhausted retries should fail"); + + assert!( + matches!(err, ClientError::Http { status: 503, .. }), + "unexpected: {err}" + ); + assert_eq!(mock.calls_async().await, 3); +} + +#[tokio::test] +async fn upload_strips_meta_upload_id_and_returns_metadata() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT) + .path("/upload") + .header("x-amz-signature", "sig") + // assert x-amz-meta-upload-id was stripped + .is_true(|req| { + !req.headers_vec() + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-amz-meta-upload-id")) + }); + then.status(200).header("etag", "\"abc123\""); + }) + .await; + + let result = ApplicationClient::new(server.base_url(), "test-token") + .upload_artifact( + &server.url("/upload"), + "up-42", + &json!({ + "x-amz-signature": "sig", + "x-amz-meta-upload-id": "should-be-stripped", + }), + None, + Bytes::from_static(b"hello"), + UploadOptions { + max_attempts: 3, + base_delay_ms: 0, + }, + ) + .await + .expect("upload should succeed"); + + mock.assert_async().await; + assert_eq!(result.upload_id, "up-42"); + assert_eq!(result.status, 200); + assert_eq!(result.size_bytes, 5); + assert_eq!(result.etag.as_deref(), Some("\"abc123\"")); +} + +#[tokio::test] +async fn upload_rejects_invalid_header_without_uploading() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT).path("/upload"); + then.status(200); + }) + .await; + + let err = ApplicationClient::new(server.base_url(), "test-token") + .upload_artifact( + &server.url("/upload"), + "up-1", + &json!({ "bad header name": "x" }), // space in name → invalid + None, + Bytes::from_static(b"data"), + UploadOptions { + max_attempts: 3, + base_delay_ms: 0, + }, + ) + .await + .expect_err("invalid header should fail before uploading"); + + assert!( + matches!(err, ClientError::InvalidHeader(_)), + "unexpected: {err}" + ); + assert_eq!(mock.calls_async().await, 0); +} + +#[tokio::test] +async fn upload_artifact_accepts_reusable_shared_payload() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT) + .path("/artifact") + .body("shared extension bundle"); + then.status(200); + }) + .await; + let client = ApplicationClient::new(server.base_url(), "test-token"); + let payload = Bytes::from_static(b"shared extension bundle"); + let first_upload = payload.clone(); + let second_upload = payload.clone(); + assert_eq!(first_upload.as_ptr(), second_upload.as_ptr()); + + for (upload_id, bytes) in [("upload-1", first_upload), ("upload-2", second_upload)] { + client + .upload_artifact( + &server.url("/artifact"), + upload_id, + &json!({}), + None, + bytes, + UploadOptions { + max_attempts: 1, + base_delay_ms: 0, + }, + ) + .await + .expect("upload shared payload"); + } + + mock.assert_calls_async(2).await; +} diff --git a/rust/src/platform/app/commands/add.rs b/rust/src/platform/app/commands/add.rs index 4dbd35a8..724dd44d 100644 --- a/rust/src/platform/app/commands/add.rs +++ b/rust/src/platform/app/commands/add.rs @@ -423,15 +423,20 @@ mod tests { #[test] fn application_name_lookup_uses_registry_id_not_oauth_client_id() { - let data = json!({ - "application": { - "id": "app-registry-id", - "clientId": "550e8400-e29b-41d4-a716-446655440000", - "name": "my-app" - } - }); + // The OAuth client id is deliberately not part of the lookup's + // selection at all, so it cannot be mistaken for the registry id. + let application = platform_app_client::application::ApplicationApplication { + id: "app-registry-id".to_owned(), + label: "My App".to_owned(), + name: "my-app".to_owned(), + description: None, + status: platform_app_client::application::ApplicationStatus::ACTIVE, + url: None, + proxy_url: None, + }; assert_eq!( - super::native_extension::application_id(&data, "my-app").expect("application id"), + super::native_extension::application_id(Some(&application), "my-app") + .expect("application id"), "app-registry-id" ); } @@ -449,7 +454,9 @@ mod tests { "data": { "application": { "id": "app-registry-id", - "name": "my-app" + "label": "My App", + "name": "my-app", + "status": "ACTIVE" } } })); @@ -557,7 +564,7 @@ mod tests { when.method(Method::POST) .path("/v1/apps/app-registry-subgraph"); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; diff --git a/rust/src/platform/app/commands/add/native_extension.rs b/rust/src/platform/app/commands/add/native_extension.rs index 5b22e910..3f9227d2 100644 --- a/rust/src/platform/app/commands/add/native_extension.rs +++ b/rust/src/platform/app/commands/add/native_extension.rs @@ -3,6 +3,7 @@ use std::io::IsTerminal; use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Stage, Tier}; +use platform_app_client::application::ApplicationApplication; use serde_json::json; use super::super::schemas::ConfigNativeExtension; @@ -83,18 +84,19 @@ pub(super) fn prepare_native_extension( Ok(config) } -pub(super) fn application_id(data: &serde_json::Value, name: &str) -> cli_engine::Result { - let application = &data["application"]; - if application.is_null() { +pub(super) fn application_id( + application: Option<&ApplicationApplication>, + name: &str, +) -> cli_engine::Result { + let Some(application) = application else { return Err(crate::error::GddyError::not_found(format!( "application {name:?} was not found" )) .with_system("applications") .into_cli_error()); - } + }; - application["id"] - .as_str() + Some(application.id.as_str()) .filter(|id| !id.is_empty()) .map(str::to_owned) .ok_or_else(|| { @@ -114,13 +116,14 @@ pub(super) async fn sync_native_extension( accept_agreements: bool, is_tty: bool, ) -> cli_engine::Result { + crate::http::ensure_generated_client_transport_observer_registered(); let app_registry = crate::platform::app::client::ApplicationClient::new(app_registry_url, token.to_owned()); let application = app_registry .get_application(&config.name) .await .map_err(super::super::client_err)?; - let application_id = application_id(&application, &config.name)?; + let application_id = application_id(application.as_ref(), &config.name)?; let native = config .native_extension @@ -344,15 +347,19 @@ mod tests { #[test] fn application_name_lookup_uses_registry_id_not_oauth_client_id() { - let data = json!({ - "application": { - "id": "app-registry-id", - "clientId": "550e8400-e29b-41d4-a716-446655440000", - "name": "my-app" - } - }); + // The OAuth client id is deliberately not part of the lookup's + // selection at all, so it cannot be mistaken for the registry id. + let application = ApplicationApplication { + id: "app-registry-id".to_owned(), + label: "My App".to_owned(), + name: "my-app".to_owned(), + description: None, + status: platform_app_client::application::ApplicationStatus::ACTIVE, + url: None, + proxy_url: None, + }; assert_eq!( - application_id(&data, "my-app").expect("application id"), + application_id(Some(&application), "my-app").expect("application id"), "app-registry-id" ); } @@ -367,7 +374,7 @@ mod tests { .header("authorization", "Bearer test-token") .is_true(|request| request.body_string().contains(r#""name":"my-app""#)); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; @@ -461,7 +468,7 @@ mod tests { when.method(Method::POST) .path("/v1/apps/app-registry-subgraph"); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; @@ -531,7 +538,7 @@ mod tests { when.method(Method::POST) .path("/v1/apps/app-registry-subgraph"); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; @@ -620,7 +627,7 @@ mod tests { when.method(Method::POST) .path("/v1/apps/app-registry-subgraph"); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; @@ -726,7 +733,7 @@ mod tests { when.method(Method::POST) .path("/v1/apps/app-registry-subgraph"); then.status(200).json_body(json!({ - "data": { "application": { "id": "app-registry-id" } } + "data": { "application": { "id": "app-registry-id", "label": "My App", "name": "my-app", "status": "ACTIVE" } } })); }) .await; diff --git a/rust/src/platform/app/commands/deploy/extensions.rs b/rust/src/platform/app/commands/deploy/extensions.rs index 1b6c1d01..082b8caf 100644 --- a/rust/src/platform/app/commands/deploy/extensions.rs +++ b/rust/src/platform/app/commands/deploy/extensions.rs @@ -2,6 +2,9 @@ //! by [`super::command`]'s deploy orchestration. use cli_engine::StreamSender; +use platform_app_client::generate_release_upload_url::{ + MutationGenerateReleaseUploadUrlInput, UploadContentType, +}; use serde_json::{Value, json}; use crate::platform::app::client::{ApplicationClient, UploadOptions}; @@ -263,34 +266,33 @@ pub(super) async fn deploy_extension( .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_input = MutationGenerateReleaseUploadUrlInput { + application_id: application_id.to_owned(), + release_id: release_id.to_owned(), + content_type: UploadContentType::JS, + target: target.clone(), + }; - let upload_data = client + let upload = client .generate_upload_url(upload_input) .await - .map_err(super::super::client_err)?; + .map_err(super::super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "generateReleaseUploadUrl returned no data".to_owned(), + ) + .into_cli_error() + })?; - 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(); + let upload_url = upload.url; + let upload_id = upload.upload_id; + let max_size_bytes = u64::try_from(upload.max_size_bytes).ok(); - // Parse required headers from ["key:value"] array + // Parse required headers from ["key:value"] entries. 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())); - } + for h in &upload.required_headers { + if let Some((k, v)) = h.split_once(':') { + headers.insert(k.trim().to_owned(), json!(v.trim())); } } diff --git a/rust/src/platform/app/commands/deploy/mod.rs b/rust/src/platform/app/commands/deploy/mod.rs index a4d9b844..6c754c9b 100644 --- a/rust/src/platform/app/commands/deploy/mod.rs +++ b/rust/src/platform/app/commands/deploy/mod.rs @@ -135,6 +135,10 @@ pub(super) fn command() -> RuntimeCommandSpec { 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?; + // Bypasses `super::make_client` (streaming commands build their + // own client), so the transport-observer registration it would + // otherwise do has to happen here instead. + crate::http::ensure_generated_client_transport_observer_registered(); let client = ApplicationClient::new(base_url, token); sender @@ -162,7 +166,7 @@ pub(super) fn command() -> RuntimeCommandSpec { sender .send(json!({ "type": "step", "name": "application.lookup", "status": "started" })) .await; - let app_data = tap_deploy_err( + let app = tap_deploy_err( &sender, client .get_application_with_releases(&name) @@ -170,14 +174,13 @@ pub(super) fn command() -> RuntimeCommandSpec { .map_err(super::client_err), ) .await?; - let app = &app_data["application"]; - if app.is_null() { + let Some(app) = app else { 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(); + }; + let application_id = app.id.clone(); sender .send(json!({ "type": "step", "name": "application.lookup", "status": "completed", "id": application_id })) .await; @@ -186,11 +189,14 @@ pub(super) fn command() -> RuntimeCommandSpec { 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 release_id = app + .releases + .and_then(|connection| connection.edges) + .into_iter() + .flatten() + .flatten() + .find_map(|edge| edge.node) + .map(|node| node.id); 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" @@ -279,7 +285,11 @@ async fn activate_release( client .activate_release(application_id, release_id) .await - .map_err(super::client_err)?; + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected("activateRelease returned no data".to_owned()) + .into_cli_error() + })?; sender .send(json!({ "type": "step", "name": "release.activate", "status": "completed" })) .await; @@ -302,21 +312,32 @@ async fn sync_manifest_metadata( client .update_application(application_id, manifest_metadata_input(config)) .await - .map_err(super::client_err)?; + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected("updateApplication returned no data".to_owned()) + .into_cli_error() + })?; sender .send(json!({ "type": "step", "name": "application.sync", "status": "completed" })) .await; Ok(()) } -fn manifest_metadata_input(config: &crate::config::Config) -> Value { - let mut input = json!({ - "url": config.url, - "proxyUrl": config.proxy_url, - "authorizationScopes": config.authorization_scopes, - }); - super::add_redirect_uris_to_input(&mut input, config.redirect_uris.as_deref()); - input +fn manifest_metadata_input( + config: &crate::config::Config, +) -> platform_app_client::update_application::MutationUpdateApplicationInput { + platform_app_client::update_application::MutationUpdateApplicationInput { + url: Some(config.url.clone()), + proxy_url: Some(config.proxy_url.clone()), + authorization_scopes: Some(config.authorization_scopes.clone()), + // `None` is omitted on the wire, so an absent manifest key leaves the + // remote allowlist alone; `Some(vec![])` clears it. + redirect_uris: config.redirect_uris.clone(), + label: None, + description: None, + distribution_type: None, + name: None, + } } #[cfg(test)] @@ -449,31 +470,48 @@ mod tests { let input = super::manifest_metadata_input(&config); - assert_eq!(input["url"], "https://app.example.com"); - assert_eq!(input["proxyUrl"], "https://api.example.com"); + assert_eq!(input.url, Some("https://app.example.com".to_owned())); + assert_eq!(input.proxy_url, Some("https://api.example.com".to_owned())); assert_eq!( - input["authorizationScopes"], - serde_json::json!(["openid", "profile"]) + input.authorization_scopes, + Some(vec!["openid".to_owned(), "profile".to_owned()]) ); assert_eq!( - input["redirectUris"], + input.redirect_uris, + Some(vec!["https://auth.example.net/callback".to_owned()]) + ); + assert_eq!(input.label, None); + assert_eq!(input.name, None); + + // What matters is the wire shape: an omitted manifest key must leave + // the remote allowlist unchanged, so `None` must not serialize as an + // explicit `null`; an explicit empty list must be sent to clear it. + let wire = serde_json::to_value(&input).expect("serialize input"); + assert_eq!( + wire["redirectUris"], serde_json::json!(["https://auth.example.net/callback"]) ); - assert!(input.get("status").is_none()); + assert!(wire.get("status").is_none()); + assert!( + wire.get("label").is_none(), + "unset fields are omitted: {wire}" + ); let mut omitted = config.clone(); omitted.redirect_uris = None; + let wire = serde_json::to_value(super::manifest_metadata_input(&omitted)) + .expect("serialize input"); assert!( - super::manifest_metadata_input(&omitted) - .get("redirectUris") - .is_none(), - "an omitted manifest key must leave the remote allowlist unchanged" + wire.get("redirectUris").is_none(), + "an omitted manifest key must leave the remote allowlist unchanged: {wire}" ); let mut clear = config; clear.redirect_uris = Some(vec![]); + let wire = + serde_json::to_value(super::manifest_metadata_input(&clear)).expect("serialize input"); assert_eq!( - super::manifest_metadata_input(&clear)["redirectUris"], + wire["redirectUris"], serde_json::json!([]), "an explicit empty list must clear the remote allowlist" ); diff --git a/rust/src/platform/app/commands/enablements.rs b/rust/src/platform/app/commands/enablements.rs index 2626a5d9..be4ef7af 100644 --- a/rust/src/platform/app/commands/enablements.rs +++ b/rust/src/platform/app/commands/enablements.rs @@ -3,6 +3,7 @@ use cli_engine::{ CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, }; +use platform_app_client::enabled_store_applications::EnabledStoreApplicationsEnabledStoreApplications; use serde_json::{Value, json}; use super::schemas::StoreEnablement; @@ -31,23 +32,16 @@ fn view_columns() -> Vec { /// Lifts `release.version` to top-level `releaseVersion` so defaults can show /// the version string without embedding the whole `release` object (dotted /// `--fields` paths like `release.version` are not supported). -fn flatten_enablements(data: Value) -> Value { - let Some(rows) = data.as_array() else { - return json!([]); - }; +fn flatten_enablements(rows: Vec) -> Value { let flattened: Vec = rows - .iter() + .into_iter() .map(|row| { - let release_version = row - .pointer("/release/version") - .cloned() - .unwrap_or(Value::Null); json!({ - "id": row.get("id").cloned().unwrap_or(Value::Null), - "name": row.get("name").cloned().unwrap_or(Value::Null), - "label": row.get("label").cloned().unwrap_or(Value::Null), - "status": row.get("status").cloned().unwrap_or(Value::Null), - "releaseVersion": release_version, + "id": row.id, + "name": row.name, + "label": row.label, + "status": row.status, + "releaseVersion": row.release.map(|r| r.version), }) }) .collect(); @@ -112,10 +106,28 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { use cli_engine::{Cli, CliConfig, Stage}; - use serde_json::json; + use platform_app_client::enabled_store_applications::{ + ApplicationStatus, EnabledStoreApplicationsEnabledStoreApplications, + EnabledStoreApplicationsEnabledStoreApplicationsRelease, + }; use super::{command, flatten_enablements}; + fn row(release: Option<&str>) -> EnabledStoreApplicationsEnabledStoreApplications { + EnabledStoreApplicationsEnabledStoreApplications { + id: "app-1".to_owned(), + name: "my-app".to_owned(), + label: "My App".to_owned(), + status: ApplicationStatus::ACTIVE, + release: release.map(|version| { + EnabledStoreApplicationsEnabledStoreApplicationsRelease { + id: "rel-1".to_owned(), + version: version.to_owned(), + } + }), + } + } + #[test] fn command_requires_store_id_flag() { command() @@ -144,16 +156,10 @@ mod tests { #[test] fn flatten_enablements_lifts_release_version_only() { - let input = json!([{ - "id": "app-1", - "name": "my-app", - "label": "My App", - "status": "ACTIVE", - "release": { "id": "rel-1", "version": "1.2.3" } - }]); + let flattened = flatten_enablements(vec![row(Some("1.2.3"))]); assert_eq!( - flatten_enablements(input), - json!([{ + flattened, + serde_json::json!([{ "id": "app-1", "name": "my-app", "label": "My App", @@ -165,14 +171,8 @@ mod tests { #[test] fn flatten_enablements_null_release_version_when_missing() { - let input = json!([{ "id": "app-1", "name": "my-app", "status": "ACTIVE" }]); - assert_eq!(flatten_enablements(input)[0]["releaseVersion"], json!(null)); - } - - #[test] - fn flatten_enablements_null_label_when_missing() { - let input = json!([{ "id": "app-1", "name": "my-app", "status": "ACTIVE" }]); - assert_eq!(flatten_enablements(input)[0]["label"], json!(null)); + let flattened = flatten_enablements(vec![row(None)]); + assert_eq!(flattened[0]["releaseVersion"], serde_json::json!(null)); } #[tokio::test] diff --git a/rust/src/platform/app/commands/import.rs b/rust/src/platform/app/commands/import.rs index fb364cfc..97acab41 100644 --- a/rust/src/platform/app/commands/import.rs +++ b/rust/src/platform/app/commands/import.rs @@ -2,7 +2,11 @@ //! config and webhook subscriptions into a local godaddy.toml. use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, TableColumn, Tier}; -use serde_json::{Value, json}; +use platform_app_client::application_with_latest_release::{ + ApplicationWithLatestReleaseApplication, + ApplicationWithLatestReleaseApplicationReleasesEdgesNode, +}; +use serde_json::json; use super::schemas::ApplicationImport; use crate::next_action::{next_action, required_value}; @@ -21,55 +25,40 @@ struct ImportArgs { force: bool, } -fn redirect_uris_from_application(app: &Value) -> Option> { - app["redirectUris"].as_array().map(|redirect_uris| { - redirect_uris - .iter() - .filter_map(|uri| uri.as_str().map(str::to_owned)) - .collect() - }) -} - /// 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"]) +fn latest_release( + app: &ApplicationWithLatestReleaseApplication, +) -> Option<&ApplicationWithLatestReleaseApplicationReleasesEdgesNode> { + app.releases + .as_ref()? + .edges + .as_ref()? + .first()? + .as_ref()? + .node + .as_ref() } /// 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, + app: &ApplicationWithLatestReleaseApplication, 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() + let Some(node) = latest_release(app) else { + return Vec::new(); + }; + node.subscriptions + .iter() + .map(|sub| crate::config::SubscriptionConfig { + name: sub.name.clone(), + events: sub.events.clone(), + url: crate::config::relativize_webhook_url(&sub.url, proxy_url), }) - .unwrap_or_default() + .collect() } /// A subscription's `(name, url, events)` reduced to a comparable signature, @@ -223,30 +212,27 @@ pub(super) async fn run( let config_path = crate::config::config_path(Some(&env)); let client = super::make_client(ctx).await?; - let data = client + let app = client .get_application_with_releases(&name) .await - .map_err(super::client_err)?; - let app = &data["application"]; - if app.is_null() { - return Err( + .map_err(super::client_err)? + .ok_or_else(|| { crate::error::GddyError::not_found(format!("application '{name}' not found")) - .into_cli_error(), - ); - } + .into_cli_error() + })?; - let client_id = app["clientId"].as_str().unwrap_or("").to_owned(); + let client_id = app.client_id.clone().unwrap_or_default(); let description = overrides .description - .or_else(|| app["description"].as_str().map(str::to_owned)) + .or_else(|| app.description.clone()) .unwrap_or_default(); let url = overrides .url - .or_else(|| app["url"].as_str().map(str::to_owned)) + .or_else(|| app.url.clone()) .unwrap_or_default(); let proxy_url = overrides .proxy_url - .or_else(|| app["proxyUrl"].as_str().map(str::to_owned)) + .or_else(|| app.proxy_url.clone()) .unwrap_or_default(); let scopes: Vec = overrides .scopes @@ -257,16 +243,8 @@ pub(super) async fn run( .map(str::to_owned) .collect() }) - .or_else(|| { - app["authorizationScopes"].as_array().map(|scopes| { - scopes - .iter() - .filter_map(|s| s.as_str().map(str::to_owned)) - .collect() - }) - }) - .unwrap_or_default(); - let redirect_uris = redirect_uris_from_application(app); + .unwrap_or_else(|| app.authorization_scopes.clone()); + let redirect_uris = Some(app.redirect_uris.clone()); for (field, u) in [("url", &url), ("proxyUrl", &proxy_url)] { if !crate::platform::app::public_url::is_public_routable_url(u) { @@ -277,7 +255,7 @@ pub(super) async fn run( } } - let webhook_subscriptions = subscriptions_from_latest_release(app, &proxy_url); + let webhook_subscriptions = subscriptions_from_latest_release(&app, &proxy_url); // Preserve locally-authored fields the API doesn't track (actions, // dependencies, extensions, settings, native_extension), if a godaddy.toml @@ -300,9 +278,8 @@ pub(super) async fn run( // 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) + let version = latest_release(&app) + .map(|node| node.version.clone()) .or_else(|| existing.as_ref().map(|c| c.version.clone())) .unwrap_or_else(|| "0.0.0".to_owned()); let actions = existing @@ -350,9 +327,9 @@ pub(super) async fn run( .collect(); let mut result = json!({ - "id": app["id"].as_str().unwrap_or("").to_owned(), + "id": app.id, "name": name, - "status": app["status"].as_str().unwrap_or("").to_owned(), + "status": app.status, "clientId": config.client_id, "url": url, "proxyUrl": proxy_url, @@ -408,6 +385,13 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { + use platform_app_client::application_with_latest_release::{ + ApplicationStatus, ApplicationWithLatestReleaseApplication, + ApplicationWithLatestReleaseApplicationReleases, + ApplicationWithLatestReleaseApplicationReleasesEdges, + ApplicationWithLatestReleaseApplicationReleasesEdgesNode, + ApplicationWithLatestReleaseApplicationReleasesEdgesNodeSubscriptions, + }; use serde_json::json; use super::{ @@ -493,21 +477,54 @@ mod tests { assert_eq!(subscriptions_at_risk(&local, &remote), vec!["a"]); } + fn app_with_release_edges( + edges: Option>>, + ) -> ApplicationWithLatestReleaseApplication { + ApplicationWithLatestReleaseApplication { + id: "app-1".to_owned(), + label: "My App".to_owned(), + name: "my-app".to_owned(), + description: None, + status: ApplicationStatus::ACTIVE, + url: None, + proxy_url: None, + authorization_scopes: vec![], + redirect_uris: vec![], + client_id: None, + releases: Some(ApplicationWithLatestReleaseApplicationReleases { edges }), + } + } + + fn release_edge( + version: &str, + subscriptions: Vec, + ) -> Option { + Some(ApplicationWithLatestReleaseApplicationReleasesEdges { + node: Some(ApplicationWithLatestReleaseApplicationReleasesEdgesNode { + id: "rel-1".to_owned(), + version: version.to_owned(), + description: None, + created_at: "2026-01-01T00:00:00Z".to_owned(), + subscriptions, + }), + }) + } + #[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 app = app_with_release_edges(Some(vec![release_edge( + "1.0.0", + vec![ + ApplicationWithLatestReleaseApplicationReleasesEdgesNodeSubscriptions { + name: "order-notifications".to_owned(), + url: "https://proxy.example.com/webhooks/orders".to_owned(), + events: vec![ + "commerce.order.created".to_owned(), + "commerce.order.updated".to_owned(), + ], + }, + ], + )])); let subs = subscriptions_from_latest_release(&app, "https://proxy.example.com"); assert_eq!(subs.len(), 1); assert_eq!(subs[0].name, "order-notifications"); @@ -520,24 +537,22 @@ mod tests { #[test] fn subscriptions_from_latest_release_is_empty_without_releases() { - let app = json!({ "releases": { "edges": [] } }); + let app = app_with_release_edges(Some(vec![])); 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" } }] } - }); + let app = app_with_release_edges(Some(vec![release_edge("1.4.2", vec![])])); assert_eq!( - latest_release(&app).and_then(|node| node["version"].as_str()), + latest_release(&app).map(|node| node.version.as_str()), Some("1.4.2") ); } #[test] fn latest_release_is_none_without_releases() { - let app = json!({ "releases": { "edges": [] } }); + let app = app_with_release_edges(Some(vec![])); assert!(latest_release(&app).is_none()); } diff --git a/rust/src/platform/app/commands/info.rs b/rust/src/platform/app/commands/info.rs index 341f9ea5..b6231c8b 100644 --- a/rust/src/platform/app/commands/info.rs +++ b/rust/src/platform/app/commands/info.rs @@ -26,19 +26,20 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx, args: InfoArgs| async move { let name = args.name; let client = super::make_client(&ctx).await?; - let data = client + let app = 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![ + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::not_found(format!("application '{name}' not found")) + .into_cli_error() + })?; + let app_id = app.id.clone(); + let data = serde_json::to_value(&app).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode application: {e}")) + .into_cli_error() + })?; + Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "platform app validate ", "Validate application configuration", diff --git a/rust/src/platform/app/commands/init.rs b/rust/src/platform/app/commands/init.rs index ccb1d190..dc86df60 100644 --- a/rust/src/platform/app/commands/init.rs +++ b/rust/src/platform/app/commands/init.rs @@ -3,7 +3,8 @@ use cli_engine::{ CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, }; -use serde_json::{Value, json}; +use platform_app_client::create_application::MutationCreateApplicationInput; +use serde_json::json; use super::schemas::ApplicationInit; use crate::next_action::{next_action, required_value}; @@ -62,15 +63,6 @@ struct InitArgs { force: bool, } -fn redirect_uris_from_application(app: &Value) -> Option> { - app["redirectUris"].as_array().map(|redirect_uris| { - redirect_uris - .iter() - .filter_map(|uri| uri.as_str().map(str::to_owned)) - .collect() - }) -} - fn validate_resolved_redirect_uris( redirect_uris: Option<&[String]>, app_url: &str, @@ -81,10 +73,6 @@ fn validate_resolved_redirect_uris( }) } -fn create_result_redirect_uris(app: &Value, requested: Option<&[String]>) -> Option> { - redirect_uris_from_application(app).or_else(|| requested.map(<[String]>::to_vec)) -} - /// `filesWritten` is a small path-by-kind object (`config`/`env`), so it /// renders as an indented property bag instead of a raw JSON dump. fn init_view_columns() -> Vec { @@ -226,27 +214,31 @@ pub(super) fn command() -> RuntimeCommandSpec { ensure_ready_for_app_init(&credential.token, &env, accept_agreements).await?; let client = super::make_client(&ctx).await?; - let mut input = json!({ - "name": name, - "label": label, - "description": description, - "url": url, - "proxyUrl": proxy_url, - "organizationId": &onboarding.org_id, - "authorizationScopes": scopes, - }); - super::add_redirect_uris_to_input(&mut input, redirect_uris.as_deref()); - let data = client - .create_application(input) + let app = client + .create_application(MutationCreateApplicationInput { + name: name.clone(), + label: label.clone(), + description: Some(description.clone()), + url: Some(url.clone()), + proxy_url: Some(proxy_url.clone()), + organization_id: Some(onboarding.org_id.clone()), + authorization_scopes: Some(scopes.clone()), + redirect_uris: redirect_uris.clone(), + distribution_type: None, + }) .await - .map_err(super::client_err)?; - - let app = &data["createApplication"]; + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "createApplication returned no data".to_owned(), + ) + .into_cli_error() + })?; // Credentials/secrets the API returns (all selected by create_application). - let client_id = app["clientId"].as_str().unwrap_or("").to_owned(); - let client_secret = app["clientSecret"].as_str().unwrap_or("").to_owned(); - let secret = app["secret"].as_str().unwrap_or("").to_owned(); - let public_key = app["publicKey"].as_str().unwrap_or("").to_owned(); + let client_id = app.client_id.clone().unwrap_or_default(); + let client_secret = app.client_secret.clone().unwrap_or_default(); + let secret = app.secret.clone().unwrap_or_default(); + let public_key = app.public_key.clone().unwrap_or_default(); // Best-effort local writes: app create already succeeded. Only paths that // actually write are included in filesWritten. @@ -303,12 +295,11 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } - let app_id = app["id"].as_str().unwrap_or("").to_owned(); - let result_redirect_uris = create_result_redirect_uris(app, redirect_uris.as_deref()); - let mut result = json!({ + let app_id = app.id.clone(); + let result = json!({ "id": app_id, "name": name, - "status": app["status"].as_str().unwrap_or("").to_owned(), + "status": app.status, "clientId": client_id, "orgId": onboarding.org_id, "url": url, @@ -316,8 +307,8 @@ pub(super) fn command() -> RuntimeCommandSpec { "authorizationScopes": scopes, "oauthGrantTypes": ["authorization_code", "client_credentials"], "filesWritten": files_written, + "redirectUris": app.redirect_uris, }); - super::add_redirect_uris_to_input(&mut result, result_redirect_uris.as_deref()); Ok(CommandResult::new(result).with_next_actions(vec![ next_action( "platform app add action --name --url ", @@ -347,25 +338,7 @@ pub(super) fn command() -> RuntimeCommandSpec { mod tests { use serde_json::json; - use super::{ - create_result_redirect_uris, init_view_columns, redirect_uris_from_application, - validate_resolved_redirect_uris, - }; - - #[test] - fn redirect_uris_from_application_preserves_missing_empty_and_populated_values() { - assert_eq!(redirect_uris_from_application(&json!({})), None); - assert_eq!( - redirect_uris_from_application(&json!({ "redirectUris": [] })), - Some(vec![]) - ); - assert_eq!( - redirect_uris_from_application(&json!({ - "redirectUris": ["https://auth.example.net/callback"] - })), - Some(vec!["https://auth.example.net/callback".to_owned()]) - ); - } + use super::{init_view_columns, validate_resolved_redirect_uris}; #[test] fn resolved_url_override_is_validated_before_create() { @@ -381,22 +354,6 @@ mod tests { ); } - #[test] - fn create_result_redirect_uris_prefers_api_response_and_falls_back_to_request() { - let requested = vec!["https://requested.example.net/callback".to_owned()]; - assert_eq!( - create_result_redirect_uris(&json!({}), Some(&requested)), - Some(requested) - ); - assert_eq!( - create_result_redirect_uris( - &json!({ "redirectUris": ["https://api.example.net/callback"] }), - None, - ), - Some(vec!["https://api.example.net/callback".to_owned()]) - ); - } - /// Proves `init_view_columns()` renders a `filesWritten` shaped like what /// the `init` handler actually builds (`config`/`env` paths, confirmed by /// inspection) as a nested property bag — a column/field name mismatch diff --git a/rust/src/platform/app/commands/lifecycle.rs b/rust/src/platform/app/commands/lifecycle.rs index f981dc9e..aaa62583 100644 --- a/rust/src/platform/app/commands/lifecycle.rs +++ b/rust/src/platform/app/commands/lifecycle.rs @@ -1,7 +1,8 @@ //! `gddy platform app enable`/`disable`/`archive` — application state transitions. use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use serde_json::json; +use platform_app_client::disable_application::MutationDisableStoreApplicationInput; +use platform_app_client::enable_application::MutationEnableStoreApplicationInput; use super::schemas::{ApplicationArchive, ApplicationRef}; use super::validate::ValidateArgs; @@ -37,30 +38,41 @@ pub(super) fn enable_command() -> RuntimeCommandSpec { let name = args.name; let store_id = args.store_id; let client = super::make_client(&ctx).await?; - let data = client - .enable_application(json!({ "applicationName": name, "storeId": store_id })) + let app = client + .enable_application(MutationEnableStoreApplicationInput { + application_name: name.clone(), + store_id: store_id.clone(), + }) .await - .map_err(super::client_err)?; - Ok( - CommandResult::new(data["enableStoreApplication"].clone()).with_next_actions(vec![ - next_action( - "platform app enablement --store-id ", - "List applications enabled on this store", - ) - .with_param("store-id", required_value(&store_id)), - next_action( - "platform app disable --store-id ", - "Disable the application on the same store", - ) - .with_param("name", required_value(&name)) - .with_param("store-id", required_value(&store_id)), - next_action( - "platform app info --name ", - "Inspect application status", + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "enableStoreApplication returned no data".to_owned(), ) - .with_param("name", required_value(&name)), - ]), - ) + .into_cli_error() + })?; + let data = serde_json::to_value(app).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode application: {e}")) + .into_cli_error() + })?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "platform app enablement --store-id ", + "List applications enabled on this store", + ) + .with_param("store-id", required_value(&store_id)), + next_action( + "platform app disable --store-id ", + "Disable the application on the same store", + ) + .with_param("name", required_value(&name)) + .with_param("store-id", required_value(&store_id)), + next_action( + "platform app info --name ", + "Inspect application status", + ) + .with_param("name", required_value(&name)), + ])) }, ) } @@ -82,32 +94,41 @@ pub(super) fn disable_command() -> RuntimeCommandSpec { let name = args.name; let store_id = args.store_id; let client = super::make_client(&ctx).await?; - let data = client - .disable_application(json!({ "applicationName": name, "storeId": store_id })) + let app = client + .disable_application(MutationDisableStoreApplicationInput { + application_name: name.clone(), + store_id: store_id.clone(), + }) .await - .map_err(super::client_err)?; - Ok( - CommandResult::new(data["disableStoreApplication"].clone()).with_next_actions( - vec![ - next_action( - "platform app enablement --store-id ", - "List applications enabled on this store", - ) - .with_param("store-id", required_value(&store_id)), - next_action( - "platform app enable --store-id ", - "Re-enable the application on the same store", - ) - .with_param("name", required_value(&name)) - .with_param("store-id", required_value(&store_id)), - next_action( - "platform app info --name ", - "Inspect application status", - ) - .with_param("name", required_value(&name)), - ], - ), - ) + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "disableStoreApplication returned no data".to_owned(), + ) + .into_cli_error() + })?; + let data = serde_json::to_value(app).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode application: {e}")) + .into_cli_error() + })?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "platform app enablement --store-id ", + "List applications enabled on this store", + ) + .with_param("store-id", required_value(&store_id)), + next_action( + "platform app enable --store-id ", + "Re-enable the application on the same store", + ) + .with_param("name", required_value(&name)) + .with_param("store-id", required_value(&store_id)), + next_action( + "platform app info --name ", + "Inspect application status", + ) + .with_param("name", required_value(&name)), + ])) }, ) } @@ -128,31 +149,37 @@ pub(super) fn archive_command() -> RuntimeCommandSpec { |ctx, args: ValidateArgs| async move { let name = args.name; let client = super::make_client(&ctx).await?; - let app_data = client + let app_id = client .get_application(&name) .await - .map_err(super::client_err)?; - let app_id = app_data["application"]["id"] - .as_str() + .map_err(super::client_err)? .ok_or_else(|| { crate::error::GddyError::not_found(format!("application '{name}' not found")) .into_cli_error() })? - .to_owned(); - let data = client + .id; + let archived = client .archive_application(&app_id) .await - .map_err(super::client_err)?; - Ok( - CommandResult::new(data["archiveApplication"].clone()).with_next_actions(vec![ - next_action( - "platform app info --name ", - "Inspect archived application", + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "archiveApplication returned no data".to_owned(), ) - .with_param("name", required_value(&name)), - next_action("platform app list", "List all platform apps"), - ]), - ) + .into_cli_error() + })?; + let data = serde_json::to_value(archived).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode application: {e}")) + .into_cli_error() + })?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "platform app info --name ", + "Inspect archived application", + ) + .with_param("name", required_value(&name)), + next_action("platform app list", "List all platform apps"), + ])) }, ) } diff --git a/rust/src/platform/app/commands/list.rs b/rust/src/platform/app/commands/list.rs index 3a54798a..4ad00667 100644 --- a/rust/src/platform/app/commands/list.rs +++ b/rust/src/platform/app/commands/list.rs @@ -23,10 +23,14 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_pagination(PaginationConfig::new(0, 200)), |ctx| async move { let client = super::make_client(&ctx).await?; - let data = client + let apps = client .list_applications() .await .map_err(super::client_err)?; + let data = serde_json::to_value(apps).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode applications: {e}")) + .into_cli_error() + })?; Ok(CommandResult::new(data).with_next_actions(vec![ next_action( "platform app info --name ", diff --git a/rust/src/platform/app/commands/mod.rs b/rust/src/platform/app/commands/mod.rs index 52f5225e..4165479a 100644 --- a/rust/src/platform/app/commands/mod.rs +++ b/rust/src/platform/app/commands/mod.rs @@ -29,6 +29,11 @@ mod validate; async fn make_client( ctx: &cli_engine::CommandContext, ) -> cli_engine::Result { + // Same idempotent registration `domain`/`email`/`shopping` do in their own + // `make_client` — without it, App Registry GraphQL traffic is invisible to + // `--debug transport` unless some other generated client happened to + // register the (process-wide) observer first. + crate::http::ensure_generated_client_transport_observer_registered(); // Lazily resolve the credential; this triggers the auth flow only for // commands that actually call the API. let token = ctx.credential().await?.token; diff --git a/rust/src/platform/app/commands/release.rs b/rust/src/platform/app/commands/release.rs index 8f3d1e31..c89a4a22 100644 --- a/rust/src/platform/app/commands/release.rs +++ b/rust/src/platform/app/commands/release.rs @@ -3,6 +3,11 @@ use std::path::Path; use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; +use platform_app_client::create_release::{ + ApplicationActionCreateInput, ApplicationSettingCreateInput, + ApplicationSubscriptionCreateInput, ApplicationUiExtensionCreateInput, + MutationCreateReleaseInput, +}; use serde_json::{Value, json}; use super::schemas::ApplicationRelease; @@ -22,18 +27,20 @@ fn ui_extension_entry( source: &str, kind: &str, targets: &[crate::config::ExtensionTarget], -) -> cli_engine::Result { +) -> cli_engine::Result { if targets.len() > 1 { return Err(cli_engine::CliCoreError::message(format!( "UI extension '{name}' has {} targets, but only one target is supported per extension during release", targets.len() ))); } - let mut entry = json!({ "name": name, "handle": handle, "source": source, "type": kind }); - if let Some(t) = targets.first() { - entry["target"] = json!(t.target); - } - Ok(entry) + Ok(ApplicationUiExtensionCreateInput { + name: Some(name.to_owned()), + handle: Some(handle.to_owned()), + source: Some(source.to_owned()), + type_: kind.to_owned(), + target: targets.first().map(|t| t.target.clone()), + }) } /// Resolves a setting's presentation from `presentation` or `presentationFile`. @@ -80,7 +87,7 @@ fn resolve_presentation( fn setting_entry( setting: &crate::config::SettingConfig, manifest_dir: &Path, -) -> cli_engine::Result { +) -> cli_engine::Result { let presentation = resolve_presentation(setting, manifest_dir)?; let mut errors = Vec::new(); validate_presentation( @@ -112,39 +119,28 @@ fn setting_entry( } } - let mut entry = json!({ - "groupSlug": setting.group, - "appSettingSlug": setting.slug, - "entryPath": setting.entry_path, - "presentation": presentation_json, - }); - if let Some(title) = &setting.title { - entry["title"] = json!(title); - } - if let Some(description) = &setting.description { - entry["description"] = json!(description); - } - if let Some(icon) = &setting.icon { - entry["iconName"] = json!(icon.name); - entry["iconLibrary"] = json!(icon.library); - } - if let Some(order) = setting.order { - entry["order"] = json!(order); - } - if !setting.capabilities.is_empty() { - entry["capabilities"] = json!(setting.capabilities); - } - if let Some(metadata) = &setting.metadata { - entry["metadata"] = metadata.clone(); - } - Ok(entry) + Ok(ApplicationSettingCreateInput { + group_slug: setting.group.clone(), + app_setting_slug: setting.slug.clone(), + entry_path: setting.entry_path.clone(), + presentation: presentation_json, + title: setting.title.clone(), + title_key: None, + description: setting.description.clone(), + description_key: None, + icon_name: setting.icon.as_ref().map(|icon| icon.name.clone()), + icon_library: setting.icon.as_ref().map(|icon| icon.library.clone()), + order: setting.order, + capabilities: (!setting.capabilities.is_empty()).then(|| setting.capabilities.clone()), + metadata: setting.metadata.clone(), + }) } /// Map godaddy.toml `[[settings]]` placements to the release `settings` input. fn build_settings( config: &crate::config::Config, manifest_dir: &Path, -) -> cli_engine::Result> { +) -> cli_engine::Result> { config .settings .iter() @@ -174,7 +170,9 @@ fn load_manifest(path: &Path) -> cli_engine::Result cli_engine::Result> { +fn build_ui_extensions( + config: &crate::config::Config, +) -> cli_engine::Result> { let mut out = Vec::new(); let Some(exts) = &config.extensions else { return Ok(out); @@ -192,9 +190,13 @@ fn build_ui_extensions(config: &crate::config::Config) -> cli_engine::Result RuntimeCommandSpec { let app_id = args.application_id; let version = args.version; let description = args.description; - let mut input = json!({ "applicationId": app_id, "version": version }); - if let Some(desc) = description { - input["description"] = json!(desc); - } let config_path = crate::config::config_path(Some(&ctx.middleware.env)); let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new("")); @@ -243,19 +241,24 @@ pub(super) fn command() -> RuntimeCommandSpec { let (actions, subscriptions, ui_extensions, settings, native_extension) = match load_manifest(&config_path)? { Some(config) => { - let actions: Vec = config + let actions: Vec = config .actions .iter() - .map(|a| json!({ "name": a.name, "url": a.url })) + .map(|a| ApplicationActionCreateInput { + name: a.name.clone(), + url: a.url.clone(), + }) .collect(); - let subscriptions: Vec = config + let subscriptions: Vec = config .subscriptions .as_ref() .map(|s| { s.webhook .iter() - .map(|w| { - json!({ "name": w.name, "events": w.events, "url": w.url }) + .map(|w| ApplicationSubscriptionCreateInput { + name: w.name.clone(), + events: w.events.clone(), + url: w.url.clone(), }) .collect() }) @@ -276,38 +279,55 @@ pub(super) fn command() -> RuntimeCommandSpec { } None => (Vec::new(), Vec::new(), Vec::new(), Vec::new(), None), }; - input["actions"] = json!(actions); - input["subscriptions"] = json!(subscriptions); - input["uiExtensions"] = json!(ui_extensions); - input["settings"] = json!(settings); + + let mut input = MutationCreateReleaseInput { + application_id: app_id, + version, + description, + actions: Some(actions), + subscriptions: Some(subscriptions), + settings: Some(settings), + application_dependencies: None, + feature_dependencies: None, + mcp_servers: None, + native_extensions: None, + ui_extensions: None, + }; native_extension::apply_native_extensions( &mut input, &ui_extensions, native_extension.as_ref(), )?; + input.ui_extensions = Some(ui_extensions); let client = super::make_client(&ctx).await?; - let data = client + let release = client .create_release(input) .await - .map_err(super::client_err)?; + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected("createRelease returned no data".to_owned()) + .into_cli_error() + })?; + let data = serde_json::to_value(release).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode release: {e}")) + .into_cli_error() + })?; // Release is keyed by `--application-id`, not name. Do not prefill // `name` from godaddy.toml — that manifest may belong to a different app. let name_param = NextActionParam::required(); - Ok( - CommandResult::new(data["createRelease"].clone()).with_next_actions(vec![ - next_action( - "platform app deploy --name ", - "Deploy the released application", - ) - .with_param("name", name_param.clone()), - next_action( - "platform app info --name ", - "Inspect application and latest release", - ) - .with_param("name", name_param), - ]), - ) + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "platform app deploy --name ", + "Deploy the released application", + ) + .with_param("name", name_param.clone()), + next_action( + "platform app info --name ", + "Inspect application and latest release", + ) + .with_param("name", name_param), + ])) }, ) } @@ -336,11 +356,11 @@ mod tests { // No targets: `target` is omitted. let none = super::ui_extension_entry("Widget", "widget", "src/w.ts", "embed", &[]) .expect("entry builds"); - assert_eq!(none["name"], "Widget"); - assert_eq!(none["handle"], "widget"); - assert_eq!(none["type"], "embed"); - assert_eq!(none["source"], "src/w.ts"); - assert!(none.get("target").is_none()); + assert_eq!(none.name.as_deref(), Some("Widget")); + assert_eq!(none.handle.as_deref(), Some("widget")); + assert_eq!(none.type_, "embed"); + assert_eq!(none.source.as_deref(), Some("src/w.ts")); + assert!(none.target.is_none()); // Exactly one target: `target` is set to that value. let one = super::ui_extension_entry( @@ -353,7 +373,7 @@ mod tests { }], ) .expect("entry builds"); - assert_eq!(one["target"], "checkout.block"); + assert_eq!(one.target.as_deref(), Some("checkout.block")); } fn placement_only_setting() -> crate::config::SettingConfig { @@ -453,31 +473,28 @@ mod tests { let mut setting = placement_only_setting(); setting.presentation = Some(boolean_presentation()); let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); - assert_eq!(entry["groupSlug"], "tax-center"); - assert_eq!(entry["appSettingSlug"], "godaddy-tax"); - assert_eq!(entry["entryPath"], "/settings/godaddy-tax"); - assert_eq!(entry["presentation"]["type"], "form"); - assert_eq!(entry["presentation"]["schemaVersion"], "settings-form-v1"); + assert_eq!(entry.group_slug, "tax-center"); + assert_eq!(entry.app_setting_slug, "godaddy-tax"); + assert_eq!(entry.entry_path, "/settings/godaddy-tax"); + assert_eq!(entry.presentation["type"], "form"); + assert_eq!(entry.presentation["schemaVersion"], "settings-form-v1"); assert_eq!( - entry["presentation"]["sections"][0]["fields"][0]["type"], + entry.presentation["sections"][0]["fields"][0]["type"], "boolean" ); assert!( - entry.get("capabilities").is_none(), + entry.capabilities.is_none(), "empty capabilities should be omitted" ); + assert!(entry.icon_name.is_none(), "absent icon should be omitted"); assert!( - entry.get("iconName").is_none(), - "absent icon should be omitted" - ); - assert!( - entry["presentation"]["sections"][0] + entry.presentation["sections"][0] .get("visibleWhen") .is_none(), "absent section visibility should be omitted instead of serialized as null" ); assert!( - entry["presentation"]["sections"][0]["fields"][0] + entry.presentation["sections"][0]["fields"][0] .get("description") .is_none(), "absent field properties should be omitted instead of serialized as null" @@ -501,14 +518,17 @@ mod tests { }; setting.presentation = Some(link_presentation()); let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); - assert_eq!(entry["groupSlug"], "payment-methods"); - assert_eq!(entry["appSettingSlug"], "paypal-payments"); - assert_eq!(entry["entryPath"], "/settings/paypal"); - assert_eq!(entry["presentation"]["type"], "link"); - assert_eq!(entry["presentation"]["schemaVersion"], "settings-link-v1"); - assert_eq!(entry["presentation"]["label"], "Configure PayPal"); - assert_eq!(entry["presentation"]["openMode"], "new-window"); - assert_eq!(entry["capabilities"], serde_json::json!(["read", "open"])); + assert_eq!(entry.group_slug, "payment-methods"); + assert_eq!(entry.app_setting_slug, "paypal-payments"); + assert_eq!(entry.entry_path, "/settings/paypal"); + assert_eq!(entry.presentation["type"], "link"); + assert_eq!(entry.presentation["schemaVersion"], "settings-link-v1"); + assert_eq!(entry.presentation["label"], "Configure PayPal"); + assert_eq!(entry.presentation["openMode"], "new-window"); + assert_eq!( + entry.capabilities, + Some(vec!["read".to_owned(), "open".to_owned()]) + ); } #[test] @@ -532,11 +552,16 @@ mod tests { let entry = super::setting_entry(&setting, std::path::Path::new("")) .expect("config link entry builds"); assert_eq!( - entry["capabilities"], - serde_json::json!(["read", "open", "config", "delete"]) + entry.capabilities, + Some(vec![ + "read".to_owned(), + "open".to_owned(), + "config".to_owned(), + "delete".to_owned(), + ]) ); assert_eq!( - entry["metadata"]["configKeys"], + entry.metadata.expect("metadata")["configKeys"], serde_json::json!(["clientId", "merchantId"]) ); } @@ -570,13 +595,16 @@ mod tests { }); setting.metadata = Some(serde_json::json!({ "provider": "godaddy-tax" })); let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); - assert_eq!(entry["title"], "GoDaddy Tax"); - assert_eq!(entry["description"], "Tax settings"); - assert_eq!(entry["order"], 10); - assert_eq!(entry["capabilities"], serde_json::json!(["read", "write"])); - assert_eq!(entry["iconName"], "percent"); - assert_eq!(entry["iconLibrary"], "lucide"); - assert_eq!(entry["metadata"]["provider"], "godaddy-tax"); + assert_eq!(entry.title.as_deref(), Some("GoDaddy Tax")); + assert_eq!(entry.description.as_deref(), Some("Tax settings")); + assert_eq!(entry.order, Some(10)); + assert_eq!( + entry.capabilities, + Some(vec!["read".to_owned(), "write".to_owned()]) + ); + assert_eq!(entry.icon_name.as_deref(), Some("percent")); + assert_eq!(entry.icon_library.as_deref(), Some("lucide")); + assert_eq!(entry.metadata.expect("metadata")["provider"], "godaddy-tax"); } #[test] @@ -586,7 +614,7 @@ mod tests { let entry = super::setting_entry(&setting, std::path::Path::new("")) .expect("list-group entry builds"); - let list_group = &entry["presentation"]["sections"][0]["fields"][0]; + let list_group = &entry.presentation["sections"][0]["fields"][0]; assert!( list_group.get("minItems").is_none(), "absent list-group bounds should be omitted instead of serialized as null" @@ -635,7 +663,7 @@ mod tests { let via_inline = super::setting_entry(&inline, std::path::Path::new("")).expect("entry builds inline"); - assert_eq!(via_file["presentation"], via_inline["presentation"]); + assert_eq!(via_file.presentation, via_inline.presentation); } #[test] diff --git a/rust/src/platform/app/commands/release/native_extension.rs b/rust/src/platform/app/commands/release/native_extension.rs index 43b54aeb..24eb52db 100644 --- a/rust/src/platform/app/commands/release/native_extension.rs +++ b/rust/src/platform/app/commands/release/native_extension.rs @@ -1,7 +1,10 @@ //! Native-extension input for `gddy platform app release`, gated by the //! `native-apps` feature flag. -use serde_json::{Value, json}; +use platform_app_client::create_release::{ + ApplicationNativeExtensionCreateInput, ApplicationUiExtensionCreateInput, + MutationCreateReleaseInput, NativeExtensionPlatform, +}; use crate::platform::app::commands::NATIVE_APPS_FLAG_KEY; @@ -50,13 +53,15 @@ pub(super) fn native_extension_draft_if_visible( /// `platform` is required (`ANDROID` is the only `NativeExtensionPlatform` /// variant). Categories are portal-owned and are not part of this input. /// Unlike core `createGpaRelease`, this includes `packageName` from toml. -fn native_extensions_input(draft: &NativeExtensionDraft) -> Value { - json!([{ - "platform": "ANDROID", - "name": draft.name, - "contact": draft.support_contact, - "packageName": draft.android_package_name, - }]) +fn native_extensions_input( + draft: &NativeExtensionDraft, +) -> Vec { + vec![ApplicationNativeExtensionCreateInput { + platform: NativeExtensionPlatform::ANDROID, + name: Some(draft.name.clone()), + contact: Some(draft.support_contact.clone()), + package_name: Some(draft.android_package_name.clone()), + }] } /// Attach toml native-extension fields to the GraphQL `createRelease` input. @@ -64,8 +69,8 @@ fn native_extensions_input(draft: &NativeExtensionDraft) -> Value { /// Registry rejects mixing non-empty `uiExtensions` with `nativeExtensions` /// (`HYBRID_EXTENSIONS_NOT_ALLOWED`). Empty `uiExtensions: []` is allowed. pub(super) fn apply_native_extensions( - input: &mut Value, - ui_extensions: &[Value], + input: &mut MutationCreateReleaseInput, + ui_extensions: &[ApplicationUiExtensionCreateInput], draft: Option<&NativeExtensionDraft>, ) -> cli_engine::Result<()> { if draft.is_some() && !ui_extensions.is_empty() { @@ -75,7 +80,7 @@ pub(super) fn apply_native_extensions( .into_cli_error()); } if let Some(draft) = draft { - input["nativeExtensions"] = native_extensions_input(draft); + input.native_extensions = Some(native_extensions_input(draft)); } Ok(()) } @@ -83,6 +88,35 @@ pub(super) fn apply_native_extensions( #[cfg(test)] mod tests { use cli_engine::{FlagPolicy, Stage}; + use platform_app_client::create_release::{ + ApplicationUiExtensionCreateInput, MutationCreateReleaseInput, NativeExtensionPlatform, + }; + + fn base_input() -> MutationCreateReleaseInput { + MutationCreateReleaseInput { + application_id: "app-123".to_owned(), + version: "1.0.11".to_owned(), + description: None, + actions: None, + application_dependencies: None, + feature_dependencies: None, + mcp_servers: None, + native_extensions: None, + settings: None, + subscriptions: None, + ui_extensions: None, + } + } + + fn widget_ui_extension() -> ApplicationUiExtensionCreateInput { + ApplicationUiExtensionCreateInput { + name: Some("Widget".to_owned()), + handle: Some("widget".to_owned()), + source: None, + type_: "embed".to_owned(), + target: None, + } + } fn valid_release_config() -> crate::config::Config { crate::config::Config { @@ -125,10 +159,10 @@ mod tests { let draft = super::native_extension_draft_if_visible(&config, &policy); assert!(draft.is_none()); - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let mut input = base_input(); super::apply_native_extensions(&mut input, &[], draft.as_ref()) .expect("non-native release"); - assert!(input.get("nativeExtensions").is_none()); + assert!(input.native_extensions.is_none()); } #[test] @@ -140,11 +174,13 @@ mod tests { assert_eq!(draft.support_contact, "support@example.com"); assert_eq!(draft.android_package_name, "com.example.app"); - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let mut input = base_input(); super::apply_native_extensions(&mut input, &[], Some(&draft)).expect("native release"); assert_eq!( - input["nativeExtensions"][0]["packageName"], - "com.example.app" + input.native_extensions.expect("native extensions")[0] + .package_name + .as_deref(), + Some("com.example.app") ); } @@ -174,10 +210,10 @@ mod tests { let draft = super::native_extension_draft_if_visible(&config, &policy); assert!(draft.is_none()); - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); - let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let mut input = base_input(); + let ui = vec![widget_ui_extension()]; super::apply_native_extensions(&mut input, &ui, draft.as_ref()).expect("ui release"); - assert!(input.get("nativeExtensions").is_none()); + assert!(input.native_extensions.is_none()); assert_eq!(ui.len(), 1); } @@ -187,15 +223,15 @@ mod tests { let policy = FlagPolicy::new().with_min_stage(Stage::Experimental); let draft = super::native_extension_draft_if_visible(&config, &policy).expect("draft"); - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); - let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let mut input = base_input(); + let ui = vec![widget_ui_extension()]; let err = super::apply_native_extensions(&mut input, &ui, Some(&draft)) .expect_err("hybrid must fail locally"); assert!( err.to_string().contains("cannot mix uiExtensions"), "got: {err}" ); - assert!(input.get("nativeExtensions").is_none()); + assert!(input.native_extensions.is_none()); } #[test] @@ -265,15 +301,20 @@ mod tests { let actual = super::native_extensions_input(&draft); - let entries = actual.as_array().expect("one-element array"); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0]["platform"], "ANDROID"); - assert_eq!(entries[0]["name"], "My Display Name"); - assert_eq!(entries[0]["contact"], "support@example.com"); - assert_eq!(entries[0]["packageName"], "com.example.app"); - // Categories are portal-owned and never travel on createRelease. - assert!(entries[0].get("appCategory").is_none()); - assert!(entries[0].get("merchantCategory").is_none()); + assert_eq!(actual.len(), 1); + assert!(matches!( + actual[0].platform, + NativeExtensionPlatform::ANDROID + )); + assert_eq!(actual[0].name.as_deref(), Some("My Display Name")); + assert_eq!(actual[0].contact.as_deref(), Some("support@example.com")); + assert_eq!(actual[0].package_name.as_deref(), Some("com.example.app")); + // Categories are portal-owned and never travel on createRelease; the + // wire shape is exactly these four keys. + let wire = serde_json::to_value(&actual[0]).expect("serialize"); + let mut keys: Vec<_> = wire.as_object().expect("object").keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["contact", "name", "packageName", "platform"]); } fn sample_draft() -> super::NativeExtensionDraft { @@ -286,33 +327,33 @@ mod tests { #[test] fn apply_native_extensions_sets_create_release_input_when_draft_present() { - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let mut input = base_input(); super::apply_native_extensions(&mut input, &[], Some(&sample_draft())) .expect("native-only release"); - let row = &input["nativeExtensions"][0]; - assert_eq!(row["platform"], "ANDROID"); - assert_eq!(row["name"], "My Display Name"); - assert_eq!(row["contact"], "support@example.com"); - assert_eq!(row["packageName"], "com.example.app"); + let rows = input.native_extensions.expect("native extensions"); + assert!(matches!(rows[0].platform, NativeExtensionPlatform::ANDROID)); + assert_eq!(rows[0].name.as_deref(), Some("My Display Name")); + assert_eq!(rows[0].contact.as_deref(), Some("support@example.com")); + assert_eq!(rows[0].package_name.as_deref(), Some("com.example.app")); } #[test] fn apply_native_extensions_omits_key_when_draft_absent() { - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let mut input = base_input(); super::apply_native_extensions(&mut input, &[], None).expect("non-native release"); - assert!(input.get("nativeExtensions").is_none()); + assert!(input.native_extensions.is_none()); } #[test] fn apply_native_extensions_rejects_nonempty_ui_extensions() { - let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); - let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let mut input = base_input(); + let ui = vec![widget_ui_extension()]; let err = super::apply_native_extensions(&mut input, &ui, Some(&sample_draft())) .expect_err("hybrid must fail locally"); assert!( err.to_string().contains("cannot mix uiExtensions"), "got: {err}" ); - assert!(input.get("nativeExtensions").is_none()); + assert!(input.native_extensions.is_none()); } } diff --git a/rust/src/platform/app/commands/update.rs b/rust/src/platform/app/commands/update.rs index da415743..4a7ba274 100644 --- a/rust/src/platform/app/commands/update.rs +++ b/rust/src/platform/app/commands/update.rs @@ -1,7 +1,7 @@ //! `gddy platform app update` — update label, description, and manifest OAuth redirects. use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use serde_json::json; +use platform_app_client::update_application::MutationUpdateApplicationInput; use super::schemas::ApplicationUpdate; use crate::next_action::{next_action, required_value}; @@ -53,13 +53,6 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::(), |context, args: UpdateArgs| async move { - let mut input = serde_json::Map::new(); - if let Some(label) = args.fields.label { - input.insert("label".to_owned(), json!(label)); - } - if let Some(description) = args.fields.description { - input.insert("description".to_owned(), json!(description)); - } let config_path = crate::config::config_path(Some(&context.middleware.env)); let redirect_uris = match crate::config::read_config(&config_path) { Ok(config) => config.redirect_uris, @@ -71,31 +64,48 @@ pub(super) fn command() -> RuntimeCommandSpec { .into_cli_error()); } }; - let mut input = json!(input); - super::add_redirect_uris_to_input(&mut input, redirect_uris.as_deref()); + // `None` is omitted from the request (the `OmitNoneInInputs` pass + // in `platform-app-client/build.rs`), so an omitted manifest key + // leaves the remote allowlist unchanged while `Some(vec![])` + // clears it. + let input = MutationUpdateApplicationInput { + label: args.fields.label, + description: args.fields.description, + redirect_uris, + authorization_scopes: None, + distribution_type: None, + name: None, + proxy_url: None, + url: None, + }; let client = super::make_client(&context).await?; - let data = client + let app = client .update_application(&args.id, input) .await - .map_err(super::client_err)?; - let name = data["updateApplication"]["name"] - .as_str() - .unwrap_or("") - .to_owned(); - Ok( - CommandResult::new(data["updateApplication"].clone()).with_next_actions(vec![ - next_action( - "platform app info --name ", - "Inspect updated application", - ) - .with_param("name", required_value(&name)), - next_action( - "platform app deploy --name ", - "Deploy updated application", + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "updateApplication returned no data".to_owned(), ) - .with_param("name", required_value(&name)), - ]), - ) + .into_cli_error() + })?; + let name = app.name.clone(); + let data = serde_json::to_value(&app).map_err(|e| { + crate::error::GddyError::unexpected(format!("failed to encode application: {e}")) + .into_cli_error() + })?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action( + "platform app info --name ", + "Inspect updated application", + ) + .with_param("name", required_value(&name)), + next_action( + "platform app deploy --name ", + "Deploy updated application", + ) + .with_param("name", required_value(&name)), + ])) }, ) } diff --git a/rust/src/platform/app/commands/validate.rs b/rust/src/platform/app/commands/validate.rs index d2fd0000..79508144 100644 --- a/rust/src/platform/app/commands/validate.rs +++ b/rust/src/platform/app/commands/validate.rs @@ -1,25 +1,26 @@ //! `gddy platform app validate` — check remote application state. use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; -use serde_json::{Value, json}; +use platform_app_client::application::{ApplicationApplication, ApplicationStatus}; +use serde_json::json; use super::schemas::ValidationResult; use crate::next_action::{next_action, required_value}; /// Missing URL is an error; missing proxy URL or INACTIVE status are warnings. -fn validate_remote_application(app: &Value) -> (bool, Vec, Vec) { +fn validate_remote_application(app: &ApplicationApplication) -> (bool, Vec, Vec) { let mut errors = Vec::new(); let mut warnings = Vec::new(); - let url = app["url"].as_str().unwrap_or(""); + let url = app.url.as_deref().unwrap_or(""); if url.is_empty() { errors.push("Application URL is required".to_owned()); } - let proxy_url = app["proxyUrl"].as_str().unwrap_or(""); + let proxy_url = app.proxy_url.as_deref().unwrap_or(""); if proxy_url.is_empty() { warnings.push("Proxy URL is not set".to_owned()); } - if app["status"].as_str() == Some("INACTIVE") { + if app.status == ApplicationStatus::INACTIVE { warnings.push("Application is currently inactive".to_owned()); } @@ -48,20 +49,17 @@ pub(super) fn command() -> RuntimeCommandSpec { |ctx, args: ValidateArgs| async move { let name = args.name; let client = super::make_client(&ctx).await?; - let data = client + let app = 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()); - } + .map_err(super::client_err)? + .ok_or_else(|| { + crate::error::GddyError::not_found(format!("application '{name}' not found")) + .into_cli_error() + })?; - let app_id = app["id"].as_str().unwrap_or("").to_owned(); - let (valid, errors, warnings) = validate_remote_application(app); + let app_id = app.id.clone(); + let (valid, errors, warnings) = validate_remote_application(&app); // Only suggest a release once the app is valid; otherwise point back to // `info` to review the reported problems. @@ -96,7 +94,7 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { - use serde_json::json; + use platform_app_client::application::{ApplicationApplication, ApplicationStatus}; use super::validate_remote_application; @@ -104,6 +102,22 @@ mod tests { super::command().spec.clap_command() } + fn app( + url: &str, + proxy_url: Option<&str>, + status: ApplicationStatus, + ) -> ApplicationApplication { + ApplicationApplication { + id: "app-1".to_owned(), + label: "My App".to_owned(), + name: "my-app".to_owned(), + description: None, + status, + url: Some(url.to_owned()), + proxy_url: proxy_url.map(str::to_owned), + } + } + #[test] fn validate_requires_name() { let err = validate_clap_command() @@ -125,11 +139,11 @@ mod tests { #[test] fn validate_remote_healthy_app_is_valid() { - let (valid, errors, warnings) = validate_remote_application(&json!({ - "url": "https://example.com", - "proxyUrl": "https://proxy.example.com", - "status": "ACTIVE", - })); + let (valid, errors, warnings) = validate_remote_application(&app( + "https://example.com", + Some("https://proxy.example.com"), + ApplicationStatus::ACTIVE, + )); assert!(valid); assert!(errors.is_empty()); assert!(warnings.is_empty()); @@ -137,11 +151,11 @@ mod tests { #[test] fn validate_remote_missing_url_is_error() { - let (valid, errors, warnings) = validate_remote_application(&json!({ - "url": "", - "proxyUrl": "https://proxy.example.com", - "status": "ACTIVE", - })); + let (valid, errors, warnings) = validate_remote_application(&app( + "", + Some("https://proxy.example.com"), + ApplicationStatus::ACTIVE, + )); assert!(!valid); assert_eq!(errors, vec!["Application URL is required".to_owned()]); assert!(warnings.is_empty()); @@ -149,11 +163,11 @@ mod tests { #[test] fn validate_remote_missing_proxy_and_inactive_are_warnings() { - let (valid, errors, warnings) = validate_remote_application(&json!({ - "url": "https://example.com", - "proxyUrl": null, - "status": "INACTIVE", - })); + let (valid, errors, warnings) = validate_remote_application(&app( + "https://example.com", + None, + ApplicationStatus::INACTIVE, + )); assert!(valid, "warnings alone should not invalidate"); assert!(errors.is_empty()); assert_eq!(