diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 11bef367..290d7013 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -565,7 +565,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 3.0.3", @@ -603,7 +603,7 @@ dependencies = [ "sha2", "strsim", "termimad", - "thiserror", + "thiserror 2.0.19", "tokio", "toml", "toml_edit 0.22.27", @@ -638,7 +638,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1039,7 +1039,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1084,7 +1084,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1414,7 +1414,7 @@ dependencies = [ "http", "httpmock", "reqwest 0.13.4", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1511,6 +1511,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "phonenumber", + "platform-app-client", "progenitor-client", "regex", "reqwest 0.13.4", @@ -1523,7 +1524,7 @@ dependencies = [ "shopping-client", "tar", "tempfile", - "thiserror", + "thiserror 2.0.19", "tokio", "tokio-tungstenite", "toml", @@ -1534,6 +1535,64 @@ dependencies = [ "zip", ] +[[package]] +name = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql_client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" +dependencies = [ + "graphql_query_derive", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "h2" version = "0.4.16" @@ -1620,6 +1679,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1672,7 +1737,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", "url", ] @@ -1750,7 +1815,7 @@ dependencies = [ "similar", "stringmetrics", "tabwriter", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "url", @@ -2069,7 +2134,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -2566,7 +2631,7 @@ dependencies = [ "owo-colors", "oxc-miette-derive", "textwrap", - "thiserror", + "thiserror 2.0.19", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -2886,7 +2951,7 @@ dependencies = [ "serde", "serde_derive", "strum", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -2912,6 +2977,25 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "platform-app-client" +version = "0.2.20" +dependencies = [ + "generated-client-support", + "graphql_client", + "graphql_client_codegen", + "httpmock", + "prettyplease", + "proc-macro2", + "reqwest 0.13.4", + "serde", + "serde_json", + "syn 1.0.109", + "syn 2.0.119", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "polling" version = "3.11.0" @@ -3017,7 +3101,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e349eed84b9a1a6a5dbe478d335e3df73d32a93c5eefe571c9b8cb298aab5d" dependencies = [ - "heck", + "heck 0.5.0", "http", "indexmap", "openapiv3", @@ -3028,7 +3112,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "typify", "unicode-ident", ] @@ -3074,7 +3158,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -3097,7 +3181,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3240,7 +3324,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -3910,7 +3994,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -4071,7 +4155,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -4083,6 +4167,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4169,7 +4264,7 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror", + "thiserror 2.0.19", "unicode-width 0.1.14", ] @@ -4184,13 +4279,33 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4524,7 +4639,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -4555,7 +4670,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" dependencies = [ - "heck", + "heck 0.5.0", "log", "proc-macro2", "quote", @@ -4565,7 +4680,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "unicode-ident", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 80ae7860..68f1d72e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,6 +14,7 @@ members = [ "shopping-client", "email-client", "generated-client-support", + "platform-app-client", ] [[bin]] @@ -34,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/api-catalog-sources.json b/rust/api-catalog-sources.json index ea5ed79b..299d1aa3 100644 --- a/rust/api-catalog-sources.json +++ b/rust/api-catalog-sources.json @@ -1,6 +1,14 @@ { "version": 1, "remote": [ + { + "domain": "app-registry", + "repository": "app-registry-api", + "org": "gdcorp-commerce", + "specPath": "apis/graphql/schema.graphql", + "baseUrl": "https://api.godaddy.com", + "endpointPath": "/v1/apps/app-registry-subgraph" + }, { "domain": "bulk-operations", "repository": "commerce.bulk-operations-specification" 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/platform-app-client/Cargo.toml b/rust/platform-app-client/Cargo.toml new file mode 100644 index 00000000..be069ab5 --- /dev/null +++ b/rust/platform-app-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "platform-app-client" +version = "0.2.20" +edition = "2024" +license = "Proprietary" +description = "Generated App Registry GraphQL client (application/release/enablement operations), produced from the vendored subgraph schema by graphql_client at build time." + +[dependencies] +generated-client-support = { path = "../generated-client-support" } +graphql_client = "0.14" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[build-dependencies] +graphql_client_codegen = "0.14" +prettyplease = "0.2" +proc-macro2 = "1" +syn = { version = "2", features = ["full", "visit-mut"] } +# graphql_client_codegen's `GraphQLClientCodegenOptions` setters take syn 1.x +# types (that's the syn major it depends on internally) — a second, renamed +# copy alongside the syn 2.x used for the final `syn::parse2` + +# `prettyplease::unparse` pretty-print step below. +syn1 = { package = "syn", version = "1" } + +[dev-dependencies] +httpmock = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/rust/platform-app-client/build.rs b/rust/platform-app-client/build.rs new file mode 100644 index 00000000..baf153ae --- /dev/null +++ b/rust/platform-app-client/build.rs @@ -0,0 +1,132 @@ +//! Generates the typed App Registry GraphQL client from the vendored +//! subgraph schema and this crate's `.graphql` operation documents. +//! +//! The committed `graphql/schema.graphql` is regenerated by `cargo run -p +//! generate-api-catalog` from the canonical `app-registry-api` service repo +//! (see that tool's `app_registry_spec.rs` for why — the schema isn't +//! hand-maintained in a "-specification" repo the way the REST domains' are). +//! Every other `.graphql` file in that directory is one operation document; +//! this build step is hermetic and never accesses the network. + +use std::{env, fs, path::Path}; + +use graphql_client_codegen::{ + CodegenMode, GraphQLClientCodegenOptions, generate_module_token_stream, +}; +use syn::visit_mut::{self, VisitMut}; + +/// Marks every unset (`None`) optional field on a generated *input* struct +/// (the `Variables` struct and the input objects nested in it) as omitted +/// from the request instead of serialized as an explicit `null`. +/// +/// In GraphQL an omitted input field and an explicit `null` are different +/// things — e.g. `distributionType` is documented as "omit to leave +/// unchanged", and the manifest `redirectUris` sync relies on an absent key +/// leaving the remote allowlist alone while `[]` clears it — so an unset +/// `Option` must not turn into `null`. +/// +/// `graphql_client_codegen`'s own `skip_serializing_none` option is not used +/// because it also applies to *response* structs, which would make the CLI's +/// JSON output drop `null` fields it has always printed. Input structs are +/// told apart from response structs by deriving `Serialize` without +/// `PartialEq` (see `set_response_derives` / `set_variables_derives` below). +struct OmitNoneInInputs; + +impl VisitMut for OmitNoneInInputs { + fn visit_item_struct_mut(&mut self, item: &mut syn::ItemStruct) { + if derives(item, "Serialize") + && !derives(item, "PartialEq") + && let syn::Fields::Named(fields) = &mut item.fields + { + for field in &mut fields.named { + if is_option(&field.ty) { + field.attrs.push(syn::parse_quote!( + #[serde(skip_serializing_if = "Option::is_none")] + )); + } + } + } + visit_mut::visit_item_struct_mut(self, item); + } +} + +fn derives(item: &syn::ItemStruct, name: &str) -> bool { + let mut found = false; + for attr in item.attrs.iter().filter(|a| a.path().is_ident("derive")) { + let _ = attr.parse_nested_meta(|meta| { + if meta + .path + .segments + .last() + .is_some_and(|seg| seg.ident == name) + { + found = true; + } + Ok(()) + }); + } + found +} + +fn is_option(ty: &syn::Type) -> bool { + matches!(ty, syn::Type::Path(path) + if path.path.segments.last().is_some_and(|seg| seg.ident == "Option")) +} + +fn main() -> Result<(), Box> { + let graphql_dir = Path::new("graphql"); + let schema_path = graphql_dir.join("schema.graphql"); + // Directory-level, not just the schema file and each currently-existing + // operation doc below: a `rerun-if-changed` only watches paths that + // exist when this script runs, so without this a newly *added* + // `graphql/*.graphql` file wouldn't trigger a rebuild until some other + // watched file also changed. + println!("cargo:rerun-if-changed={}", graphql_dir.display()); + println!("cargo:rerun-if-changed={}", schema_path.display()); + println!("cargo:rerun-if-changed=build.rs"); + + let mut query_paths: Vec<_> = fs::read_dir(graphql_dir)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.extension().and_then(|ext| ext.to_str()) == Some("graphql") + && path.file_name().and_then(|name| name.to_str()) != Some("schema.graphql") + }) + .collect(); + query_paths.sort(); + + let mut tokens = proc_macro2::TokenStream::new(); + for query_path in &query_paths { + println!("cargo:rerun-if-changed={}", query_path.display()); + + let mut options = GraphQLClientCodegenOptions::new(CodegenMode::Cli); + options.set_module_visibility(syn1::parse_str("pub")?); + // Bare `Serialize`/`Deserialize`, not qualified paths: every + // generated module already has `use serde::{Serialize, + // Deserialize};` in scope, and a qualified path here would dodge + // graphql_client_codegen's own dedup filter (an exact string match + // against `"Serialize"`/`"Deserialize"`) that keeps them off enum + // types, which already get hand-written impls — a qualified path + // collides with those impls instead of being skipped. + options.set_response_derives("Clone,Debug,PartialEq,Serialize".to_owned()); + // `Deserialize` on the input/variable side isn't needed by + // `client.rs` itself — every `ApplicationClient` method takes a + // typed input struct directly, not `serde_json::Value` — it's kept + // so this crate's own tests can build inputs from `json!(...)` + // literals instead of fully-spelled-out struct literals. + options.set_variables_derives("Clone,Debug,Deserialize".to_owned()); + options.set_custom_scalars_module(syn1::parse_str("crate::scalars")?); + + let generated = generate_module_token_stream(query_path.clone(), &schema_path, options) + .map_err(|e| format!("codegen failed for {}: {e}", query_path.display()))?; + tokens.extend(generated); + } + + let mut ast: syn::File = syn::parse2(tokens)?; + OmitNoneInInputs.visit_file_mut(&mut ast); + fs::write( + Path::new(&env::var("OUT_DIR")?).join("codegen.rs"), + prettyplease::unparse(&ast), + )?; + Ok(()) +} diff --git a/rust/platform-app-client/graphql/ActivateRelease.graphql b/rust/platform-app-client/graphql/ActivateRelease.graphql new file mode 100644 index 00000000..ec67a7f2 --- /dev/null +++ b/rust/platform-app-client/graphql/ActivateRelease.graphql @@ -0,0 +1,11 @@ +mutation ActivateRelease($applicationId: ID!, $releaseId: ID!) { + activateRelease(applicationId: $applicationId, releaseId: $releaseId) { + id + version + description + status + activatedAt + createdAt + updatedAt + } +} diff --git a/rust/platform-app-client/graphql/Application.graphql b/rust/platform-app-client/graphql/Application.graphql new file mode 100644 index 00000000..61e97b56 --- /dev/null +++ b/rust/platform-app-client/graphql/Application.graphql @@ -0,0 +1,11 @@ +query Application($name: String!) { + application(name: $name) { + id + label + name + description + status + url + proxyUrl + } +} diff --git a/rust/platform-app-client/graphql/ApplicationWithLatestRelease.graphql b/rust/platform-app-client/graphql/ApplicationWithLatestRelease.graphql new file mode 100644 index 00000000..7f1bd00c --- /dev/null +++ b/rust/platform-app-client/graphql/ApplicationWithLatestRelease.graphql @@ -0,0 +1,29 @@ +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 + } + } + } + } + } +} diff --git a/rust/platform-app-client/graphql/ApplicationsList.graphql b/rust/platform-app-client/graphql/ApplicationsList.graphql new file mode 100644 index 00000000..7f41c45d --- /dev/null +++ b/rust/platform-app-client/graphql/ApplicationsList.graphql @@ -0,0 +1,15 @@ +query ApplicationsList($status: ApplicationStatusFilter) { + applications(status: $status) { + edges { + node { + id + label + name + description + status + url + proxyUrl + } + } + } +} diff --git a/rust/platform-app-client/graphql/ArchiveApplication.graphql b/rust/platform-app-client/graphql/ArchiveApplication.graphql new file mode 100644 index 00000000..6ac4fda2 --- /dev/null +++ b/rust/platform-app-client/graphql/ArchiveApplication.graphql @@ -0,0 +1,10 @@ +mutation ArchiveApplication($id: String!) { + archiveApplication(id: $id) { + id + label + name + status + createdAt + archivedAt + } +} diff --git a/rust/platform-app-client/graphql/CreateApplication.graphql b/rust/platform-app-client/graphql/CreateApplication.graphql new file mode 100644 index 00000000..45845cce --- /dev/null +++ b/rust/platform-app-client/graphql/CreateApplication.graphql @@ -0,0 +1,17 @@ +mutation CreateApplication($input: MutationCreateApplicationInput!) { + createApplication(input: $input) { + id + clientId + clientSecret + label + name + description + status + url + proxyUrl + authorizationScopes + redirectUris + secret + publicKey + } +} diff --git a/rust/platform-app-client/graphql/CreateRelease.graphql b/rust/platform-app-client/graphql/CreateRelease.graphql new file mode 100644 index 00000000..0b1c7e06 --- /dev/null +++ b/rust/platform-app-client/graphql/CreateRelease.graphql @@ -0,0 +1,39 @@ +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 + titleKey + description + descriptionKey + iconName + iconLibrary + metadata + presentation + } + } +} diff --git a/rust/platform-app-client/graphql/DisableApplication.graphql b/rust/platform-app-client/graphql/DisableApplication.graphql new file mode 100644 index 00000000..dbd5ac6a --- /dev/null +++ b/rust/platform-app-client/graphql/DisableApplication.graphql @@ -0,0 +1,5 @@ +mutation DisableApplication($input: MutationDisableStoreApplicationInput!) { + disableStoreApplication(input: $input) { + id + } +} diff --git a/rust/platform-app-client/graphql/EnableApplication.graphql b/rust/platform-app-client/graphql/EnableApplication.graphql new file mode 100644 index 00000000..28dd7bcf --- /dev/null +++ b/rust/platform-app-client/graphql/EnableApplication.graphql @@ -0,0 +1,5 @@ +mutation EnableApplication($input: MutationEnableStoreApplicationInput!) { + enableStoreApplication(input: $input) { + id + } +} diff --git a/rust/platform-app-client/graphql/EnabledStoreApplications.graphql b/rust/platform-app-client/graphql/EnabledStoreApplications.graphql new file mode 100644 index 00000000..0e961f28 --- /dev/null +++ b/rust/platform-app-client/graphql/EnabledStoreApplications.graphql @@ -0,0 +1,12 @@ +query EnabledStoreApplications($storeId: String!) { + enabledStoreApplications(storeId: $storeId) { + id + name + label + status + release { + id + version + } + } +} diff --git a/rust/platform-app-client/graphql/GenerateReleaseUploadUrl.graphql b/rust/platform-app-client/graphql/GenerateReleaseUploadUrl.graphql new file mode 100644 index 00000000..c47103ac --- /dev/null +++ b/rust/platform-app-client/graphql/GenerateReleaseUploadUrl.graphql @@ -0,0 +1,10 @@ +mutation GenerateReleaseUploadUrl($input: MutationGenerateReleaseUploadUrlInput!) { + generateReleaseUploadUrl(input: $input) { + uploadId + url + key + expiresAt + maxSizeBytes + requiredHeaders + } +} diff --git a/rust/platform-app-client/graphql/UpdateApplication.graphql b/rust/platform-app-client/graphql/UpdateApplication.graphql new file mode 100644 index 00000000..c7fff622 --- /dev/null +++ b/rust/platform-app-client/graphql/UpdateApplication.graphql @@ -0,0 +1,14 @@ +mutation UpdateApplication($id: String!, $input: MutationUpdateApplicationInput!) { + updateApplication(id: $id, input: $input) { + id + clientId + label + name + description + status + url + proxyUrl + authorizationScopes + redirectUris + } +} diff --git a/rust/platform-app-client/graphql/schema.graphql b/rust/platform-app-client/graphql/schema.graphql new file mode 100644 index 00000000..52e83924 --- /dev/null +++ b/rust/platform-app-client/graphql/schema.graphql @@ -0,0 +1,1617 @@ +type Application { + """The timestamp of when the Application was activated.""" + activatedAt: DateTime + + """The timestamp of when the Application was archived.""" + archivedAt: DateTime + + """ + The authorization scopes that the OAuth2 client has access to and will use to call API endpoints. + """ + authorizationScopes: [String!]! + + """ + Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps). + """ + clientId: ID + + """ + The primary OAuth client secret for this application. Only returned on application creation. + """ + clientSecret: String + + """The timestamp of when the Application was created.""" + createdAt: DateTime! + + """The timestamp of when the Application was deactivated.""" + deactivatedAt: DateTime + + """General information about your application.""" + description: String + + """The distribution type for this application.""" + distributionType: DistributionType! + + """The globally-unique ID of the Application.""" + id: ID! + + """A human-readable label of the Application for display purposes.""" + label: String! + + """ + Paginated listings for this application. Requires read scope. When using the filter, `applicationIds` is ignored — results are always scoped to this application. + """ + listings( + after: String + before: String + + """Optional filter for the listings.""" + filter: ApplicationListingFilterInput + first: Int + last: Int + ): ApplicationListingsConnection + + """A url-friendly name for the Application.""" + name: String! + + """The ID of the organization that owns this Application.""" + organizationId: ID! + + """ + The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions. + """ + proxyUrl: String + + """ + The public key (base64 encoded) for verifying action requests to your application. + """ + publicKey: String + + """ + Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update. + """ + redirectUris: [String!]! + + """The releases of the Application.""" + releases( + after: String + before: String + first: Int + last: Int + + """The order in which to sort the releases.""" + orderBy: ReleaseOrderBy + + """Filter by release status.""" + status: ReleaseStatusFilter + ): ApplicationReleasesConnection + + """ + The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again. + """ + secret: String + + """The status of the application.""" + status: ApplicationStatus! + + """The timestamp of when the Application was updated.""" + updatedAt: DateTime! + + """The public website of the application.""" + url: String +} + +"""An action that can be executed by an application within the platform.""" +type ApplicationAction { + """The timestamp of when the action was created.""" + createdAt: DateTime! + + """The globally-unique ID of the action.""" + id: String! + + """A unique identifier for the action within the application.""" + name: String! + + """The timestamp of when the action was last updated.""" + updatedAt: DateTime! + + """The endpoint URL that will be called when this action is triggered.""" + url: String! +} + +"""Input for creating a new application action.""" +input ApplicationActionCreateInput { + """A unique identifier for the action within the application.""" + name: String! + + """ + The path of the endpoint URL that will be called when this action is triggered. + """ + url: String! +} + +""" +A dependency an application release declares on another application (the application's id, the name it was declared under, and its required/excluded/enabled state). +""" +type ApplicationDependency { + """ + The immutable id of the application depended upon. Unaffected by renames of that application. + """ + applicationId: ID! + + """ + The application name this dependency was declared under. A record of what was declared, not a live reference — it is not rewritten when the depended-upon application is renamed, so it can differ from that application's current name. Use `applicationId` to identify the application. + """ + name: String! + + """The declared state for this application dependency.""" + state: ApplicationDependencyState! +} + +""" +Input for declaring a dependency on another application from a release. +""" +input ApplicationDependencyInput { + """ + The name of the application depended upon. Must match a non-archived application. + """ + name: String! + + """The declared state for this application dependency.""" + state: ApplicationDependencyState! +} + +"""The state of an application dependency declaration.""" +enum ApplicationDependencyState { + """ + The dependency application is enabled by default but can be overridden. + """ + ENABLED + + """ + The dependency application is excluded and must not be enabled alongside this release. + """ + EXCLUDED + + """ + The dependency application is required and cannot be disabled while this release is live. + """ + REQUIRED +} + +""" +An enablement represents an application installed for a specific entity (e.g. a store). +""" +type ApplicationEnablement { + """When the enablement was created.""" + createdAt: DateTime! + + """When the application was disabled.""" + disabledAt: DateTime + + """When the application was enabled.""" + enabledAt: DateTime + + """The ID of the entity this application is enabled for.""" + entityId: String! + + """The type of entity (e.g. STORE).""" + entityType: String! + + """The globally-unique ID of the enablement.""" + id: String! + + """When the enablement was last updated.""" + updatedAt: DateTime! +} + +"""A listing for an application.""" +type ApplicationListing { + """The ID of the application this listing belongs to.""" + applicationId: ID! + + """The timestamp when the listing was created.""" + createdAt: DateTime! + + """ + Taxonomy facets for this listing (region, industry, category, tag, etc.). + """ + facets: [ApplicationListingFacet!]! + + """CDN URL of the confirmed HIGHLIGHT asset, or null if not uploaded.""" + highlightImageUrl: String + + """CDN URL of the confirmed ICON asset, or null if not uploaded.""" + iconUrl: String + + """The globally-unique ID of the listing.""" + id: ID! + + """A description for the listing (plain text, max 4000 chars).""" + listingDescription: String + + """HTTPS URL for the privacy policy. Null when not set.""" + privacyPolicyUrl: String + + """CDN URLs of confirmed SCREENSHOT assets, ordered by sort_order.""" + screenshotUrls: [String!]! + + """The lifecycle status of the listing.""" + status: ListingStatus! + + """A short marketing pitch for the listing (plain text, max 160 chars).""" + tagline: String + + """HTTPS URL for the terms and conditions. Null when not set.""" + termsAndConditionsUrl: String + + """The timestamp when the listing was last updated.""" + updatedAt: DateTime! + + """CDN URLs of confirmed VIDEO assets, ordered by sort_order.""" + videoUrls: [String!]! + + """The visibility of the listing.""" + visibility: ListingVisibility! +} + +"""A taxonomy facet attached to a listing.""" +type ApplicationListingFacet { + """The facet namespace (e.g. 'region', 'industry', 'category', 'tag').""" + namespace: String! + + """The facet value (e.g. 'US', 'retail', 'ecommerce').""" + value: String! +} + +""" +Filter listings by facet. Returns listings that either have no facets in the given namespace (unrestricted), or have at least one facet in that namespace matching any of the provided values. +""" +input ApplicationListingFacetFilterInput { + """The facet namespace to filter on (e.g. 'region').""" + namespace: String! + + """ + Accepted facet values (OR semantics within this filter). An empty array is treated as a no-op for this facet filter, not as a filter that matches nothing. + """ + values: [String!]! +} + +"""A namespace + value pair for a listing facet.""" +input ApplicationListingFacetInput { + """The facet namespace (e.g. 'region', 'industry', 'category', 'tag').""" + namespace: String! + + """The facet value.""" + value: String! +} + +"""Filter options for listing queries.""" +input ApplicationListingFilterInput { + """ + Restrict results to listings for these application IDs (max 50, duplicates ignored). + """ + applicationIds: [ID!] + + """ + Filter by taxonomy facets. All provided filters must match (AND semantics across namespaces). + """ + facets: [ApplicationListingFacetFilterInput!] + + """ + Filter by listing lifecycle status. Defaults to ACTIVE for public LISTED discovery. Pass [ACTIVE, INACTIVE] to include deactivated listings (e.g. My Apps by applicationIds). + """ + status: [ListingStatus!] + + """ + Filter by visibility. Defaults to [LISTED] for public discovery. Pass [LISTED, UNLISTED, HIDDEN] to see all listings for an owner context. + """ + visibility: [ListingVisibility!] +} + +"""Fields to create or update a listing.""" +input ApplicationListingInput { + """ + Taxonomy facets for the listing. When provided, replaces all existing facets atomically. + """ + facets: [ApplicationListingFacetInput!] + + """A description for the listing (plain text, max 4000 chars).""" + listingDescription: String + + """ + HTTPS URL for the privacy policy (max 255 chars). Pass null or empty string to clear. + """ + privacyPolicyUrl: String + + """ + The lifecycle status of the listing. Defaults to INACTIVE on create. ACTIVE requires listing description, privacy URL, terms URL, categories, industries, icon, and screenshots. + """ + status: ListingStatus + + """A short marketing pitch (plain text, max 160 chars).""" + tagline: String + + """ + HTTPS URL for the terms and conditions (max 255 chars). Pass null or empty string to clear. + """ + termsAndConditionsUrl: String + + """The visibility of the listing. Defaults to LISTED.""" + visibility: ListingVisibility +} + +type ApplicationListingsConnection { + edges: [ApplicationListingsConnectionEdge] + pageInfo: PageInfo! +} + +type ApplicationListingsConnectionEdge { + cursor: String! + node: ApplicationListing +} + +""" +An MCP (Model Context Protocol) server registered with an application release. +""" +type ApplicationMcpServer { + """The timestamp of when the MCP server was created.""" + createdAt: DateTime! + + """The globally-unique ID of the MCP server.""" + id: String! + + """Additional configuration for the MCP server.""" + metadata: JSONObject + + """A unique identifier for the MCP server within the release.""" + name: String! + + """The MCP protocol version supported by this server.""" + protocolVersion: String + + """The timestamp of when the MCP server was last updated.""" + updatedAt: DateTime! + + """ + The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory'). + """ + url: String! +} + +"""Input for creating a new MCP server registration.""" +input ApplicationMcpServerCreateInput { + """ + Additional configuration for the MCP server (e.g., tool descriptions, capabilities). + """ + metadata: JSONObject + + """A unique identifier for the MCP server within the release.""" + name: String! + + """The MCP protocol version supported by this server.""" + protocolVersion: String + + """ + The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory'). + """ + url: String! +} + +""" +A native extension registered with an application release. The target platform is indicated by `platform`. +""" +type ApplicationNativeExtension { + """The ID of the associated application.""" + applicationId: String! + + """Support contact for the native extension.""" + contact: String @deprecated(reason: "Support contact belongs at the application-registration level, not on an individual extension. Retained temporarily and will be removed once an application-level contact exists.") + + """The timestamp of when the native extension was created.""" + createdAt: DateTime! + + """The globally-unique ID of the native extension.""" + id: String! + + """The display name of the native extension.""" + name: String + + """ + The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager. + """ + packageName: String + + """The platform for this native extension.""" + platform: NativeExtensionPlatform! + + """The ID of the associated release.""" + releaseId: String! + + """The timestamp of when the native extension was last updated.""" + updatedAt: DateTime! +} + +"""Input for creating a new native extension.""" +input ApplicationNativeExtensionCreateInput { + """Support contact for the native extension.""" + contact: String @deprecated(reason: "Support contact belongs at the application-registration level, not on an individual extension. Retained temporarily and will be removed once an application-level contact exists.") + + """The display name of the native extension.""" + name: String + + """ + The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager. + """ + packageName: String + + """The platform for this native extension.""" + platform: NativeExtensionPlatform! +} + +"""Fields by which applications can be ordered.""" +input ApplicationOrderBy { + """Order applications by creation date.""" + createdAt: OrderByDirectionEnum + + """Order applications by ID.""" + id: OrderByDirectionEnum + + """Order applications by name.""" + name: OrderByDirectionEnum + + """Order applications by last update date.""" + updatedAt: OrderByDirectionEnum +} + +type ApplicationRelease { + """The actions registered with this release.""" + actions: [ApplicationAction!]! + + """The timestamp of when the Application was activated.""" + activatedAt: DateTime + + """ + The other applications this release depends on, and the state it declares for each. + """ + applicationDependencies: [ApplicationDependency!]! + + """The timestamp of when the Application was created.""" + createdAt: DateTime! + + """The timestamp of when the release was deactivated.""" + deactivatedAt: DateTime + + """ + Information about the new release. Provide information about what has changed and any implications that it might have on your users. + """ + description: String + + """The feature dependencies declared by this release.""" + featureDependencies: [FeatureDependency!]! + + """The globally-unique ID of the release.""" + id: String! + + """The MCP servers registered with this release.""" + mcpServers: [ApplicationMcpServer!]! + + """The native extensions registered with this release.""" + nativeExtensions: [ApplicationNativeExtension!]! + + """The settings capabilities registered with this release.""" + settings( + """Filter settings by Commerce settings group slug.""" + groupSlug: String + ): [ApplicationSetting!]! + + """The lifecycle status of the release.""" + status: ApplicationReleaseStatus! + + """The webhook subscriptions registered with this release.""" + subscriptions: [ApplicationSubscription!]! + + """The UI extensions registered with this release.""" + uiExtensions( + """ + Optional UI extension target filter. Supports has (single match) and hasAny (any match). + """ + target: StringListFilter + ): [ApplicationUiExtension!]! + + """The timestamp of when the Application was updated.""" + updatedAt: DateTime! + + """The version number of this release.""" + version: String! +} + +"""The status of an application release.""" +enum ApplicationReleaseStatus { + """Release is active and available for use.""" + ACTIVE + + """Release is created but not active.""" + INACTIVE +} + +type ApplicationReleasesConnection { + edges: [ApplicationReleasesConnectionEdge] + pageInfo: PageInfo! +} + +type ApplicationReleasesConnectionEdge { + cursor: String! + node: ApplicationRelease +} + +""" +A settings capability registered with an application release and placed into a Commerce-owned settings group. +""" +type ApplicationSetting { + """The app-owned slug for this setting registration.""" + appSettingSlug: String! + + """Lifecycle capabilities supported by this setting.""" + capabilities: [String!]! + + """The timestamp of when the setting was created.""" + createdAt: DateTime! + + """Display description for this setting.""" + description: String + + """Localization key for the display description.""" + descriptionKey: String + + """The GPA settings namespace path that lifecycle endpoints live beneath.""" + entryPath: String! + + """The Commerce-owned settings group slug.""" + groupSlug: String! + + """Optional icon library for display.""" + iconLibrary: String + + """Optional icon name for display.""" + iconName: String + + """The globally-unique ID of the application setting.""" + id: String! + + """Additional app-defined settings metadata.""" + metadata: JSONObject + + """Sort order within the settings group.""" + order: Int! + + """The client-renderable presentation contract for this setting.""" + presentation: JSONObject! + + """Display title for this setting.""" + title: String + + """Localization key for the display title.""" + titleKey: String + + """The timestamp of when the setting was last updated.""" + updatedAt: DateTime! +} + +"""Input for creating a new application settings registration.""" +input ApplicationSettingCreateInput { + """The app-owned slug for this setting registration.""" + appSettingSlug: String! + + """ + Lifecycle capabilities supported by this setting (read, write, validate, test, delete, open, config). config requires metadata.configKeys. + """ + capabilities: [String!] + + """Display description for this setting.""" + description: String + + """Localization key for the display description.""" + descriptionKey: String + + """The GPA settings namespace path that lifecycle endpoints live beneath.""" + entryPath: String! + + """The Commerce-owned settings group slug.""" + groupSlug: String! + + """Optional icon library for display.""" + iconLibrary: String + + """Optional icon name for display.""" + iconName: String + + """Additional app-defined settings metadata.""" + metadata: JSONObject + + """Sort order within the settings group.""" + order: Int + + """ + The settings-form-v1 or settings-link-v1 presentation contract for this setting. + """ + presentation: JSONObject! + + """Display title for this setting.""" + title: String + + """Localization key for the display title.""" + titleKey: String +} + +"""The current status of an application in the platform.""" +enum ApplicationStatus { + """Application is active and can be used.""" + ACTIVE + + """Application has been archived and is no longer available.""" + ARCHIVED + + """Application has been blocked from use due to policy violations.""" + BLOCKED + + """Application is registered but not active.""" + INACTIVE + + """Application is being verified and is not yet active.""" + VERIFYING +} + +"""Filter applications by status.""" +input ApplicationStatusFilter { + """Filter by exact status match.""" + eq: ApplicationStatus + + """Filter by any of the specified statuses.""" + in: [ApplicationStatus!] + + """Filter by status not equal to the specified value.""" + ne: ApplicationStatus +} + +""" +Webhook subscriptions that an application has opted to subscribe to. Each subscription has one or many events. +""" +type ApplicationSubscription { + """The timestamp of when the Application was created.""" + createdAt: DateTime! + + """A list of events that the subscription will receive.""" + events: [String!]! + + """The globally-unique ID of the release.""" + id: String! + + """The name of the webhook subscription""" + name: String! + + """The timestamp of when the Application was updated.""" + updatedAt: DateTime! + + """The delivery URL of the webhook.""" + url: String! +} + +"""Input for creating a new application webhook subscription.""" +input ApplicationSubscriptionCreateInput { + """List of event types this subscription will receive.""" + events: [String!]! + + """A unique name for the subscription.""" + name: String! + + """The webhook URL that will receive the events.""" + url: String! +} + +""" +A UI extension registered with an application release (e.g., embed, checkout, blocks). +""" +type ApplicationUiExtension { + """The base CDN URL for packaged UI extension assets.""" + cdnUrl: String + + """The timestamp of when the UI extension was created.""" + createdAt: DateTime! + + """The unique handle/identifier for the extension (e.g., 'my-extension').""" + handle: String + + """The globally-unique ID of the UI extension.""" + id: String! + + """The display name of the extension.""" + name: String + + """ + The source file path for the extension (e.g., 'extensions/my-ext/index.ts'). + """ + source: String + + """ + The target location for the extension (e.g., 'body.end', 'checkout.header'). + """ + target: String + + """The type of UI extension (e.g., 'embed', 'checkout', 'blocks').""" + type: String! + + """The timestamp of when the UI extension was last updated.""" + updatedAt: DateTime! +} + +"""Input for creating a new application UI extension.""" +input ApplicationUiExtensionCreateInput { + """ + The unique handle/identifier for the extension (e.g., 'my-extension'). Optional for blocks extensions. + """ + handle: String + + """The display name of the extension. Optional for blocks extensions.""" + name: String + + """ + The source file path for the extension (e.g., 'extensions/my-ext/index.ts'). Optional for blocks extensions. + """ + source: String + + """ + The target location for the extension (e.g., 'body.end', 'checkout.header'). Required for targeted extensions, optional for block-type extensions. + """ + target: String + + """The type of UI extension (e.g., 'embed', 'checkout', 'blocks').""" + type: String! +} + +"""Pre-signed upload URL and metadata for listing media.""" +type CreateMediaUploadUrlResponse { + expiresAt: DateTime! + maxSizeBytes: Int! + mediaObjectId: ID! + requiredHeaders: [String!]! + uploadUrl: String! +} + +"""A date-time string in ISO 8601 format (e.g. 2022-01-01T00:00:00Z).""" +scalar DateTime + +"""The distribution type for an application.""" +enum DistributionType { + """Application has custom distribution.""" + CUSTOM + + """Application is publicly available.""" + PUBLIC +} + +type EnabledApplication { + """The timestamp of when the Application was activated.""" + activatedAt: DateTime + + """The timestamp of when the Application was archived.""" + archivedAt: DateTime + + """ + The authorization scopes that the OAuth2 client has access to and will use to call API endpoints. + """ + authorizationScopes: [String!]! + + """ + Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps). + """ + clientId: ID + + """ + The primary OAuth client secret for this application. Only returned on application creation. + """ + clientSecret: String + + """The timestamp of when the Application was created.""" + createdAt: DateTime! + + """The timestamp of when the Application was deactivated.""" + deactivatedAt: DateTime + + """General information about your application.""" + description: String + + """The globally-unique ID of the Application.""" + id: ID! + + """A human-readable label of the Application for display purposes.""" + label: String! + + """A url-friendly name for the Application.""" + name: String! + + """The ID of the organization that owns this Application.""" + organizationId: ID! + + """ + The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions. + """ + proxyUrl: String + + """ + The public key (base64 encoded) for verifying action requests to your application. + """ + publicKey: String + + """ + Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update. + """ + redirectUris: [String!]! + + """The release/version enabled for this application""" + release: ApplicationRelease + + """ + The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again. + """ + secret: String + + """The status of the application.""" + status: ApplicationStatus! + + """The timestamp of when the Application was updated.""" + updatedAt: DateTime! + + """The public website of the application.""" + url: String +} + +"""Fields by which enablements can be ordered.""" +input EnablementOrderBy { + """Order enablements by creation date.""" + createdAt: OrderByDirectionEnum + + """Order enablements by ID.""" + id: OrderByDirectionEnum + + """Order enablements by last update date.""" + updatedAt: OrderByDirectionEnum +} + +""" +A feature dependency declared by an application release (a feature name and its required/excluded/enabled/disabled state). +""" +type FeatureDependency { + """The feature name from the available features list.""" + name: String! + + """The declared state for this feature.""" + state: FeatureDependencyState! +} + +"""Input for declaring a feature dependency on a release.""" +input FeatureDependencyInput { + """The feature name from the available features list.""" + name: String! + + """The declared state for this feature.""" + state: FeatureDependencyState! +} + +"""The state of a feature dependency declaration.""" +enum FeatureDependencyState { + """Feature is disabled by default but can be overridden.""" + DISABLED + + """Feature is enabled by default but can be overridden.""" + ENABLED + + """Feature is excluded and cannot be required by other apps.""" + EXCLUDED + + """Feature is required and cannot be excluded by other apps.""" + REQUIRED +} + +"""Response containing pre-signed upload URL and metadata.""" +type GenerateReleaseUploadUrlResponse { + """The timestamp when the upload URL expires.""" + expiresAt: DateTime! + + """The S3 key where the file should be uploaded.""" + key: String! + + """Maximum allowed file size in bytes.""" + maxSizeBytes: Int! + + """Required headers for the upload as key:value pairs.""" + requiredHeaders: [String!]! + + """The unique upload identifier for tracking.""" + uploadId: String! + + """The pre-signed URL for uploading the file.""" + url: String! +} + +"""A JSON object scalar type.""" +scalar JSONObject + +"""Allowed MIME types for listing media uploads.""" +enum ListingMediaContentType { + IMAGE_JPEG + IMAGE_PNG + VIDEO_MP4 +} + +"""The merchandising role of a listing media asset.""" +enum ListingMediaRole { + HIGHLIGHT + ICON + SCREENSHOT + VIDEO +} + +"""The lifecycle status of a listing media upload.""" +enum ListingMediaStatus { + CONFIRMED + FAILED + PENDING + REMOVED + SUPERSEDED +} + +"""The lifecycle status of a listing.""" +enum ListingStatus { + """Listing is active and eligible for marketplace discovery.""" + ACTIVE + + """Listing is inactive until activation requirements are met.""" + INACTIVE +} + +"""The visibility of a listing.""" +enum ListingVisibility { + """Listing is hidden from discovery surfaces.""" + HIDDEN + + """Listing is publicly visible.""" + LISTED + + """Listing is not shown in search.""" + UNLISTED +} + +"""A media asset attached to an application listing.""" +type MediaObject { + applicationId: ID! + contentType: String + createdAt: DateTime! + failureReason: String + filename: String + id: ID! + listingId: ID + role: ListingMediaRole + sortOrder: Int! + status: ListingMediaStatus! + updatedAt: DateTime! + url: String +} + +type Mutation { + """ + Activate a release and, when permitted, promote its parent application to ACTIVE. + """ + activateRelease( + """The ID of the application.""" + applicationId: ID! + + """The ID of the release to activate.""" + releaseId: ID! + ): ApplicationRelease + + """Archive an existing Application.""" + archiveApplication( + """The ID of the application to archive.""" + id: String! + ): Application + + """Confirm a listing media upload after the client PUTs the file to S3.""" + confirmMediaObject( + """The application that owns the media object — used for authorization.""" + applicationId: ID! + + """The media object ID returned from createMediaUploadUrl.""" + mediaObjectId: ID! + ): MediaObject + + """Create a new Application.""" + createApplication(input: MutationCreateApplicationInput!): Application + + """ + Create a pending listing media record and return a pre-signed S3 PUT URL. + """ + createMediaUploadUrl( + """The application that owns the listing (1:1).""" + applicationId: ID! + + """MIME type of the file to upload.""" + contentType: ListingMediaContentType! + + """ + Declared file size in bytes. Rejected if it exceeds the role cap before issuing the presigned URL. + """ + fileSizeBytes: Int! + + """Original filename from the client.""" + filename: String! + + """Merchandising role for this asset.""" + role: ListingMediaRole! + + """ + Optional ordering for screenshots/videos (0–7). Defaults to next slot. Ignored for other roles (defaults to 0). + """ + sortOrder: Int + ): CreateMediaUploadUrlResponse + + """Create a new release for an application.""" + createRelease(input: MutationCreateReleaseInput!): ApplicationRelease + + """ + Deactivate an application through its lifecycle operation. Activate it again by activating a release. + """ + deactivateApplication( + """The ID of the application to deactivate.""" + id: String! + ): Application + + """ + Deactivate a release. When it is the last active release, its application listing is also deactivated. + """ + deactivateRelease( + """The ID of the application.""" + applicationId: ID! + + """The ID of the release to deactivate.""" + releaseId: ID! + ): ApplicationRelease + + """Soft-delete the listing for an application.""" + deleteApplicationListing( + """The ID of the application.""" + applicationId: ID! + ): Boolean + + """Disable an application on a customer""" + disableCustomerApplication(input: MutationDisableCustomerApplicationInput!): Application + + """Disable an application in a store""" + disableStoreApplication(input: MutationDisableStoreApplicationInput!): Application + + """Enable an application on a customer""" + enableCustomerApplication(input: MutationEnableCustomerApplicationInput!): Application + + """Enable an application on a store""" + enableStoreApplication(input: MutationEnableStoreApplicationInput!): Application + + """Generate a pre-signed URL for uploading UI extension artifacts.""" + generateReleaseUploadUrl(input: MutationGenerateReleaseUploadUrlInput!): GenerateReleaseUploadUrlResponse + + """ + Remove a confirmed listing media object. Sets status to REMOVED, deletes the promoted S3 object, and invalidates the CDN path. + """ + removeListingMediaObject( + """ + The application that owns the media object — prevents cross-app removal. + """ + applicationId: ID! + + """The confirmed media object ID to remove.""" + mediaObjectId: ID! + ): MediaObject + + """ + Create or update the listing for an application. Idempotent — safe to call multiple times for the same application. + """ + setApplicationListing( + """The ID of the application.""" + applicationId: ID! + + """The listing fields to create or update.""" + input: ApplicationListingInput! + ): ApplicationListing + + """Update an existing Application.""" + updateApplication(id: String!, input: MutationUpdateApplicationInput!): Application +} + +input MutationCreateApplicationInput { + """ + The authorization scopes of the application. Defines the GoDaddy permissions for the application. + """ + authorizationScopes: [String!] + + """The description of the application.""" + description: String + + """The distribution type for the application. Defaults to PUBLIC.""" + distributionType: DistributionType = PUBLIC + + """The label of the application.""" + label: String! + + """The name of the application.""" + name: String! + + """ + Optional organization ID for multi-org users. If omitted, the organization is auto-resolved from the user's session. If provided, the user must be an admin of the specified organization. + """ + organizationId: String + + """ + The proxy URL of the application. This is the base URL for all API endpoints. + """ + proxyUrl: String + + """ + Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the application URL or its default callback are rejected; those defaults are registered automatically. + """ + redirectUris: [String!] + + """ + The public URL of the application. This is typically the URL of the application's website. + """ + url: String +} + +input MutationCreateReleaseInput { + """The actions associated with the release.""" + actions: [ApplicationActionCreateInput!] + + """ + The other applications this release depends on, and the state it declares for each. + """ + applicationDependencies: [ApplicationDependencyInput!] + + """The ID of the application.""" + applicationId: ID! + + """The description of the release.""" + description: String + + """The feature dependencies declared by the release.""" + featureDependencies: [FeatureDependencyInput!] + + """The MCP servers associated with the release.""" + mcpServers: [ApplicationMcpServerCreateInput!] + + """The native extensions associated with the release.""" + nativeExtensions: [ApplicationNativeExtensionCreateInput!] + + """The settings capabilities associated with the release.""" + settings: [ApplicationSettingCreateInput!] + + """The subscriptions associated with the release.""" + subscriptions: [ApplicationSubscriptionCreateInput!] + + """The UI extensions associated with the release.""" + uiExtensions: [ApplicationUiExtensionCreateInput!] + + """The version of the release.""" + version: String! +} + +input MutationDisableCustomerApplicationInput { + """The name of the application to disable.""" + applicationName: String! + + """The ID of the customer to disable the application on.""" + customerId: String! +} + +input MutationDisableStoreApplicationInput { + """The name of the application to disable.""" + applicationName: String! + + """The ID of the store to disable the application on.""" + storeId: String! +} + +input MutationEnableCustomerApplicationInput { + """The name of the application to enable.""" + applicationName: String! + + """The ID of the customer to enable the application on.""" + customerId: String! +} + +input MutationEnableStoreApplicationInput { + """The name of the application to enable.""" + applicationName: String! + + """The ID of the store to enable the application on.""" + storeId: String! +} + +input MutationGenerateReleaseUploadUrlInput { + """The ID of the application.""" + applicationId: ID! + + """The content type of the file to be uploaded.""" + contentType: UploadContentType! + + """The ID of the release.""" + releaseId: ID! + + """The target for the upload.""" + target: String +} + +input MutationUpdateApplicationInput { + """ + The authorization scopes of the application. Defines the GoDaddy permissions for the application. + """ + authorizationScopes: [String!] + + """The description of the application.""" + description: String + + """ + The distribution type for the application (PUBLIC or CUSTOM). Omit to leave unchanged. + """ + distributionType: DistributionType + + """The label of the application.""" + label: String + + """The name of the application.""" + name: String + + """ + The proxy URL of the application. This is the base URL for all API endpoints. + """ + proxyUrl: String + + """ + Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the effective application URL or its default callback are rejected. On URL changes, both old defaults are replaced; submit any old URI explicitly to retain it as an extra. + """ + redirectUris: [String!] + + """ + The public URL of the application. This is typically the URL of the application's website. + """ + url: String +} + +"""The platform for a native extension.""" +enum NativeExtensionPlatform { + """Android platform (APK distribution).""" + ANDROID +} + +"""Represents a null value.""" +scalar Null + +"""Direction for ordering results in queries.""" +enum OrderByDirectionEnum { + """Sort in ascending order.""" + ASC + + """Sort in descending order.""" + DESC +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +type Query { + """Get a single application by ID or name.""" + application( + """The OAuth client ID of the application to retrieve.""" + clientId: String + + """The unique ID of the application to retrieve.""" + id: String + + """The unique name of the application to retrieve.""" + name: String + ): Application + + """ + Paginated listings. Defaults to publicly LISTED listings for active PUBLIC-distribution apps. Pass visibility filter to query owner/admin contexts. + """ + applicationListings( + after: String + before: String + + """Filter options for the query.""" + filter: ApplicationListingFilterInput + first: Int + last: Int + ): QueryApplicationListingsConnection + + """ + Get paginated applications. Returns all public applications for service requests, or only applications owned by the authenticated customer's organization. Multi-org users may pass an explicit `organizationId` to select which organization's apps to list. + """ + applications( + after: String + before: String + first: Int + last: Int + + """The order in which to sort the applications.""" + orderBy: ApplicationOrderBy + + """ + Optional organization ID. When provided, lists apps for that specific organization. Customer tokens must be admin of the organization; service tokens may specify any organization. + """ + organizationId: String + + """Filter by application status. Defaults to ACTIVE applications only.""" + status: ApplicationStatusFilter + ): QueryApplicationsConnection + + """ + Get all applications enabled for an entity by entity ID and type, with optional filtering by actions, UI extensions, and settings. + """ + enabledApplications( + """ + Filter by action names. Supports has (single match) and hasAny (any match). + """ + actionNames: StringListFilter + + """The ID of the entity to get enabled applications for.""" + entityId: String! + + """ + The type of the entity the applications are enabled on. Currently STORE. + """ + entityType: String! + + """Filter by Commerce settings group slug.""" + settingGroupSlug: String + + """ + Filter by application setting slugs. Supports has (single match) and hasAny (any match). + """ + settingSlugs: StringListFilter + + """ + Filter by UI extension targets. Supports has (single match) and hasAny (any match). + """ + uiExtensionTargets: StringListFilter + + """ + Filter by UI extension types. Supports has (single match) and hasAny (any match). + """ + uiExtensionTypes: StringListFilter + ): [EnabledApplication!] + + """ + Get all applications enabled for a store by store ID, with optional filtering by actions, UI extensions, and settings. + """ + enabledStoreApplications( + """ + Filter by action names. Supports has (single match) and hasAny (any match). + """ + actionNames: StringListFilter + + """Filter by Commerce settings group slug.""" + settingGroupSlug: String + + """ + Filter by application setting slugs. Supports has (single match) and hasAny (any match). + """ + settingSlugs: StringListFilter + + """The ID of the store to get enabled applications for.""" + storeId: String! + + """ + Filter by UI extension targets. Supports has (single match) and hasAny (any match). + """ + uiExtensionTargets: StringListFilter + + """ + Filter by UI extension types. Supports has (single match) and hasAny (any match). + """ + uiExtensionTypes: StringListFilter + ): [EnabledApplication!] + + """Returns a paginated list of enablements for a specific release.""" + enablementsByRelease( + after: String + before: String + first: Int + last: Int + + """The order in which to sort the enablements.""" + orderBy: EnablementOrderBy + + """The release ID to query enablements for.""" + releaseId: ID! + ): QueryEnablementsByReleaseConnection! + + """Get the status of a release upload by upload ID.""" + getReleaseUpload( + """The application ID to verify authorization.""" + applicationId: ID! + + """The unique upload identifier.""" + uploadId: String! + ): ReleaseUpload + + """Get a single native extension by ID.""" + nativeExtension( + """The ID of the native extension to retrieve.""" + id: ID! + ): ApplicationNativeExtension + + """ + List native extensions by application or release. At least one of applicationId or releaseId must be provided. + """ + nativeExtensions( + """Filter by application ID.""" + applicationId: ID + + """Maximum number of results to return.""" + limit: Int + + """Filter by release ID.""" + releaseId: ID + ): [ApplicationNativeExtension!]! + + """ + Get the resolved feature states for a store, after reconciling all active app declarations. + """ + storeFeatures( + """The ID of the store to resolve features for.""" + storeId: String! + ): [FeatureDependency!]! + + """Returns webhook subscription IDs for a specific release/entity pair.""" + webhookSubscriptionInstancesByRelease( + """The entity ID to scope the query to.""" + entityId: ID! + + """The entity type to scope the query to (e.g. STORE).""" + entityType: String! + + """The release ID to query webhook subscriptions for.""" + releaseId: ID! + ): [String!]! +} + +type QueryApplicationListingsConnection { + edges: [QueryApplicationListingsConnectionEdge] + pageInfo: PageInfo! +} + +type QueryApplicationListingsConnectionEdge { + cursor: String! + node: ApplicationListing +} + +type QueryApplicationsConnection { + edges: [QueryApplicationsConnectionEdge] + pageInfo: PageInfo! +} + +type QueryApplicationsConnectionEdge { + cursor: String! + node: Application +} + +type QueryEnablementsByReleaseConnection { + edges: [QueryEnablementsByReleaseConnectionEdge] + pageInfo: PageInfo! +} + +type QueryEnablementsByReleaseConnectionEdge { + cursor: String! + node: ApplicationEnablement +} + +"""Fields by which application releases can be ordered.""" +input ReleaseOrderBy { + """Order releases by activation date.""" + activatedAt: OrderByDirectionEnum + + """Order releases by creation date.""" + createdAt: OrderByDirectionEnum + + """Order releases by ID.""" + id: OrderByDirectionEnum + + """Order releases by last update date.""" + updatedAt: OrderByDirectionEnum + + """Order releases by version number. Must be in {major}.{minor} format.""" + version: OrderByDirectionEnum +} + +"""Filter releases by status.""" +input ReleaseStatusFilter { + """Filter by exact status match.""" + eq: ApplicationReleaseStatus + + """Filter by any of the specified statuses.""" + in: [ApplicationReleaseStatus!] + + """Filter by status not equal to the specified value.""" + ne: ApplicationReleaseStatus +} + +"""A release upload record tracking the status of uploaded artifacts.""" +type ReleaseUpload { + """The ID of the associated application.""" + applicationId: String! + + """The timestamp when the upload was completed or failed.""" + completedAt: DateTime + + """The MIME type of the uploaded content.""" + contentType: UploadContentType! + + """The timestamp when the upload was requested.""" + createdAt: DateTime! + + """The reason for failure if status is FAILED.""" + failureReason: String + + """The globally-unique ID of the upload record.""" + id: String! + + """The ID of the associated release.""" + releaseId: String! + + """The size of the uploaded file in bytes.""" + sizeBytes: Int + + """The current status of the upload.""" + status: ReleaseUploadStatus! + + """The unique upload identifier used for correlation.""" + uploadId: String! +} + +"""The status of a release upload.""" +enum ReleaseUploadStatus { + """Upload or processing failed.""" + FAILED + + """File has been processed and deployed successfully.""" + PROCESSED + + """Upload URL has been generated and is awaiting upload.""" + REQUESTED + + """File has been uploaded and is awaiting processing.""" + UPLOADED +} + +"""Filter for fields that are lists/sets of strings.""" +input StringListFilter { + """Field must contain this value.""" + has: String + + """Field must contain at least one of these values.""" + hasAny: [String!] +} + +"""Supported content types for release uploads.""" +enum UploadContentType { + """JavaScript file""" + JS + + """TAR archive""" + TAR + + """ZIP archive""" + ZIP +} \ No newline at end of file diff --git a/rust/platform-app-client/src/lib.rs b/rust/platform-app-client/src/lib.rs new file mode 100644 index 00000000..73a8ab44 --- /dev/null +++ b/rust/platform-app-client/src/lib.rs @@ -0,0 +1,401 @@ +//! Generated App Registry GraphQL client +//! +//! The contents of this crate are **generated** by `graphql_client_codegen` +//! at build time from the vendored subgraph schema (`graphql/schema.graphql`) +//! and this crate's `.graphql` operation documents. Construct [`Client`] with +//! [`client_with_auth`] to supply a pre-authenticated `reqwest::Client` +//! wrapper (the CLI sets the `Authorization: Bearer ` header itself), +//! then call [`Client::send`] with any generated operation type (it +//! implements `graphql_client::GraphQLQuery`). +//! +//! The lint allowances are scoped to the generated module so the hand-written +//! code below is still linted normally. `BuildError`/`TransportObserver`/ +//! `set_transport_observer` are shared with every other generated client +//! crate — see `generated-client-support`. + +/// Rust types for this schema's custom scalars, referenced by the generated +/// module via `set_custom_scalars_module` in `build.rs`. +#[allow(clippy::upper_case_acronyms)] +pub mod scalars { + /// The schema declares no format for `DateTime` beyond "a string" — kept + /// as the wire string rather than parsed, matching how + /// `ApplicationClient` treats every other field today (opaque + /// `serde_json::Value` passthrough to `CommandResult`). + pub type DateTime = String; + /// Named to match the schema's own scalar name exactly — + /// `custom_scalars_module` codegen references `crate::scalars::` + /// verbatim, casing included. + pub type JSONObject = serde_json::Value; + pub type Null = (); +} + +/// graphql_client-generated operation types. Exempt from the workspace's +/// strict style/rustdoc lints (it's machine-generated); the rest of the +/// crate is not. +mod generated { + #![allow(clippy::all)] + #![allow(dead_code)] + #![allow(unused_imports)] + #![allow(rustdoc::all)] + + use crate::scalars::{DateTime, JSONObject, Null}; + + include!(concat!(env!("OUT_DIR"), "/codegen.rs")); +} + +pub use generated::*; +pub use generated_client_support::{BuildError, TransportObserver, set_transport_observer}; + +const GRAPHQL_PATH: &str = "/v1/apps/app-registry-subgraph"; + +/// Operations whose responses carry credentials (`CreateApplication` returns +/// the client secret, webhook secret and public key). Their response bodies +/// are never handed to the transport observer, so `--debug transport` cannot +/// write them into a trace. +const SECRET_RESPONSE_OPERATIONS: &[&str] = &["CreateApplication"]; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("HTTP error {status}: {body}")] + Http { status: u16, body: String }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("GraphQL errors: {0}")] + GraphQL(String), +} + +/// Wraps a pre-authenticated `reqwest::Client` and sends any generated +/// operation (a `graphql_client::GraphQLQuery` implementor) to the App +/// Registry subgraph. +pub struct Client { + http: reqwest::Client, + base_url: String, +} + +/// Build a [`Client`] whose every request carries a pre-set `Authorization` +/// header and `x-request-id`. `authorization` is the full header value the +/// App Registry endpoint expects — e.g. `"Bearer "`. +pub fn client_with_auth( + base_url: &str, + authorization: &str, + user_agent: &str, + request_id: &str, +) -> Result { + let http = generated_client_support::build_authenticated_http_client( + authorization, + user_agent, + request_id, + )?; + Ok(Client { + http, + base_url: base_url.to_owned(), + }) +} + +impl Client { + /// Sends `Q` (any generated operation type) and returns its typed + /// response data — or a [`ClientError::GraphQL`] built from the + /// response's `errors` array, the same "HTTP 200 with a top-level + /// `errors` array" convention `platform::app::client::ClientError` + /// hand-rolls today. + pub async fn send(&self, variables: Q::Variables) -> Result + where + Q: graphql_client::GraphQLQuery, + { + let body = Q::build_query(variables); + let url = format!("{}{GRAPHQL_PATH}", self.base_url); + let request = self.http.post(&url).json(&body).build()?; + generated_client_support::notify_request(&request); + + let response = if SECRET_RESPONSE_OPERATIONS.contains(&body.operation_name) { + self.http.execute(request).await? + } else { + generated_client_support::execute_and_observe(&self.http, request).await? + }; + + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(ClientError::Http { + status: status.as_u16(), + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + + let parsed: graphql_client::Response = 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) = parsed.errors.filter(|errors| !errors.is_empty()) { + // `graphql_client::Error` has its own `Display` (`path:line:col: + // message`) — join that instead of `Debug`-formatting the whole + // struct, which included noisy `None`/empty `extensions` fields + // and wasn't stable across schema changes to those fields. + let joined = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "); + return Err(ClientError::GraphQL(joined)); + } + parsed + .data + .ok_or_else(|| ClientError::GraphQL("response had no data and no errors".to_owned())) + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + + fn client_for(server: &MockServer) -> Client { + client_with_auth( + &server.base_url(), + "Bearer test-token", + "test-agent", + "req-1", + ) + .expect("build client") + } + + #[tokio::test] + async fn applications_list_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 statuses = ["ACTIVE", "ARCHIVED", "BLOCKED", "INACTIVE", "VERIFYING"] + .into_iter() + .map(|s| serde_json::from_value(json!(s)).expect("valid ApplicationStatus")) + .collect(); + let variables = applications_list::Variables { + status: Some(applications_list::ApplicationStatusFilter { + eq: None, + in_: Some(statuses), + ne: None, + }), + }; + + let data = client_for(&server) + .send::(variables) + .await + .expect("list applications"); + + mock.assert_async().await; + let edges = data.applications.expect("applications connection").edges; + assert_eq!(edges.expect("edges").len(), 2); + } + + #[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-09-18T00:00:00Z", + "createdAt": "2026-09-18T00:00:00Z", + "updatedAt": "2026-09-18T00:00:00Z" + } + } + })); + }) + .await; + + let variables = activate_release::Variables { + application_id: "app-123".to_owned(), + release_id: "rel-456".to_owned(), + }; + + let data = client_for(&server) + .send::(variables) + .await + .expect("activate release"); + + mock.assert_async().await; + let release = data.activate_release.expect("activateRelease result"); + assert_eq!(release.id, "rel-456"); + assert_eq!(release.version, "1.0.0"); + } + + /// Same "GraphQL errors on HTTP 200" convention + /// `platform::app::client::ClientError::GraphQL` hand-rolls today — + /// proves the generic `graphql_client::Response` wrapper covers it + /// without extra code on our side. + #[tokio::test] + async fn send_surfaces_graphql_errors_on_http_200() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "errors": [{ "message": "release not found" }] + })); + }) + .await; + + let variables = activate_release::Variables { + application_id: "app-123".to_owned(), + release_id: "missing".to_owned(), + }; + + let err = client_for(&server) + .send::(variables) + .await + .expect_err("graphql errors should surface"); + + mock.assert_async().await; + assert!( + matches!(err, ClientError::GraphQL(ref msg) if msg.contains("release not found")), + "expected GraphQL error variant, got: {err:?}" + ); + } + + /// Records every response body handed to the transport observer. + struct RecordingObserver(std::sync::Mutex>); + + impl TransportObserver for RecordingObserver { + fn on_request(&self, _request: &reqwest::Request) {} + + fn on_response( + &self, + _status: reqwest::StatusCode, + _headers: &reqwest::header::HeaderMap, + body: &[u8], + ) { + self.0 + .lock() + .expect("recorder lock") + .push(String::from_utf8_lossy(body).into_owned()); + } + } + + #[tokio::test] + async fn credential_bearing_responses_are_not_shown_to_the_transport_observer() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .is_true(|req| req.body_string().contains("CreateApplication")); + then.status(200).json_body(json!({ + "data": { + "createApplication": { + "id": "app-1", + "clientId": "client-1", + "clientSecret": "client-secret-value", + "label": "My App", + "name": "my-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null, + "authorizationScopes": [], + "redirectUris": [], + "secret": "webhook-secret-value", + "publicKey": "public-key-value" + } + } + })); + }) + .await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .is_true(|req| !req.body_string().contains("CreateApplication")); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-1", + "label": "My App", + "name": "observed-app", + "description": null, + "status": "ACTIVE", + "url": null, + "proxyUrl": null + } + } + })); + }) + .await; + + let observer = std::sync::Arc::new(RecordingObserver(std::sync::Mutex::new(Vec::new()))); + set_transport_observer(Some(observer.clone())); + + let client = client_for(&server); + let variables: create_application::Variables = serde_json::from_value(json!({ + "input": { "name": "my-app", "label": "My App" } + })) + .expect("create variables"); + let created = client + .send::(variables) + .await + .expect("create application"); + client + .send::(application::Variables { + name: "observed-app".to_owned(), + }) + .await + .expect("get application"); + + set_transport_observer(None); + assert_eq!( + created.create_application.and_then(|app| app.client_secret), + Some("client-secret-value".to_owned()), + "the caller still receives the credentials" + ); + let seen = observer.0.lock().expect("recorder lock").join("\n"); + assert!( + seen.contains("observed-app"), + "ordinary responses are still observed" + ); + assert!( + !seen.contains("client-secret-value") && !seen.contains("webhook-secret-value"), + "credential-bearing response leaked to the observer: {seen}" + ); + } +} diff --git a/rust/schemas/api/app-registry.json b/rust/schemas/api/app-registry.json new file mode 100644 index 00000000..39698ea0 --- /dev/null +++ b/rust/schemas/api/app-registry.json @@ -0,0 +1,2640 @@ +{ + "baseUrl": "https://api.godaddy.com", + "description": "GraphQL API with 28 operations", + "endpoints": [ + { + "description": "GraphQL endpoint with 28 operations", + "graphql": { + "operationCount": 28, + "operations": [ + { + "args": [ + { + "description": "The OAuth client ID of the application to retrieve.", + "name": "clientId", + "required": false, + "type": "String" + }, + { + "description": "The unique ID of the application to retrieve.", + "name": "id", + "required": false, + "type": "String" + }, + { + "description": "The unique name of the application to retrieve.", + "name": "name", + "required": false, + "type": "String" + } + ], + "deprecated": false, + "description": "Get a single application by ID or name.", + "kind": "query", + "name": "application", + "returnType": "Application" + }, + { + "args": [ + { + "name": "after", + "required": false, + "type": "String" + }, + { + "name": "before", + "required": false, + "type": "String" + }, + { + "description": "Filter options for the query.", + "name": "filter", + "required": false, + "type": "ApplicationListingFilterInput" + }, + { + "name": "first", + "required": false, + "type": "Int" + }, + { + "name": "last", + "required": false, + "type": "Int" + } + ], + "deprecated": false, + "description": "Paginated listings. Defaults to publicly LISTED listings for active PUBLIC-distribution apps. Pass visibility filter to query owner/admin contexts.", + "kind": "query", + "name": "applicationListings", + "returnType": "QueryApplicationListingsConnection" + }, + { + "args": [ + { + "name": "after", + "required": false, + "type": "String" + }, + { + "name": "before", + "required": false, + "type": "String" + }, + { + "name": "first", + "required": false, + "type": "Int" + }, + { + "name": "last", + "required": false, + "type": "Int" + }, + { + "description": "The order in which to sort the applications.", + "name": "orderBy", + "required": false, + "type": "ApplicationOrderBy" + }, + { + "description": "Optional organization ID. When provided, lists apps for that specific organization. Customer tokens must be admin of the organization; service tokens may specify any organization.", + "name": "organizationId", + "required": false, + "type": "String" + }, + { + "description": "Filter by application status. Defaults to ACTIVE applications only.", + "name": "status", + "required": false, + "type": "ApplicationStatusFilter" + } + ], + "deprecated": false, + "description": "Get paginated applications. Returns all public applications for service requests, or only applications owned by the authenticated customer's organization. Multi-org users may pass an explicit `organizationId` to select which organization's apps to list.", + "kind": "query", + "name": "applications", + "returnType": "QueryApplicationsConnection" + }, + { + "args": [ + { + "description": "Filter by action names. Supports has (single match) and hasAny (any match).", + "name": "actionNames", + "required": false, + "type": "StringListFilter" + }, + { + "description": "The ID of the entity to get enabled applications for.", + "name": "entityId", + "required": true, + "type": "String!" + }, + { + "description": "The type of the entity the applications are enabled on. Currently STORE.", + "name": "entityType", + "required": true, + "type": "String!" + }, + { + "description": "Filter by Commerce settings group slug.", + "name": "settingGroupSlug", + "required": false, + "type": "String" + }, + { + "description": "Filter by application setting slugs. Supports has (single match) and hasAny (any match).", + "name": "settingSlugs", + "required": false, + "type": "StringListFilter" + }, + { + "description": "Filter by UI extension targets. Supports has (single match) and hasAny (any match).", + "name": "uiExtensionTargets", + "required": false, + "type": "StringListFilter" + }, + { + "description": "Filter by UI extension types. Supports has (single match) and hasAny (any match).", + "name": "uiExtensionTypes", + "required": false, + "type": "StringListFilter" + } + ], + "deprecated": false, + "description": "Get all applications enabled for an entity by entity ID and type, with optional filtering by actions, UI extensions, and settings.", + "kind": "query", + "name": "enabledApplications", + "returnType": "[EnabledApplication!]" + }, + { + "args": [ + { + "description": "Filter by action names. Supports has (single match) and hasAny (any match).", + "name": "actionNames", + "required": false, + "type": "StringListFilter" + }, + { + "description": "Filter by Commerce settings group slug.", + "name": "settingGroupSlug", + "required": false, + "type": "String" + }, + { + "description": "Filter by application setting slugs. Supports has (single match) and hasAny (any match).", + "name": "settingSlugs", + "required": false, + "type": "StringListFilter" + }, + { + "description": "The ID of the store to get enabled applications for.", + "name": "storeId", + "required": true, + "type": "String!" + }, + { + "description": "Filter by UI extension targets. Supports has (single match) and hasAny (any match).", + "name": "uiExtensionTargets", + "required": false, + "type": "StringListFilter" + }, + { + "description": "Filter by UI extension types. Supports has (single match) and hasAny (any match).", + "name": "uiExtensionTypes", + "required": false, + "type": "StringListFilter" + } + ], + "deprecated": false, + "description": "Get all applications enabled for a store by store ID, with optional filtering by actions, UI extensions, and settings.", + "kind": "query", + "name": "enabledStoreApplications", + "returnType": "[EnabledApplication!]" + }, + { + "args": [ + { + "name": "after", + "required": false, + "type": "String" + }, + { + "name": "before", + "required": false, + "type": "String" + }, + { + "name": "first", + "required": false, + "type": "Int" + }, + { + "name": "last", + "required": false, + "type": "Int" + }, + { + "description": "The order in which to sort the enablements.", + "name": "orderBy", + "required": false, + "type": "EnablementOrderBy" + }, + { + "description": "The release ID to query enablements for.", + "name": "releaseId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Returns a paginated list of enablements for a specific release.", + "kind": "query", + "name": "enablementsByRelease", + "returnType": "QueryEnablementsByReleaseConnection!" + }, + { + "args": [ + { + "description": "The application ID to verify authorization.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The unique upload identifier.", + "name": "uploadId", + "required": true, + "type": "String!" + } + ], + "deprecated": false, + "description": "Get the status of a release upload by upload ID.", + "kind": "query", + "name": "getReleaseUpload", + "returnType": "ReleaseUpload" + }, + { + "args": [ + { + "description": "The ID of the native extension to retrieve.", + "name": "id", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Get a single native extension by ID.", + "kind": "query", + "name": "nativeExtension", + "returnType": "ApplicationNativeExtension" + }, + { + "args": [ + { + "description": "Filter by application ID.", + "name": "applicationId", + "required": false, + "type": "ID" + }, + { + "description": "Maximum number of results to return.", + "name": "limit", + "required": false, + "type": "Int" + }, + { + "description": "Filter by release ID.", + "name": "releaseId", + "required": false, + "type": "ID" + } + ], + "deprecated": false, + "description": "List native extensions by application or release. At least one of applicationId or releaseId must be provided.", + "kind": "query", + "name": "nativeExtensions", + "returnType": "[ApplicationNativeExtension!]!" + }, + { + "args": [ + { + "description": "The ID of the store to resolve features for.", + "name": "storeId", + "required": true, + "type": "String!" + } + ], + "deprecated": false, + "description": "Get the resolved feature states for a store, after reconciling all active app declarations.", + "kind": "query", + "name": "storeFeatures", + "returnType": "[FeatureDependency!]!" + }, + { + "args": [ + { + "description": "The entity ID to scope the query to.", + "name": "entityId", + "required": true, + "type": "ID!" + }, + { + "description": "The entity type to scope the query to (e.g. STORE).", + "name": "entityType", + "required": true, + "type": "String!" + }, + { + "description": "The release ID to query webhook subscriptions for.", + "name": "releaseId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Returns webhook subscription IDs for a specific release/entity pair.", + "kind": "query", + "name": "webhookSubscriptionInstancesByRelease", + "returnType": "[String!]!" + }, + { + "args": [ + { + "description": "The ID of the application.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The ID of the release to activate.", + "name": "releaseId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Activate a release and, when permitted, promote its parent application to ACTIVE.", + "kind": "mutation", + "name": "activateRelease", + "returnType": "ApplicationRelease" + }, + { + "args": [ + { + "description": "The ID of the application to archive.", + "name": "id", + "required": true, + "type": "String!" + } + ], + "deprecated": false, + "description": "Archive an existing Application.", + "kind": "mutation", + "name": "archiveApplication", + "returnType": "Application" + }, + { + "args": [ + { + "description": "The application that owns the media object — used for authorization.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The media object ID returned from createMediaUploadUrl.", + "name": "mediaObjectId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Confirm a listing media upload after the client PUTs the file to S3.", + "kind": "mutation", + "name": "confirmMediaObject", + "returnType": "MediaObject" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationCreateApplicationInput!" + } + ], + "deprecated": false, + "description": "Create a new Application.", + "kind": "mutation", + "name": "createApplication", + "returnType": "Application" + }, + { + "args": [ + { + "description": "The application that owns the listing (1:1).", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "MIME type of the file to upload.", + "name": "contentType", + "required": true, + "type": "ListingMediaContentType!" + }, + { + "description": "Declared file size in bytes. Rejected if it exceeds the role cap before issuing the presigned URL.", + "name": "fileSizeBytes", + "required": true, + "type": "Int!" + }, + { + "description": "Original filename from the client.", + "name": "filename", + "required": true, + "type": "String!" + }, + { + "description": "Merchandising role for this asset.", + "name": "role", + "required": true, + "type": "ListingMediaRole!" + }, + { + "description": "Optional ordering for screenshots/videos (0–7). Defaults to next slot. Ignored for other roles (defaults to 0).", + "name": "sortOrder", + "required": false, + "type": "Int" + } + ], + "deprecated": false, + "description": "Create a pending listing media record and return a pre-signed S3 PUT URL.", + "kind": "mutation", + "name": "createMediaUploadUrl", + "returnType": "CreateMediaUploadUrlResponse" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationCreateReleaseInput!" + } + ], + "deprecated": false, + "description": "Create a new release for an application.", + "kind": "mutation", + "name": "createRelease", + "returnType": "ApplicationRelease" + }, + { + "args": [ + { + "description": "The ID of the application to deactivate.", + "name": "id", + "required": true, + "type": "String!" + } + ], + "deprecated": false, + "description": "Deactivate an application through its lifecycle operation. Activate it again by activating a release.", + "kind": "mutation", + "name": "deactivateApplication", + "returnType": "Application" + }, + { + "args": [ + { + "description": "The ID of the application.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The ID of the release to deactivate.", + "name": "releaseId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Deactivate a release. When it is the last active release, its application listing is also deactivated.", + "kind": "mutation", + "name": "deactivateRelease", + "returnType": "ApplicationRelease" + }, + { + "args": [ + { + "description": "The ID of the application.", + "name": "applicationId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Soft-delete the listing for an application.", + "kind": "mutation", + "name": "deleteApplicationListing", + "returnType": "Boolean" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationDisableCustomerApplicationInput!" + } + ], + "deprecated": false, + "description": "Disable an application on a customer", + "kind": "mutation", + "name": "disableCustomerApplication", + "returnType": "Application" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationDisableStoreApplicationInput!" + } + ], + "deprecated": false, + "description": "Disable an application in a store", + "kind": "mutation", + "name": "disableStoreApplication", + "returnType": "Application" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationEnableCustomerApplicationInput!" + } + ], + "deprecated": false, + "description": "Enable an application on a customer", + "kind": "mutation", + "name": "enableCustomerApplication", + "returnType": "Application" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationEnableStoreApplicationInput!" + } + ], + "deprecated": false, + "description": "Enable an application on a store", + "kind": "mutation", + "name": "enableStoreApplication", + "returnType": "Application" + }, + { + "args": [ + { + "name": "input", + "required": true, + "type": "MutationGenerateReleaseUploadUrlInput!" + } + ], + "deprecated": false, + "description": "Generate a pre-signed URL for uploading UI extension artifacts.", + "kind": "mutation", + "name": "generateReleaseUploadUrl", + "returnType": "GenerateReleaseUploadUrlResponse" + }, + { + "args": [ + { + "description": "The application that owns the media object — prevents cross-app removal.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The confirmed media object ID to remove.", + "name": "mediaObjectId", + "required": true, + "type": "ID!" + } + ], + "deprecated": false, + "description": "Remove a confirmed listing media object. Sets status to REMOVED, deletes the promoted S3 object, and invalidates the CDN path.", + "kind": "mutation", + "name": "removeListingMediaObject", + "returnType": "MediaObject" + }, + { + "args": [ + { + "description": "The ID of the application.", + "name": "applicationId", + "required": true, + "type": "ID!" + }, + { + "description": "The listing fields to create or update.", + "name": "input", + "required": true, + "type": "ApplicationListingInput!" + } + ], + "deprecated": false, + "description": "Create or update the listing for an application. Idempotent — safe to call multiple times for the same application.", + "kind": "mutation", + "name": "setApplicationListing", + "returnType": "ApplicationListing" + }, + { + "args": [ + { + "name": "id", + "required": true, + "type": "String!" + }, + { + "name": "input", + "required": true, + "type": "MutationUpdateApplicationInput!" + } + ], + "deprecated": false, + "description": "Update an existing Application.", + "kind": "mutation", + "name": "updateApplication", + "returnType": "Application" + } + ], + "schemaRef": "./schema.graphql", + "sdl": "type Application {\n \"\"\"The timestamp of when the Application was activated.\"\"\"\n activatedAt: DateTime\n\n \"\"\"The timestamp of when the Application was archived.\"\"\"\n archivedAt: DateTime\n\n \"\"\"\n The authorization scopes that the OAuth2 client has access to and will use to call API endpoints.\n \"\"\"\n authorizationScopes: [String!]!\n\n \"\"\"\n Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps).\n \"\"\"\n clientId: ID\n\n \"\"\"\n The primary OAuth client secret for this application. Only returned on application creation.\n \"\"\"\n clientSecret: String\n\n \"\"\"The timestamp of when the Application was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The timestamp of when the Application was deactivated.\"\"\"\n deactivatedAt: DateTime\n\n \"\"\"General information about your application.\"\"\"\n description: String\n\n \"\"\"The distribution type for this application.\"\"\"\n distributionType: DistributionType!\n\n \"\"\"The globally-unique ID of the Application.\"\"\"\n id: ID!\n\n \"\"\"A human-readable label of the Application for display purposes.\"\"\"\n label: String!\n\n \"\"\"\n Paginated listings for this application. Requires read scope. When using the filter, `applicationIds` is ignored — results are always scoped to this application.\n \"\"\"\n listings(\n after: String\n before: String\n\n \"\"\"Optional filter for the listings.\"\"\"\n filter: ApplicationListingFilterInput\n first: Int\n last: Int\n ): ApplicationListingsConnection\n\n \"\"\"A url-friendly name for the Application.\"\"\"\n name: String!\n\n \"\"\"The ID of the organization that owns this Application.\"\"\"\n organizationId: ID!\n\n \"\"\"\n The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions.\n \"\"\"\n proxyUrl: String\n\n \"\"\"\n The public key (base64 encoded) for verifying action requests to your application.\n \"\"\"\n publicKey: String\n\n \"\"\"\n Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update.\n \"\"\"\n redirectUris: [String!]!\n\n \"\"\"The releases of the Application.\"\"\"\n releases(\n after: String\n before: String\n first: Int\n last: Int\n\n \"\"\"The order in which to sort the releases.\"\"\"\n orderBy: ReleaseOrderBy\n\n \"\"\"Filter by release status.\"\"\"\n status: ReleaseStatusFilter\n ): ApplicationReleasesConnection\n\n \"\"\"\n The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again.\n \"\"\"\n secret: String\n\n \"\"\"The status of the application.\"\"\"\n status: ApplicationStatus!\n\n \"\"\"The timestamp of when the Application was updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"The public website of the application.\"\"\"\n url: String\n}\n\n\"\"\"An action that can be executed by an application within the platform.\"\"\"\ntype ApplicationAction {\n \"\"\"The timestamp of when the action was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The globally-unique ID of the action.\"\"\"\n id: String!\n\n \"\"\"A unique identifier for the action within the application.\"\"\"\n name: String!\n\n \"\"\"The timestamp of when the action was last updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"The endpoint URL that will be called when this action is triggered.\"\"\"\n url: String!\n}\n\n\"\"\"Input for creating a new application action.\"\"\"\ninput ApplicationActionCreateInput {\n \"\"\"A unique identifier for the action within the application.\"\"\"\n name: String!\n\n \"\"\"\n The path of the endpoint URL that will be called when this action is triggered.\n \"\"\"\n url: String!\n}\n\n\"\"\"\nA dependency an application release declares on another application (the application's id, the name it was declared under, and its required/excluded/enabled state).\n\"\"\"\ntype ApplicationDependency {\n \"\"\"\n The immutable id of the application depended upon. Unaffected by renames of that application.\n \"\"\"\n applicationId: ID!\n\n \"\"\"\n The application name this dependency was declared under. A record of what was declared, not a live reference — it is not rewritten when the depended-upon application is renamed, so it can differ from that application's current name. Use `applicationId` to identify the application.\n \"\"\"\n name: String!\n\n \"\"\"The declared state for this application dependency.\"\"\"\n state: ApplicationDependencyState!\n}\n\n\"\"\"\nInput for declaring a dependency on another application from a release.\n\"\"\"\ninput ApplicationDependencyInput {\n \"\"\"\n The name of the application depended upon. Must match a non-archived application.\n \"\"\"\n name: String!\n\n \"\"\"The declared state for this application dependency.\"\"\"\n state: ApplicationDependencyState!\n}\n\n\"\"\"The state of an application dependency declaration.\"\"\"\nenum ApplicationDependencyState {\n \"\"\"\n The dependency application is enabled by default but can be overridden.\n \"\"\"\n ENABLED\n\n \"\"\"\n The dependency application is excluded and must not be enabled alongside this release.\n \"\"\"\n EXCLUDED\n\n \"\"\"\n The dependency application is required and cannot be disabled while this release is live.\n \"\"\"\n REQUIRED\n}\n\n\"\"\"\nAn enablement represents an application installed for a specific entity (e.g. a store).\n\"\"\"\ntype ApplicationEnablement {\n \"\"\"When the enablement was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"When the application was disabled.\"\"\"\n disabledAt: DateTime\n\n \"\"\"When the application was enabled.\"\"\"\n enabledAt: DateTime\n\n \"\"\"The ID of the entity this application is enabled for.\"\"\"\n entityId: String!\n\n \"\"\"The type of entity (e.g. STORE).\"\"\"\n entityType: String!\n\n \"\"\"The globally-unique ID of the enablement.\"\"\"\n id: String!\n\n \"\"\"When the enablement was last updated.\"\"\"\n updatedAt: DateTime!\n}\n\n\"\"\"A listing for an application.\"\"\"\ntype ApplicationListing {\n \"\"\"The ID of the application this listing belongs to.\"\"\"\n applicationId: ID!\n\n \"\"\"The timestamp when the listing was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"\n Taxonomy facets for this listing (region, industry, category, tag, etc.).\n \"\"\"\n facets: [ApplicationListingFacet!]!\n\n \"\"\"CDN URL of the confirmed HIGHLIGHT asset, or null if not uploaded.\"\"\"\n highlightImageUrl: String\n\n \"\"\"CDN URL of the confirmed ICON asset, or null if not uploaded.\"\"\"\n iconUrl: String\n\n \"\"\"The globally-unique ID of the listing.\"\"\"\n id: ID!\n\n \"\"\"A description for the listing (plain text, max 4000 chars).\"\"\"\n listingDescription: String\n\n \"\"\"HTTPS URL for the privacy policy. Null when not set.\"\"\"\n privacyPolicyUrl: String\n\n \"\"\"CDN URLs of confirmed SCREENSHOT assets, ordered by sort_order.\"\"\"\n screenshotUrls: [String!]!\n\n \"\"\"The lifecycle status of the listing.\"\"\"\n status: ListingStatus!\n\n \"\"\"A short marketing pitch for the listing (plain text, max 160 chars).\"\"\"\n tagline: String\n\n \"\"\"HTTPS URL for the terms and conditions. Null when not set.\"\"\"\n termsAndConditionsUrl: String\n\n \"\"\"The timestamp when the listing was last updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"CDN URLs of confirmed VIDEO assets, ordered by sort_order.\"\"\"\n videoUrls: [String!]!\n\n \"\"\"The visibility of the listing.\"\"\"\n visibility: ListingVisibility!\n}\n\n\"\"\"A taxonomy facet attached to a listing.\"\"\"\ntype ApplicationListingFacet {\n \"\"\"The facet namespace (e.g. 'region', 'industry', 'category', 'tag').\"\"\"\n namespace: String!\n\n \"\"\"The facet value (e.g. 'US', 'retail', 'ecommerce').\"\"\"\n value: String!\n}\n\n\"\"\"\nFilter listings by facet. Returns listings that either have no facets in the given namespace (unrestricted), or have at least one facet in that namespace matching any of the provided values.\n\"\"\"\ninput ApplicationListingFacetFilterInput {\n \"\"\"The facet namespace to filter on (e.g. 'region').\"\"\"\n namespace: String!\n\n \"\"\"\n Accepted facet values (OR semantics within this filter). An empty array is treated as a no-op for this facet filter, not as a filter that matches nothing.\n \"\"\"\n values: [String!]!\n}\n\n\"\"\"A namespace + value pair for a listing facet.\"\"\"\ninput ApplicationListingFacetInput {\n \"\"\"The facet namespace (e.g. 'region', 'industry', 'category', 'tag').\"\"\"\n namespace: String!\n\n \"\"\"The facet value.\"\"\"\n value: String!\n}\n\n\"\"\"Filter options for listing queries.\"\"\"\ninput ApplicationListingFilterInput {\n \"\"\"\n Restrict results to listings for these application IDs (max 50, duplicates ignored).\n \"\"\"\n applicationIds: [ID!]\n\n \"\"\"\n Filter by taxonomy facets. All provided filters must match (AND semantics across namespaces).\n \"\"\"\n facets: [ApplicationListingFacetFilterInput!]\n\n \"\"\"\n Filter by listing lifecycle status. Defaults to ACTIVE for public LISTED discovery. Pass [ACTIVE, INACTIVE] to include deactivated listings (e.g. My Apps by applicationIds).\n \"\"\"\n status: [ListingStatus!]\n\n \"\"\"\n Filter by visibility. Defaults to [LISTED] for public discovery. Pass [LISTED, UNLISTED, HIDDEN] to see all listings for an owner context.\n \"\"\"\n visibility: [ListingVisibility!]\n}\n\n\"\"\"Fields to create or update a listing.\"\"\"\ninput ApplicationListingInput {\n \"\"\"\n Taxonomy facets for the listing. When provided, replaces all existing facets atomically.\n \"\"\"\n facets: [ApplicationListingFacetInput!]\n\n \"\"\"A description for the listing (plain text, max 4000 chars).\"\"\"\n listingDescription: String\n\n \"\"\"\n HTTPS URL for the privacy policy (max 255 chars). Pass null or empty string to clear.\n \"\"\"\n privacyPolicyUrl: String\n\n \"\"\"\n The lifecycle status of the listing. Defaults to INACTIVE on create. ACTIVE requires listing description, privacy URL, terms URL, categories, industries, icon, and screenshots.\n \"\"\"\n status: ListingStatus\n\n \"\"\"A short marketing pitch (plain text, max 160 chars).\"\"\"\n tagline: String\n\n \"\"\"\n HTTPS URL for the terms and conditions (max 255 chars). Pass null or empty string to clear.\n \"\"\"\n termsAndConditionsUrl: String\n\n \"\"\"The visibility of the listing. Defaults to LISTED.\"\"\"\n visibility: ListingVisibility\n}\n\ntype ApplicationListingsConnection {\n edges: [ApplicationListingsConnectionEdge]\n pageInfo: PageInfo!\n}\n\ntype ApplicationListingsConnectionEdge {\n cursor: String!\n node: ApplicationListing\n}\n\n\"\"\"\nAn MCP (Model Context Protocol) server registered with an application release.\n\"\"\"\ntype ApplicationMcpServer {\n \"\"\"The timestamp of when the MCP server was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The globally-unique ID of the MCP server.\"\"\"\n id: String!\n\n \"\"\"Additional configuration for the MCP server.\"\"\"\n metadata: JSONObject\n\n \"\"\"A unique identifier for the MCP server within the release.\"\"\"\n name: String!\n\n \"\"\"The MCP protocol version supported by this server.\"\"\"\n protocolVersion: String\n\n \"\"\"The timestamp of when the MCP server was last updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"\n The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory').\n \"\"\"\n url: String!\n}\n\n\"\"\"Input for creating a new MCP server registration.\"\"\"\ninput ApplicationMcpServerCreateInput {\n \"\"\"\n Additional configuration for the MCP server (e.g., tool descriptions, capabilities).\n \"\"\"\n metadata: JSONObject\n\n \"\"\"A unique identifier for the MCP server within the release.\"\"\"\n name: String!\n\n \"\"\"The MCP protocol version supported by this server.\"\"\"\n protocolVersion: String\n\n \"\"\"\n The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory').\n \"\"\"\n url: String!\n}\n\n\"\"\"\nA native extension registered with an application release. The target platform is indicated by `platform`.\n\"\"\"\ntype ApplicationNativeExtension {\n \"\"\"The ID of the associated application.\"\"\"\n applicationId: String!\n\n \"\"\"Support contact for the native extension.\"\"\"\n contact: String @deprecated(reason: \"Support contact belongs at the application-registration level, not on an individual extension. Retained temporarily and will be removed once an application-level contact exists.\")\n\n \"\"\"The timestamp of when the native extension was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The globally-unique ID of the native extension.\"\"\"\n id: String!\n\n \"\"\"The display name of the native extension.\"\"\"\n name: String\n\n \"\"\"\n The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager.\n \"\"\"\n packageName: String\n\n \"\"\"The platform for this native extension.\"\"\"\n platform: NativeExtensionPlatform!\n\n \"\"\"The ID of the associated release.\"\"\"\n releaseId: String!\n\n \"\"\"The timestamp of when the native extension was last updated.\"\"\"\n updatedAt: DateTime!\n}\n\n\"\"\"Input for creating a new native extension.\"\"\"\ninput ApplicationNativeExtensionCreateInput {\n \"\"\"Support contact for the native extension.\"\"\"\n contact: String @deprecated(reason: \"Support contact belongs at the application-registration level, not on an individual extension. Retained temporarily and will be removed once an application-level contact exists.\")\n\n \"\"\"The display name of the native extension.\"\"\"\n name: String\n\n \"\"\"\n The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager.\n \"\"\"\n packageName: String\n\n \"\"\"The platform for this native extension.\"\"\"\n platform: NativeExtensionPlatform!\n}\n\n\"\"\"Fields by which applications can be ordered.\"\"\"\ninput ApplicationOrderBy {\n \"\"\"Order applications by creation date.\"\"\"\n createdAt: OrderByDirectionEnum\n\n \"\"\"Order applications by ID.\"\"\"\n id: OrderByDirectionEnum\n\n \"\"\"Order applications by name.\"\"\"\n name: OrderByDirectionEnum\n\n \"\"\"Order applications by last update date.\"\"\"\n updatedAt: OrderByDirectionEnum\n}\n\ntype ApplicationRelease {\n \"\"\"The actions registered with this release.\"\"\"\n actions: [ApplicationAction!]!\n\n \"\"\"The timestamp of when the Application was activated.\"\"\"\n activatedAt: DateTime\n\n \"\"\"\n The other applications this release depends on, and the state it declares for each.\n \"\"\"\n applicationDependencies: [ApplicationDependency!]!\n\n \"\"\"The timestamp of when the Application was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The timestamp of when the release was deactivated.\"\"\"\n deactivatedAt: DateTime\n\n \"\"\"\n Information about the new release. Provide information about what has changed and any implications that it might have on your users.\n \"\"\"\n description: String\n\n \"\"\"The feature dependencies declared by this release.\"\"\"\n featureDependencies: [FeatureDependency!]!\n\n \"\"\"The globally-unique ID of the release.\"\"\"\n id: String!\n\n \"\"\"The MCP servers registered with this release.\"\"\"\n mcpServers: [ApplicationMcpServer!]!\n\n \"\"\"The native extensions registered with this release.\"\"\"\n nativeExtensions: [ApplicationNativeExtension!]!\n\n \"\"\"The settings capabilities registered with this release.\"\"\"\n settings(\n \"\"\"Filter settings by Commerce settings group slug.\"\"\"\n groupSlug: String\n ): [ApplicationSetting!]!\n\n \"\"\"The lifecycle status of the release.\"\"\"\n status: ApplicationReleaseStatus!\n\n \"\"\"The webhook subscriptions registered with this release.\"\"\"\n subscriptions: [ApplicationSubscription!]!\n\n \"\"\"The UI extensions registered with this release.\"\"\"\n uiExtensions(\n \"\"\"\n Optional UI extension target filter. Supports has (single match) and hasAny (any match).\n \"\"\"\n target: StringListFilter\n ): [ApplicationUiExtension!]!\n\n \"\"\"The timestamp of when the Application was updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"The version number of this release.\"\"\"\n version: String!\n}\n\n\"\"\"The status of an application release.\"\"\"\nenum ApplicationReleaseStatus {\n \"\"\"Release is active and available for use.\"\"\"\n ACTIVE\n\n \"\"\"Release is created but not active.\"\"\"\n INACTIVE\n}\n\ntype ApplicationReleasesConnection {\n edges: [ApplicationReleasesConnectionEdge]\n pageInfo: PageInfo!\n}\n\ntype ApplicationReleasesConnectionEdge {\n cursor: String!\n node: ApplicationRelease\n}\n\n\"\"\"\nA settings capability registered with an application release and placed into a Commerce-owned settings group.\n\"\"\"\ntype ApplicationSetting {\n \"\"\"The app-owned slug for this setting registration.\"\"\"\n appSettingSlug: String!\n\n \"\"\"Lifecycle capabilities supported by this setting.\"\"\"\n capabilities: [String!]!\n\n \"\"\"The timestamp of when the setting was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"Display description for this setting.\"\"\"\n description: String\n\n \"\"\"Localization key for the display description.\"\"\"\n descriptionKey: String\n\n \"\"\"The GPA settings namespace path that lifecycle endpoints live beneath.\"\"\"\n entryPath: String!\n\n \"\"\"The Commerce-owned settings group slug.\"\"\"\n groupSlug: String!\n\n \"\"\"Optional icon library for display.\"\"\"\n iconLibrary: String\n\n \"\"\"Optional icon name for display.\"\"\"\n iconName: String\n\n \"\"\"The globally-unique ID of the application setting.\"\"\"\n id: String!\n\n \"\"\"Additional app-defined settings metadata.\"\"\"\n metadata: JSONObject\n\n \"\"\"Sort order within the settings group.\"\"\"\n order: Int!\n\n \"\"\"The client-renderable presentation contract for this setting.\"\"\"\n presentation: JSONObject!\n\n \"\"\"Display title for this setting.\"\"\"\n title: String\n\n \"\"\"Localization key for the display title.\"\"\"\n titleKey: String\n\n \"\"\"The timestamp of when the setting was last updated.\"\"\"\n updatedAt: DateTime!\n}\n\n\"\"\"Input for creating a new application settings registration.\"\"\"\ninput ApplicationSettingCreateInput {\n \"\"\"The app-owned slug for this setting registration.\"\"\"\n appSettingSlug: String!\n\n \"\"\"\n Lifecycle capabilities supported by this setting (read, write, validate, test, delete, open, config). config requires metadata.configKeys.\n \"\"\"\n capabilities: [String!]\n\n \"\"\"Display description for this setting.\"\"\"\n description: String\n\n \"\"\"Localization key for the display description.\"\"\"\n descriptionKey: String\n\n \"\"\"The GPA settings namespace path that lifecycle endpoints live beneath.\"\"\"\n entryPath: String!\n\n \"\"\"The Commerce-owned settings group slug.\"\"\"\n groupSlug: String!\n\n \"\"\"Optional icon library for display.\"\"\"\n iconLibrary: String\n\n \"\"\"Optional icon name for display.\"\"\"\n iconName: String\n\n \"\"\"Additional app-defined settings metadata.\"\"\"\n metadata: JSONObject\n\n \"\"\"Sort order within the settings group.\"\"\"\n order: Int\n\n \"\"\"\n The settings-form-v1 or settings-link-v1 presentation contract for this setting.\n \"\"\"\n presentation: JSONObject!\n\n \"\"\"Display title for this setting.\"\"\"\n title: String\n\n \"\"\"Localization key for the display title.\"\"\"\n titleKey: String\n}\n\n\"\"\"The current status of an application in the platform.\"\"\"\nenum ApplicationStatus {\n \"\"\"Application is active and can be used.\"\"\"\n ACTIVE\n\n \"\"\"Application has been archived and is no longer available.\"\"\"\n ARCHIVED\n\n \"\"\"Application has been blocked from use due to policy violations.\"\"\"\n BLOCKED\n\n \"\"\"Application is registered but not active.\"\"\"\n INACTIVE\n\n \"\"\"Application is being verified and is not yet active.\"\"\"\n VERIFYING\n}\n\n\"\"\"Filter applications by status.\"\"\"\ninput ApplicationStatusFilter {\n \"\"\"Filter by exact status match.\"\"\"\n eq: ApplicationStatus\n\n \"\"\"Filter by any of the specified statuses.\"\"\"\n in: [ApplicationStatus!]\n\n \"\"\"Filter by status not equal to the specified value.\"\"\"\n ne: ApplicationStatus\n}\n\n\"\"\"\nWebhook subscriptions that an application has opted to subscribe to. Each subscription has one or many events.\n\"\"\"\ntype ApplicationSubscription {\n \"\"\"The timestamp of when the Application was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"A list of events that the subscription will receive.\"\"\"\n events: [String!]!\n\n \"\"\"The globally-unique ID of the release.\"\"\"\n id: String!\n\n \"\"\"The name of the webhook subscription\"\"\"\n name: String!\n\n \"\"\"The timestamp of when the Application was updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"The delivery URL of the webhook.\"\"\"\n url: String!\n}\n\n\"\"\"Input for creating a new application webhook subscription.\"\"\"\ninput ApplicationSubscriptionCreateInput {\n \"\"\"List of event types this subscription will receive.\"\"\"\n events: [String!]!\n\n \"\"\"A unique name for the subscription.\"\"\"\n name: String!\n\n \"\"\"The webhook URL that will receive the events.\"\"\"\n url: String!\n}\n\n\"\"\"\nA UI extension registered with an application release (e.g., embed, checkout, blocks).\n\"\"\"\ntype ApplicationUiExtension {\n \"\"\"The base CDN URL for packaged UI extension assets.\"\"\"\n cdnUrl: String\n\n \"\"\"The timestamp of when the UI extension was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The unique handle/identifier for the extension (e.g., 'my-extension').\"\"\"\n handle: String\n\n \"\"\"The globally-unique ID of the UI extension.\"\"\"\n id: String!\n\n \"\"\"The display name of the extension.\"\"\"\n name: String\n\n \"\"\"\n The source file path for the extension (e.g., 'extensions/my-ext/index.ts').\n \"\"\"\n source: String\n\n \"\"\"\n The target location for the extension (e.g., 'body.end', 'checkout.header').\n \"\"\"\n target: String\n\n \"\"\"The type of UI extension (e.g., 'embed', 'checkout', 'blocks').\"\"\"\n type: String!\n\n \"\"\"The timestamp of when the UI extension was last updated.\"\"\"\n updatedAt: DateTime!\n}\n\n\"\"\"Input for creating a new application UI extension.\"\"\"\ninput ApplicationUiExtensionCreateInput {\n \"\"\"\n The unique handle/identifier for the extension (e.g., 'my-extension'). Optional for blocks extensions.\n \"\"\"\n handle: String\n\n \"\"\"The display name of the extension. Optional for blocks extensions.\"\"\"\n name: String\n\n \"\"\"\n The source file path for the extension (e.g., 'extensions/my-ext/index.ts'). Optional for blocks extensions.\n \"\"\"\n source: String\n\n \"\"\"\n The target location for the extension (e.g., 'body.end', 'checkout.header'). Required for targeted extensions, optional for block-type extensions.\n \"\"\"\n target: String\n\n \"\"\"The type of UI extension (e.g., 'embed', 'checkout', 'blocks').\"\"\"\n type: String!\n}\n\n\"\"\"Pre-signed upload URL and metadata for listing media.\"\"\"\ntype CreateMediaUploadUrlResponse {\n expiresAt: DateTime!\n maxSizeBytes: Int!\n mediaObjectId: ID!\n requiredHeaders: [String!]!\n uploadUrl: String!\n}\n\n\"\"\"A date-time string in ISO 8601 format (e.g. 2022-01-01T00:00:00Z).\"\"\"\nscalar DateTime\n\n\"\"\"The distribution type for an application.\"\"\"\nenum DistributionType {\n \"\"\"Application has custom distribution.\"\"\"\n CUSTOM\n\n \"\"\"Application is publicly available.\"\"\"\n PUBLIC\n}\n\ntype EnabledApplication {\n \"\"\"The timestamp of when the Application was activated.\"\"\"\n activatedAt: DateTime\n\n \"\"\"The timestamp of when the Application was archived.\"\"\"\n archivedAt: DateTime\n\n \"\"\"\n The authorization scopes that the OAuth2 client has access to and will use to call API endpoints.\n \"\"\"\n authorizationScopes: [String!]!\n\n \"\"\"\n Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps).\n \"\"\"\n clientId: ID\n\n \"\"\"\n The primary OAuth client secret for this application. Only returned on application creation.\n \"\"\"\n clientSecret: String\n\n \"\"\"The timestamp of when the Application was created.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The timestamp of when the Application was deactivated.\"\"\"\n deactivatedAt: DateTime\n\n \"\"\"General information about your application.\"\"\"\n description: String\n\n \"\"\"The globally-unique ID of the Application.\"\"\"\n id: ID!\n\n \"\"\"A human-readable label of the Application for display purposes.\"\"\"\n label: String!\n\n \"\"\"A url-friendly name for the Application.\"\"\"\n name: String!\n\n \"\"\"The ID of the organization that owns this Application.\"\"\"\n organizationId: ID!\n\n \"\"\"\n The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions.\n \"\"\"\n proxyUrl: String\n\n \"\"\"\n The public key (base64 encoded) for verifying action requests to your application.\n \"\"\"\n publicKey: String\n\n \"\"\"\n Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update.\n \"\"\"\n redirectUris: [String!]!\n\n \"\"\"The release/version enabled for this application\"\"\"\n release: ApplicationRelease\n\n \"\"\"\n The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again.\n \"\"\"\n secret: String\n\n \"\"\"The status of the application.\"\"\"\n status: ApplicationStatus!\n\n \"\"\"The timestamp of when the Application was updated.\"\"\"\n updatedAt: DateTime!\n\n \"\"\"The public website of the application.\"\"\"\n url: String\n}\n\n\"\"\"Fields by which enablements can be ordered.\"\"\"\ninput EnablementOrderBy {\n \"\"\"Order enablements by creation date.\"\"\"\n createdAt: OrderByDirectionEnum\n\n \"\"\"Order enablements by ID.\"\"\"\n id: OrderByDirectionEnum\n\n \"\"\"Order enablements by last update date.\"\"\"\n updatedAt: OrderByDirectionEnum\n}\n\n\"\"\"\nA feature dependency declared by an application release (a feature name and its required/excluded/enabled/disabled state).\n\"\"\"\ntype FeatureDependency {\n \"\"\"The feature name from the available features list.\"\"\"\n name: String!\n\n \"\"\"The declared state for this feature.\"\"\"\n state: FeatureDependencyState!\n}\n\n\"\"\"Input for declaring a feature dependency on a release.\"\"\"\ninput FeatureDependencyInput {\n \"\"\"The feature name from the available features list.\"\"\"\n name: String!\n\n \"\"\"The declared state for this feature.\"\"\"\n state: FeatureDependencyState!\n}\n\n\"\"\"The state of a feature dependency declaration.\"\"\"\nenum FeatureDependencyState {\n \"\"\"Feature is disabled by default but can be overridden.\"\"\"\n DISABLED\n\n \"\"\"Feature is enabled by default but can be overridden.\"\"\"\n ENABLED\n\n \"\"\"Feature is excluded and cannot be required by other apps.\"\"\"\n EXCLUDED\n\n \"\"\"Feature is required and cannot be excluded by other apps.\"\"\"\n REQUIRED\n}\n\n\"\"\"Response containing pre-signed upload URL and metadata.\"\"\"\ntype GenerateReleaseUploadUrlResponse {\n \"\"\"The timestamp when the upload URL expires.\"\"\"\n expiresAt: DateTime!\n\n \"\"\"The S3 key where the file should be uploaded.\"\"\"\n key: String!\n\n \"\"\"Maximum allowed file size in bytes.\"\"\"\n maxSizeBytes: Int!\n\n \"\"\"Required headers for the upload as key:value pairs.\"\"\"\n requiredHeaders: [String!]!\n\n \"\"\"The unique upload identifier for tracking.\"\"\"\n uploadId: String!\n\n \"\"\"The pre-signed URL for uploading the file.\"\"\"\n url: String!\n}\n\n\"\"\"A JSON object scalar type.\"\"\"\nscalar JSONObject\n\n\"\"\"Allowed MIME types for listing media uploads.\"\"\"\nenum ListingMediaContentType {\n IMAGE_JPEG\n IMAGE_PNG\n VIDEO_MP4\n}\n\n\"\"\"The merchandising role of a listing media asset.\"\"\"\nenum ListingMediaRole {\n HIGHLIGHT\n ICON\n SCREENSHOT\n VIDEO\n}\n\n\"\"\"The lifecycle status of a listing media upload.\"\"\"\nenum ListingMediaStatus {\n CONFIRMED\n FAILED\n PENDING\n REMOVED\n SUPERSEDED\n}\n\n\"\"\"The lifecycle status of a listing.\"\"\"\nenum ListingStatus {\n \"\"\"Listing is active and eligible for marketplace discovery.\"\"\"\n ACTIVE\n\n \"\"\"Listing is inactive until activation requirements are met.\"\"\"\n INACTIVE\n}\n\n\"\"\"The visibility of a listing.\"\"\"\nenum ListingVisibility {\n \"\"\"Listing is hidden from discovery surfaces.\"\"\"\n HIDDEN\n\n \"\"\"Listing is publicly visible.\"\"\"\n LISTED\n\n \"\"\"Listing is not shown in search.\"\"\"\n UNLISTED\n}\n\n\"\"\"A media asset attached to an application listing.\"\"\"\ntype MediaObject {\n applicationId: ID!\n contentType: String\n createdAt: DateTime!\n failureReason: String\n filename: String\n id: ID!\n listingId: ID\n role: ListingMediaRole\n sortOrder: Int!\n status: ListingMediaStatus!\n updatedAt: DateTime!\n url: String\n}\n\ntype Mutation {\n \"\"\"\n Activate a release and, when permitted, promote its parent application to ACTIVE.\n \"\"\"\n activateRelease(\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n\n \"\"\"The ID of the release to activate.\"\"\"\n releaseId: ID!\n ): ApplicationRelease\n\n \"\"\"Archive an existing Application.\"\"\"\n archiveApplication(\n \"\"\"The ID of the application to archive.\"\"\"\n id: String!\n ): Application\n\n \"\"\"Confirm a listing media upload after the client PUTs the file to S3.\"\"\"\n confirmMediaObject(\n \"\"\"The application that owns the media object — used for authorization.\"\"\"\n applicationId: ID!\n\n \"\"\"The media object ID returned from createMediaUploadUrl.\"\"\"\n mediaObjectId: ID!\n ): MediaObject\n\n \"\"\"Create a new Application.\"\"\"\n createApplication(input: MutationCreateApplicationInput!): Application\n\n \"\"\"\n Create a pending listing media record and return a pre-signed S3 PUT URL.\n \"\"\"\n createMediaUploadUrl(\n \"\"\"The application that owns the listing (1:1).\"\"\"\n applicationId: ID!\n\n \"\"\"MIME type of the file to upload.\"\"\"\n contentType: ListingMediaContentType!\n\n \"\"\"\n Declared file size in bytes. Rejected if it exceeds the role cap before issuing the presigned URL.\n \"\"\"\n fileSizeBytes: Int!\n\n \"\"\"Original filename from the client.\"\"\"\n filename: String!\n\n \"\"\"Merchandising role for this asset.\"\"\"\n role: ListingMediaRole!\n\n \"\"\"\n Optional ordering for screenshots/videos (0–7). Defaults to next slot. Ignored for other roles (defaults to 0).\n \"\"\"\n sortOrder: Int\n ): CreateMediaUploadUrlResponse\n\n \"\"\"Create a new release for an application.\"\"\"\n createRelease(input: MutationCreateReleaseInput!): ApplicationRelease\n\n \"\"\"\n Deactivate an application through its lifecycle operation. Activate it again by activating a release.\n \"\"\"\n deactivateApplication(\n \"\"\"The ID of the application to deactivate.\"\"\"\n id: String!\n ): Application\n\n \"\"\"\n Deactivate a release. When it is the last active release, its application listing is also deactivated.\n \"\"\"\n deactivateRelease(\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n\n \"\"\"The ID of the release to deactivate.\"\"\"\n releaseId: ID!\n ): ApplicationRelease\n\n \"\"\"Soft-delete the listing for an application.\"\"\"\n deleteApplicationListing(\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n ): Boolean\n\n \"\"\"Disable an application on a customer\"\"\"\n disableCustomerApplication(input: MutationDisableCustomerApplicationInput!): Application\n\n \"\"\"Disable an application in a store\"\"\"\n disableStoreApplication(input: MutationDisableStoreApplicationInput!): Application\n\n \"\"\"Enable an application on a customer\"\"\"\n enableCustomerApplication(input: MutationEnableCustomerApplicationInput!): Application\n\n \"\"\"Enable an application on a store\"\"\"\n enableStoreApplication(input: MutationEnableStoreApplicationInput!): Application\n\n \"\"\"Generate a pre-signed URL for uploading UI extension artifacts.\"\"\"\n generateReleaseUploadUrl(input: MutationGenerateReleaseUploadUrlInput!): GenerateReleaseUploadUrlResponse\n\n \"\"\"\n Remove a confirmed listing media object. Sets status to REMOVED, deletes the promoted S3 object, and invalidates the CDN path.\n \"\"\"\n removeListingMediaObject(\n \"\"\"\n The application that owns the media object — prevents cross-app removal.\n \"\"\"\n applicationId: ID!\n\n \"\"\"The confirmed media object ID to remove.\"\"\"\n mediaObjectId: ID!\n ): MediaObject\n\n \"\"\"\n Create or update the listing for an application. Idempotent — safe to call multiple times for the same application.\n \"\"\"\n setApplicationListing(\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n\n \"\"\"The listing fields to create or update.\"\"\"\n input: ApplicationListingInput!\n ): ApplicationListing\n\n \"\"\"Update an existing Application.\"\"\"\n updateApplication(id: String!, input: MutationUpdateApplicationInput!): Application\n}\n\ninput MutationCreateApplicationInput {\n \"\"\"\n The authorization scopes of the application. Defines the GoDaddy permissions for the application.\n \"\"\"\n authorizationScopes: [String!]\n\n \"\"\"The description of the application.\"\"\"\n description: String\n\n \"\"\"The distribution type for the application. Defaults to PUBLIC.\"\"\"\n distributionType: DistributionType = PUBLIC\n\n \"\"\"The label of the application.\"\"\"\n label: String!\n\n \"\"\"The name of the application.\"\"\"\n name: String!\n\n \"\"\"\n Optional organization ID for multi-org users. If omitted, the organization is auto-resolved from the user's session. If provided, the user must be an admin of the specified organization.\n \"\"\"\n organizationId: String\n\n \"\"\"\n The proxy URL of the application. This is the base URL for all API endpoints.\n \"\"\"\n proxyUrl: String\n\n \"\"\"\n Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the application URL or its default callback are rejected; those defaults are registered automatically.\n \"\"\"\n redirectUris: [String!]\n\n \"\"\"\n The public URL of the application. This is typically the URL of the application's website.\n \"\"\"\n url: String\n}\n\ninput MutationCreateReleaseInput {\n \"\"\"The actions associated with the release.\"\"\"\n actions: [ApplicationActionCreateInput!]\n\n \"\"\"\n The other applications this release depends on, and the state it declares for each.\n \"\"\"\n applicationDependencies: [ApplicationDependencyInput!]\n\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n\n \"\"\"The description of the release.\"\"\"\n description: String\n\n \"\"\"The feature dependencies declared by the release.\"\"\"\n featureDependencies: [FeatureDependencyInput!]\n\n \"\"\"The MCP servers associated with the release.\"\"\"\n mcpServers: [ApplicationMcpServerCreateInput!]\n\n \"\"\"The native extensions associated with the release.\"\"\"\n nativeExtensions: [ApplicationNativeExtensionCreateInput!]\n\n \"\"\"The settings capabilities associated with the release.\"\"\"\n settings: [ApplicationSettingCreateInput!]\n\n \"\"\"The subscriptions associated with the release.\"\"\"\n subscriptions: [ApplicationSubscriptionCreateInput!]\n\n \"\"\"The UI extensions associated with the release.\"\"\"\n uiExtensions: [ApplicationUiExtensionCreateInput!]\n\n \"\"\"The version of the release.\"\"\"\n version: String!\n}\n\ninput MutationDisableCustomerApplicationInput {\n \"\"\"The name of the application to disable.\"\"\"\n applicationName: String!\n\n \"\"\"The ID of the customer to disable the application on.\"\"\"\n customerId: String!\n}\n\ninput MutationDisableStoreApplicationInput {\n \"\"\"The name of the application to disable.\"\"\"\n applicationName: String!\n\n \"\"\"The ID of the store to disable the application on.\"\"\"\n storeId: String!\n}\n\ninput MutationEnableCustomerApplicationInput {\n \"\"\"The name of the application to enable.\"\"\"\n applicationName: String!\n\n \"\"\"The ID of the customer to enable the application on.\"\"\"\n customerId: String!\n}\n\ninput MutationEnableStoreApplicationInput {\n \"\"\"The name of the application to enable.\"\"\"\n applicationName: String!\n\n \"\"\"The ID of the store to enable the application on.\"\"\"\n storeId: String!\n}\n\ninput MutationGenerateReleaseUploadUrlInput {\n \"\"\"The ID of the application.\"\"\"\n applicationId: ID!\n\n \"\"\"The content type of the file to be uploaded.\"\"\"\n contentType: UploadContentType!\n\n \"\"\"The ID of the release.\"\"\"\n releaseId: ID!\n\n \"\"\"The target for the upload.\"\"\"\n target: String\n}\n\ninput MutationUpdateApplicationInput {\n \"\"\"\n The authorization scopes of the application. Defines the GoDaddy permissions for the application.\n \"\"\"\n authorizationScopes: [String!]\n\n \"\"\"The description of the application.\"\"\"\n description: String\n\n \"\"\"\n The distribution type for the application (PUBLIC or CUSTOM). Omit to leave unchanged.\n \"\"\"\n distributionType: DistributionType\n\n \"\"\"The label of the application.\"\"\"\n label: String\n\n \"\"\"The name of the application.\"\"\"\n name: String\n\n \"\"\"\n The proxy URL of the application. This is the base URL for all API endpoints.\n \"\"\"\n proxyUrl: String\n\n \"\"\"\n Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the effective application URL or its default callback are rejected. On URL changes, both old defaults are replaced; submit any old URI explicitly to retain it as an extra.\n \"\"\"\n redirectUris: [String!]\n\n \"\"\"\n The public URL of the application. This is typically the URL of the application's website.\n \"\"\"\n url: String\n}\n\n\"\"\"The platform for a native extension.\"\"\"\nenum NativeExtensionPlatform {\n \"\"\"Android platform (APK distribution).\"\"\"\n ANDROID\n}\n\n\"\"\"Represents a null value.\"\"\"\nscalar Null\n\n\"\"\"Direction for ordering results in queries.\"\"\"\nenum OrderByDirectionEnum {\n \"\"\"Sort in ascending order.\"\"\"\n ASC\n\n \"\"\"Sort in descending order.\"\"\"\n DESC\n}\n\ntype PageInfo {\n endCursor: String\n hasNextPage: Boolean!\n hasPreviousPage: Boolean!\n startCursor: String\n}\n\ntype Query {\n \"\"\"Get a single application by ID or name.\"\"\"\n application(\n \"\"\"The OAuth client ID of the application to retrieve.\"\"\"\n clientId: String\n\n \"\"\"The unique ID of the application to retrieve.\"\"\"\n id: String\n\n \"\"\"The unique name of the application to retrieve.\"\"\"\n name: String\n ): Application\n\n \"\"\"\n Paginated listings. Defaults to publicly LISTED listings for active PUBLIC-distribution apps. Pass visibility filter to query owner/admin contexts.\n \"\"\"\n applicationListings(\n after: String\n before: String\n\n \"\"\"Filter options for the query.\"\"\"\n filter: ApplicationListingFilterInput\n first: Int\n last: Int\n ): QueryApplicationListingsConnection\n\n \"\"\"\n Get paginated applications. Returns all public applications for service requests, or only applications owned by the authenticated customer's organization. Multi-org users may pass an explicit `organizationId` to select which organization's apps to list.\n \"\"\"\n applications(\n after: String\n before: String\n first: Int\n last: Int\n\n \"\"\"The order in which to sort the applications.\"\"\"\n orderBy: ApplicationOrderBy\n\n \"\"\"\n Optional organization ID. When provided, lists apps for that specific organization. Customer tokens must be admin of the organization; service tokens may specify any organization.\n \"\"\"\n organizationId: String\n\n \"\"\"Filter by application status. Defaults to ACTIVE applications only.\"\"\"\n status: ApplicationStatusFilter\n ): QueryApplicationsConnection\n\n \"\"\"\n Get all applications enabled for an entity by entity ID and type, with optional filtering by actions, UI extensions, and settings.\n \"\"\"\n enabledApplications(\n \"\"\"\n Filter by action names. Supports has (single match) and hasAny (any match).\n \"\"\"\n actionNames: StringListFilter\n\n \"\"\"The ID of the entity to get enabled applications for.\"\"\"\n entityId: String!\n\n \"\"\"\n The type of the entity the applications are enabled on. Currently STORE.\n \"\"\"\n entityType: String!\n\n \"\"\"Filter by Commerce settings group slug.\"\"\"\n settingGroupSlug: String\n\n \"\"\"\n Filter by application setting slugs. Supports has (single match) and hasAny (any match).\n \"\"\"\n settingSlugs: StringListFilter\n\n \"\"\"\n Filter by UI extension targets. Supports has (single match) and hasAny (any match).\n \"\"\"\n uiExtensionTargets: StringListFilter\n\n \"\"\"\n Filter by UI extension types. Supports has (single match) and hasAny (any match).\n \"\"\"\n uiExtensionTypes: StringListFilter\n ): [EnabledApplication!]\n\n \"\"\"\n Get all applications enabled for a store by store ID, with optional filtering by actions, UI extensions, and settings.\n \"\"\"\n enabledStoreApplications(\n \"\"\"\n Filter by action names. Supports has (single match) and hasAny (any match).\n \"\"\"\n actionNames: StringListFilter\n\n \"\"\"Filter by Commerce settings group slug.\"\"\"\n settingGroupSlug: String\n\n \"\"\"\n Filter by application setting slugs. Supports has (single match) and hasAny (any match).\n \"\"\"\n settingSlugs: StringListFilter\n\n \"\"\"The ID of the store to get enabled applications for.\"\"\"\n storeId: String!\n\n \"\"\"\n Filter by UI extension targets. Supports has (single match) and hasAny (any match).\n \"\"\"\n uiExtensionTargets: StringListFilter\n\n \"\"\"\n Filter by UI extension types. Supports has (single match) and hasAny (any match).\n \"\"\"\n uiExtensionTypes: StringListFilter\n ): [EnabledApplication!]\n\n \"\"\"Returns a paginated list of enablements for a specific release.\"\"\"\n enablementsByRelease(\n after: String\n before: String\n first: Int\n last: Int\n\n \"\"\"The order in which to sort the enablements.\"\"\"\n orderBy: EnablementOrderBy\n\n \"\"\"The release ID to query enablements for.\"\"\"\n releaseId: ID!\n ): QueryEnablementsByReleaseConnection!\n\n \"\"\"Get the status of a release upload by upload ID.\"\"\"\n getReleaseUpload(\n \"\"\"The application ID to verify authorization.\"\"\"\n applicationId: ID!\n\n \"\"\"The unique upload identifier.\"\"\"\n uploadId: String!\n ): ReleaseUpload\n\n \"\"\"Get a single native extension by ID.\"\"\"\n nativeExtension(\n \"\"\"The ID of the native extension to retrieve.\"\"\"\n id: ID!\n ): ApplicationNativeExtension\n\n \"\"\"\n List native extensions by application or release. At least one of applicationId or releaseId must be provided.\n \"\"\"\n nativeExtensions(\n \"\"\"Filter by application ID.\"\"\"\n applicationId: ID\n\n \"\"\"Maximum number of results to return.\"\"\"\n limit: Int\n\n \"\"\"Filter by release ID.\"\"\"\n releaseId: ID\n ): [ApplicationNativeExtension!]!\n\n \"\"\"\n Get the resolved feature states for a store, after reconciling all active app declarations.\n \"\"\"\n storeFeatures(\n \"\"\"The ID of the store to resolve features for.\"\"\"\n storeId: String!\n ): [FeatureDependency!]!\n\n \"\"\"Returns webhook subscription IDs for a specific release/entity pair.\"\"\"\n webhookSubscriptionInstancesByRelease(\n \"\"\"The entity ID to scope the query to.\"\"\"\n entityId: ID!\n\n \"\"\"The entity type to scope the query to (e.g. STORE).\"\"\"\n entityType: String!\n\n \"\"\"The release ID to query webhook subscriptions for.\"\"\"\n releaseId: ID!\n ): [String!]!\n}\n\ntype QueryApplicationListingsConnection {\n edges: [QueryApplicationListingsConnectionEdge]\n pageInfo: PageInfo!\n}\n\ntype QueryApplicationListingsConnectionEdge {\n cursor: String!\n node: ApplicationListing\n}\n\ntype QueryApplicationsConnection {\n edges: [QueryApplicationsConnectionEdge]\n pageInfo: PageInfo!\n}\n\ntype QueryApplicationsConnectionEdge {\n cursor: String!\n node: Application\n}\n\ntype QueryEnablementsByReleaseConnection {\n edges: [QueryEnablementsByReleaseConnectionEdge]\n pageInfo: PageInfo!\n}\n\ntype QueryEnablementsByReleaseConnectionEdge {\n cursor: String!\n node: ApplicationEnablement\n}\n\n\"\"\"Fields by which application releases can be ordered.\"\"\"\ninput ReleaseOrderBy {\n \"\"\"Order releases by activation date.\"\"\"\n activatedAt: OrderByDirectionEnum\n\n \"\"\"Order releases by creation date.\"\"\"\n createdAt: OrderByDirectionEnum\n\n \"\"\"Order releases by ID.\"\"\"\n id: OrderByDirectionEnum\n\n \"\"\"Order releases by last update date.\"\"\"\n updatedAt: OrderByDirectionEnum\n\n \"\"\"Order releases by version number. Must be in {major}.{minor} format.\"\"\"\n version: OrderByDirectionEnum\n}\n\n\"\"\"Filter releases by status.\"\"\"\ninput ReleaseStatusFilter {\n \"\"\"Filter by exact status match.\"\"\"\n eq: ApplicationReleaseStatus\n\n \"\"\"Filter by any of the specified statuses.\"\"\"\n in: [ApplicationReleaseStatus!]\n\n \"\"\"Filter by status not equal to the specified value.\"\"\"\n ne: ApplicationReleaseStatus\n}\n\n\"\"\"A release upload record tracking the status of uploaded artifacts.\"\"\"\ntype ReleaseUpload {\n \"\"\"The ID of the associated application.\"\"\"\n applicationId: String!\n\n \"\"\"The timestamp when the upload was completed or failed.\"\"\"\n completedAt: DateTime\n\n \"\"\"The MIME type of the uploaded content.\"\"\"\n contentType: UploadContentType!\n\n \"\"\"The timestamp when the upload was requested.\"\"\"\n createdAt: DateTime!\n\n \"\"\"The reason for failure if status is FAILED.\"\"\"\n failureReason: String\n\n \"\"\"The globally-unique ID of the upload record.\"\"\"\n id: String!\n\n \"\"\"The ID of the associated release.\"\"\"\n releaseId: String!\n\n \"\"\"The size of the uploaded file in bytes.\"\"\"\n sizeBytes: Int\n\n \"\"\"The current status of the upload.\"\"\"\n status: ReleaseUploadStatus!\n\n \"\"\"The unique upload identifier used for correlation.\"\"\"\n uploadId: String!\n}\n\n\"\"\"The status of a release upload.\"\"\"\nenum ReleaseUploadStatus {\n \"\"\"Upload or processing failed.\"\"\"\n FAILED\n\n \"\"\"File has been processed and deployed successfully.\"\"\"\n PROCESSED\n\n \"\"\"Upload URL has been generated and is awaiting upload.\"\"\"\n REQUESTED\n\n \"\"\"File has been uploaded and is awaiting processing.\"\"\"\n UPLOADED\n}\n\n\"\"\"Filter for fields that are lists/sets of strings.\"\"\"\ninput StringListFilter {\n \"\"\"Field must contain this value.\"\"\"\n has: String\n\n \"\"\"Field must contain at least one of these values.\"\"\"\n hasAny: [String!]\n}\n\n\"\"\"Supported content types for release uploads.\"\"\"\nenum UploadContentType {\n \"\"\"JavaScript file\"\"\"\n JS\n\n \"\"\"TAR archive\"\"\"\n TAR\n\n \"\"\"ZIP archive\"\"\"\n ZIP\n}", + "types": [ + { + "fields": [ + { + "description": "The timestamp of when the Application was activated.", + "name": "activatedAt", + "type": "DateTime" + }, + { + "description": "The timestamp of when the Application was archived.", + "name": "archivedAt", + "type": "DateTime" + }, + { + "description": "The authorization scopes that the OAuth2 client has access to and will use to call API endpoints.", + "name": "authorizationScopes", + "type": "[String!]!" + }, + { + "description": "Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps).", + "name": "clientId", + "type": "ID" + }, + { + "description": "The primary OAuth client secret for this application. Only returned on application creation.", + "name": "clientSecret", + "type": "String" + }, + { + "description": "The timestamp of when the Application was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The timestamp of when the Application was deactivated.", + "name": "deactivatedAt", + "type": "DateTime" + }, + { + "description": "General information about your application.", + "name": "description", + "type": "String" + }, + { + "description": "The distribution type for this application.", + "name": "distributionType", + "type": "DistributionType!" + }, + { + "description": "The globally-unique ID of the Application.", + "name": "id", + "type": "ID!" + }, + { + "description": "A human-readable label of the Application for display purposes.", + "name": "label", + "type": "String!" + }, + { + "description": "Paginated listings for this application. Requires read scope. When using the filter, `applicationIds` is ignored — results are always scoped to this application.", + "name": "listings", + "type": "ApplicationListingsConnection" + }, + { + "description": "A url-friendly name for the Application.", + "name": "name", + "type": "String!" + }, + { + "description": "The ID of the organization that owns this Application.", + "name": "organizationId", + "type": "ID!" + }, + { + "description": "The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions.", + "name": "proxyUrl", + "type": "String" + }, + { + "description": "The public key (base64 encoded) for verifying action requests to your application.", + "name": "publicKey", + "type": "String" + }, + { + "description": "Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update.", + "name": "redirectUris", + "type": "[String!]!" + }, + { + "description": "The releases of the Application.", + "name": "releases", + "type": "ApplicationReleasesConnection" + }, + { + "description": "The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again.", + "name": "secret", + "type": "String" + }, + { + "description": "The status of the application.", + "name": "status", + "type": "ApplicationStatus!" + }, + { + "description": "The timestamp of when the Application was updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The public website of the application.", + "name": "url", + "type": "String" + } + ], + "kind": "object", + "name": "Application" + }, + { + "fields": [ + { + "description": "The timestamp of when the action was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The globally-unique ID of the action.", + "name": "id", + "type": "String!" + }, + { + "description": "A unique identifier for the action within the application.", + "name": "name", + "type": "String!" + }, + { + "description": "The timestamp of when the action was last updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The endpoint URL that will be called when this action is triggered.", + "name": "url", + "type": "String!" + } + ], + "kind": "object", + "name": "ApplicationAction" + }, + { + "fields": [ + { + "description": "A unique identifier for the action within the application.", + "name": "name", + "type": "String!" + }, + { + "description": "The path of the endpoint URL that will be called when this action is triggered.", + "name": "url", + "type": "String!" + } + ], + "kind": "input", + "name": "ApplicationActionCreateInput" + }, + { + "fields": [ + { + "description": "The immutable id of the application depended upon. Unaffected by renames of that application.", + "name": "applicationId", + "type": "ID!" + }, + { + "description": "The application name this dependency was declared under. A record of what was declared, not a live reference — it is not rewritten when the depended-upon application is renamed, so it can differ from that application's current name. Use `applicationId` to identify the application.", + "name": "name", + "type": "String!" + }, + { + "description": "The declared state for this application dependency.", + "name": "state", + "type": "ApplicationDependencyState!" + } + ], + "kind": "object", + "name": "ApplicationDependency" + }, + { + "fields": [ + { + "description": "The name of the application depended upon. Must match a non-archived application.", + "name": "name", + "type": "String!" + }, + { + "description": "The declared state for this application dependency.", + "name": "state", + "type": "ApplicationDependencyState!" + } + ], + "kind": "input", + "name": "ApplicationDependencyInput" + }, + { + "kind": "enum", + "name": "ApplicationDependencyState", + "values": [ + "ENABLED", + "EXCLUDED", + "REQUIRED" + ] + }, + { + "fields": [ + { + "description": "When the enablement was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "When the application was disabled.", + "name": "disabledAt", + "type": "DateTime" + }, + { + "description": "When the application was enabled.", + "name": "enabledAt", + "type": "DateTime" + }, + { + "description": "The ID of the entity this application is enabled for.", + "name": "entityId", + "type": "String!" + }, + { + "description": "The type of entity (e.g. STORE).", + "name": "entityType", + "type": "String!" + }, + { + "description": "The globally-unique ID of the enablement.", + "name": "id", + "type": "String!" + }, + { + "description": "When the enablement was last updated.", + "name": "updatedAt", + "type": "DateTime!" + } + ], + "kind": "object", + "name": "ApplicationEnablement" + }, + { + "fields": [ + { + "description": "The ID of the application this listing belongs to.", + "name": "applicationId", + "type": "ID!" + }, + { + "description": "The timestamp when the listing was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "Taxonomy facets for this listing (region, industry, category, tag, etc.).", + "name": "facets", + "type": "[ApplicationListingFacet!]!" + }, + { + "description": "CDN URL of the confirmed HIGHLIGHT asset, or null if not uploaded.", + "name": "highlightImageUrl", + "type": "String" + }, + { + "description": "CDN URL of the confirmed ICON asset, or null if not uploaded.", + "name": "iconUrl", + "type": "String" + }, + { + "description": "The globally-unique ID of the listing.", + "name": "id", + "type": "ID!" + }, + { + "description": "A description for the listing (plain text, max 4000 chars).", + "name": "listingDescription", + "type": "String" + }, + { + "description": "HTTPS URL for the privacy policy. Null when not set.", + "name": "privacyPolicyUrl", + "type": "String" + }, + { + "description": "CDN URLs of confirmed SCREENSHOT assets, ordered by sort_order.", + "name": "screenshotUrls", + "type": "[String!]!" + }, + { + "description": "The lifecycle status of the listing.", + "name": "status", + "type": "ListingStatus!" + }, + { + "description": "A short marketing pitch for the listing (plain text, max 160 chars).", + "name": "tagline", + "type": "String" + }, + { + "description": "HTTPS URL for the terms and conditions. Null when not set.", + "name": "termsAndConditionsUrl", + "type": "String" + }, + { + "description": "The timestamp when the listing was last updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "CDN URLs of confirmed VIDEO assets, ordered by sort_order.", + "name": "videoUrls", + "type": "[String!]!" + }, + { + "description": "The visibility of the listing.", + "name": "visibility", + "type": "ListingVisibility!" + } + ], + "kind": "object", + "name": "ApplicationListing" + }, + { + "fields": [ + { + "description": "The facet namespace (e.g. 'region', 'industry', 'category', 'tag').", + "name": "namespace", + "type": "String!" + }, + { + "description": "The facet value (e.g. 'US', 'retail', 'ecommerce').", + "name": "value", + "type": "String!" + } + ], + "kind": "object", + "name": "ApplicationListingFacet" + }, + { + "fields": [ + { + "description": "The facet namespace to filter on (e.g. 'region').", + "name": "namespace", + "type": "String!" + }, + { + "description": "Accepted facet values (OR semantics within this filter). An empty array is treated as a no-op for this facet filter, not as a filter that matches nothing.", + "name": "values", + "type": "[String!]!" + } + ], + "kind": "input", + "name": "ApplicationListingFacetFilterInput" + }, + { + "fields": [ + { + "description": "The facet namespace (e.g. 'region', 'industry', 'category', 'tag').", + "name": "namespace", + "type": "String!" + }, + { + "description": "The facet value.", + "name": "value", + "type": "String!" + } + ], + "kind": "input", + "name": "ApplicationListingFacetInput" + }, + { + "fields": [ + { + "description": "Restrict results to listings for these application IDs (max 50, duplicates ignored).", + "name": "applicationIds", + "type": "[ID!]" + }, + { + "description": "Filter by taxonomy facets. All provided filters must match (AND semantics across namespaces).", + "name": "facets", + "type": "[ApplicationListingFacetFilterInput!]" + }, + { + "description": "Filter by listing lifecycle status. Defaults to ACTIVE for public LISTED discovery. Pass [ACTIVE, INACTIVE] to include deactivated listings (e.g. My Apps by applicationIds).", + "name": "status", + "type": "[ListingStatus!]" + }, + { + "description": "Filter by visibility. Defaults to [LISTED] for public discovery. Pass [LISTED, UNLISTED, HIDDEN] to see all listings for an owner context.", + "name": "visibility", + "type": "[ListingVisibility!]" + } + ], + "kind": "input", + "name": "ApplicationListingFilterInput" + }, + { + "fields": [ + { + "description": "Taxonomy facets for the listing. When provided, replaces all existing facets atomically.", + "name": "facets", + "type": "[ApplicationListingFacetInput!]" + }, + { + "description": "A description for the listing (plain text, max 4000 chars).", + "name": "listingDescription", + "type": "String" + }, + { + "description": "HTTPS URL for the privacy policy (max 255 chars). Pass null or empty string to clear.", + "name": "privacyPolicyUrl", + "type": "String" + }, + { + "description": "The lifecycle status of the listing. Defaults to INACTIVE on create. ACTIVE requires listing description, privacy URL, terms URL, categories, industries, icon, and screenshots.", + "name": "status", + "type": "ListingStatus" + }, + { + "description": "A short marketing pitch (plain text, max 160 chars).", + "name": "tagline", + "type": "String" + }, + { + "description": "HTTPS URL for the terms and conditions (max 255 chars). Pass null or empty string to clear.", + "name": "termsAndConditionsUrl", + "type": "String" + }, + { + "description": "The visibility of the listing. Defaults to LISTED.", + "name": "visibility", + "type": "ListingVisibility" + } + ], + "kind": "input", + "name": "ApplicationListingInput" + }, + { + "fields": [ + { + "name": "edges", + "type": "[ApplicationListingsConnectionEdge]" + }, + { + "name": "pageInfo", + "type": "PageInfo!" + } + ], + "kind": "object", + "name": "ApplicationListingsConnection" + }, + { + "fields": [ + { + "name": "cursor", + "type": "String!" + }, + { + "name": "node", + "type": "ApplicationListing" + } + ], + "kind": "object", + "name": "ApplicationListingsConnectionEdge" + }, + { + "fields": [ + { + "description": "The timestamp of when the MCP server was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The globally-unique ID of the MCP server.", + "name": "id", + "type": "String!" + }, + { + "description": "Additional configuration for the MCP server.", + "name": "metadata", + "type": "JSONObject" + }, + { + "description": "A unique identifier for the MCP server within the release.", + "name": "name", + "type": "String!" + }, + { + "description": "The MCP protocol version supported by this server.", + "name": "protocolVersion", + "type": "String" + }, + { + "description": "The timestamp of when the MCP server was last updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory').", + "name": "url", + "type": "String!" + } + ], + "kind": "object", + "name": "ApplicationMcpServer" + }, + { + "fields": [ + { + "description": "Additional configuration for the MCP server (e.g., tool descriptions, capabilities).", + "name": "metadata", + "type": "JSONObject" + }, + { + "description": "A unique identifier for the MCP server within the release.", + "name": "name", + "type": "String!" + }, + { + "description": "The MCP protocol version supported by this server.", + "name": "protocolVersion", + "type": "String" + }, + { + "description": "The path of the endpoint URL that Traefik will route to (e.g., '/mcp/inventory').", + "name": "url", + "type": "String!" + } + ], + "kind": "input", + "name": "ApplicationMcpServerCreateInput" + }, + { + "fields": [ + { + "description": "The ID of the associated application.", + "name": "applicationId", + "type": "String!" + }, + { + "description": "Support contact for the native extension.", + "name": "contact", + "type": "String" + }, + { + "description": "The timestamp of when the native extension was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The globally-unique ID of the native extension.", + "name": "id", + "type": "String!" + }, + { + "description": "The display name of the native extension.", + "name": "name", + "type": "String" + }, + { + "description": "The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager.", + "name": "packageName", + "type": "String" + }, + { + "description": "The platform for this native extension.", + "name": "platform", + "type": "NativeExtensionPlatform!" + }, + { + "description": "The ID of the associated release.", + "name": "releaseId", + "type": "String!" + }, + { + "description": "The timestamp of when the native extension was last updated.", + "name": "updatedAt", + "type": "DateTime!" + } + ], + "kind": "object", + "name": "ApplicationNativeExtension" + }, + { + "fields": [ + { + "description": "Support contact for the native extension.", + "name": "contact", + "type": "String" + }, + { + "description": "The display name of the native extension.", + "name": "name", + "type": "String" + }, + { + "description": "The platform package identifier, in reverse-DNS form (e.g. 'com.example.app'). Optional: not every native deployment is installed through a package manager.", + "name": "packageName", + "type": "String" + }, + { + "description": "The platform for this native extension.", + "name": "platform", + "type": "NativeExtensionPlatform!" + } + ], + "kind": "input", + "name": "ApplicationNativeExtensionCreateInput" + }, + { + "fields": [ + { + "description": "Order applications by creation date.", + "name": "createdAt", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order applications by ID.", + "name": "id", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order applications by name.", + "name": "name", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order applications by last update date.", + "name": "updatedAt", + "type": "OrderByDirectionEnum" + } + ], + "kind": "input", + "name": "ApplicationOrderBy" + }, + { + "fields": [ + { + "description": "The actions registered with this release.", + "name": "actions", + "type": "[ApplicationAction!]!" + }, + { + "description": "The timestamp of when the Application was activated.", + "name": "activatedAt", + "type": "DateTime" + }, + { + "description": "The other applications this release depends on, and the state it declares for each.", + "name": "applicationDependencies", + "type": "[ApplicationDependency!]!" + }, + { + "description": "The timestamp of when the Application was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The timestamp of when the release was deactivated.", + "name": "deactivatedAt", + "type": "DateTime" + }, + { + "description": "Information about the new release. Provide information about what has changed and any implications that it might have on your users.", + "name": "description", + "type": "String" + }, + { + "description": "The feature dependencies declared by this release.", + "name": "featureDependencies", + "type": "[FeatureDependency!]!" + }, + { + "description": "The globally-unique ID of the release.", + "name": "id", + "type": "String!" + }, + { + "description": "The MCP servers registered with this release.", + "name": "mcpServers", + "type": "[ApplicationMcpServer!]!" + }, + { + "description": "The native extensions registered with this release.", + "name": "nativeExtensions", + "type": "[ApplicationNativeExtension!]!" + }, + { + "description": "The settings capabilities registered with this release.", + "name": "settings", + "type": "[ApplicationSetting!]!" + }, + { + "description": "The lifecycle status of the release.", + "name": "status", + "type": "ApplicationReleaseStatus!" + }, + { + "description": "The webhook subscriptions registered with this release.", + "name": "subscriptions", + "type": "[ApplicationSubscription!]!" + }, + { + "description": "The UI extensions registered with this release.", + "name": "uiExtensions", + "type": "[ApplicationUiExtension!]!" + }, + { + "description": "The timestamp of when the Application was updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The version number of this release.", + "name": "version", + "type": "String!" + } + ], + "kind": "object", + "name": "ApplicationRelease" + }, + { + "kind": "enum", + "name": "ApplicationReleaseStatus", + "values": [ + "ACTIVE", + "INACTIVE" + ] + }, + { + "fields": [ + { + "name": "edges", + "type": "[ApplicationReleasesConnectionEdge]" + }, + { + "name": "pageInfo", + "type": "PageInfo!" + } + ], + "kind": "object", + "name": "ApplicationReleasesConnection" + }, + { + "fields": [ + { + "name": "cursor", + "type": "String!" + }, + { + "name": "node", + "type": "ApplicationRelease" + } + ], + "kind": "object", + "name": "ApplicationReleasesConnectionEdge" + }, + { + "fields": [ + { + "description": "The app-owned slug for this setting registration.", + "name": "appSettingSlug", + "type": "String!" + }, + { + "description": "Lifecycle capabilities supported by this setting.", + "name": "capabilities", + "type": "[String!]!" + }, + { + "description": "The timestamp of when the setting was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "Display description for this setting.", + "name": "description", + "type": "String" + }, + { + "description": "Localization key for the display description.", + "name": "descriptionKey", + "type": "String" + }, + { + "description": "The GPA settings namespace path that lifecycle endpoints live beneath.", + "name": "entryPath", + "type": "String!" + }, + { + "description": "The Commerce-owned settings group slug.", + "name": "groupSlug", + "type": "String!" + }, + { + "description": "Optional icon library for display.", + "name": "iconLibrary", + "type": "String" + }, + { + "description": "Optional icon name for display.", + "name": "iconName", + "type": "String" + }, + { + "description": "The globally-unique ID of the application setting.", + "name": "id", + "type": "String!" + }, + { + "description": "Additional app-defined settings metadata.", + "name": "metadata", + "type": "JSONObject" + }, + { + "description": "Sort order within the settings group.", + "name": "order", + "type": "Int!" + }, + { + "description": "The client-renderable presentation contract for this setting.", + "name": "presentation", + "type": "JSONObject!" + }, + { + "description": "Display title for this setting.", + "name": "title", + "type": "String" + }, + { + "description": "Localization key for the display title.", + "name": "titleKey", + "type": "String" + }, + { + "description": "The timestamp of when the setting was last updated.", + "name": "updatedAt", + "type": "DateTime!" + } + ], + "kind": "object", + "name": "ApplicationSetting" + }, + { + "fields": [ + { + "description": "The app-owned slug for this setting registration.", + "name": "appSettingSlug", + "type": "String!" + }, + { + "description": "Lifecycle capabilities supported by this setting (read, write, validate, test, delete, open, config). config requires metadata.configKeys.", + "name": "capabilities", + "type": "[String!]" + }, + { + "description": "Display description for this setting.", + "name": "description", + "type": "String" + }, + { + "description": "Localization key for the display description.", + "name": "descriptionKey", + "type": "String" + }, + { + "description": "The GPA settings namespace path that lifecycle endpoints live beneath.", + "name": "entryPath", + "type": "String!" + }, + { + "description": "The Commerce-owned settings group slug.", + "name": "groupSlug", + "type": "String!" + }, + { + "description": "Optional icon library for display.", + "name": "iconLibrary", + "type": "String" + }, + { + "description": "Optional icon name for display.", + "name": "iconName", + "type": "String" + }, + { + "description": "Additional app-defined settings metadata.", + "name": "metadata", + "type": "JSONObject" + }, + { + "description": "Sort order within the settings group.", + "name": "order", + "type": "Int" + }, + { + "description": "The settings-form-v1 or settings-link-v1 presentation contract for this setting.", + "name": "presentation", + "type": "JSONObject!" + }, + { + "description": "Display title for this setting.", + "name": "title", + "type": "String" + }, + { + "description": "Localization key for the display title.", + "name": "titleKey", + "type": "String" + } + ], + "kind": "input", + "name": "ApplicationSettingCreateInput" + }, + { + "kind": "enum", + "name": "ApplicationStatus", + "values": [ + "ACTIVE", + "ARCHIVED", + "BLOCKED", + "INACTIVE", + "VERIFYING" + ] + }, + { + "fields": [ + { + "description": "Filter by exact status match.", + "name": "eq", + "type": "ApplicationStatus" + }, + { + "description": "Filter by any of the specified statuses.", + "name": "in", + "type": "[ApplicationStatus!]" + }, + { + "description": "Filter by status not equal to the specified value.", + "name": "ne", + "type": "ApplicationStatus" + } + ], + "kind": "input", + "name": "ApplicationStatusFilter" + }, + { + "fields": [ + { + "description": "The timestamp of when the Application was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "A list of events that the subscription will receive.", + "name": "events", + "type": "[String!]!" + }, + { + "description": "The globally-unique ID of the release.", + "name": "id", + "type": "String!" + }, + { + "description": "The name of the webhook subscription", + "name": "name", + "type": "String!" + }, + { + "description": "The timestamp of when the Application was updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The delivery URL of the webhook.", + "name": "url", + "type": "String!" + } + ], + "kind": "object", + "name": "ApplicationSubscription" + }, + { + "fields": [ + { + "description": "List of event types this subscription will receive.", + "name": "events", + "type": "[String!]!" + }, + { + "description": "A unique name for the subscription.", + "name": "name", + "type": "String!" + }, + { + "description": "The webhook URL that will receive the events.", + "name": "url", + "type": "String!" + } + ], + "kind": "input", + "name": "ApplicationSubscriptionCreateInput" + }, + { + "fields": [ + { + "description": "The base CDN URL for packaged UI extension assets.", + "name": "cdnUrl", + "type": "String" + }, + { + "description": "The timestamp of when the UI extension was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The unique handle/identifier for the extension (e.g., 'my-extension').", + "name": "handle", + "type": "String" + }, + { + "description": "The globally-unique ID of the UI extension.", + "name": "id", + "type": "String!" + }, + { + "description": "The display name of the extension.", + "name": "name", + "type": "String" + }, + { + "description": "The source file path for the extension (e.g., 'extensions/my-ext/index.ts').", + "name": "source", + "type": "String" + }, + { + "description": "The target location for the extension (e.g., 'body.end', 'checkout.header').", + "name": "target", + "type": "String" + }, + { + "description": "The type of UI extension (e.g., 'embed', 'checkout', 'blocks').", + "name": "type", + "type": "String!" + }, + { + "description": "The timestamp of when the UI extension was last updated.", + "name": "updatedAt", + "type": "DateTime!" + } + ], + "kind": "object", + "name": "ApplicationUiExtension" + }, + { + "fields": [ + { + "description": "The unique handle/identifier for the extension (e.g., 'my-extension'). Optional for blocks extensions.", + "name": "handle", + "type": "String" + }, + { + "description": "The display name of the extension. Optional for blocks extensions.", + "name": "name", + "type": "String" + }, + { + "description": "The source file path for the extension (e.g., 'extensions/my-ext/index.ts'). Optional for blocks extensions.", + "name": "source", + "type": "String" + }, + { + "description": "The target location for the extension (e.g., 'body.end', 'checkout.header'). Required for targeted extensions, optional for block-type extensions.", + "name": "target", + "type": "String" + }, + { + "description": "The type of UI extension (e.g., 'embed', 'checkout', 'blocks').", + "name": "type", + "type": "String!" + } + ], + "kind": "input", + "name": "ApplicationUiExtensionCreateInput" + }, + { + "fields": [ + { + "name": "expiresAt", + "type": "DateTime!" + }, + { + "name": "maxSizeBytes", + "type": "Int!" + }, + { + "name": "mediaObjectId", + "type": "ID!" + }, + { + "name": "requiredHeaders", + "type": "[String!]!" + }, + { + "name": "uploadUrl", + "type": "String!" + } + ], + "kind": "object", + "name": "CreateMediaUploadUrlResponse" + }, + { + "kind": "enum", + "name": "DistributionType", + "values": [ + "CUSTOM", + "PUBLIC" + ] + }, + { + "fields": [ + { + "description": "The timestamp of when the Application was activated.", + "name": "activatedAt", + "type": "DateTime" + }, + { + "description": "The timestamp of when the Application was archived.", + "name": "archivedAt", + "type": "DateTime" + }, + { + "description": "The authorization scopes that the OAuth2 client has access to and will use to call API endpoints.", + "name": "authorizationScopes", + "type": "[String!]!" + }, + { + "description": "Earliest linked OAuth client ID for this application by createdAt then id (includes NULL-secret and ARCHIVED apps).", + "name": "clientId", + "type": "ID" + }, + { + "description": "The primary OAuth client secret for this application. Only returned on application creation.", + "name": "clientSecret", + "type": "String" + }, + { + "description": "The timestamp of when the Application was created.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The timestamp of when the Application was deactivated.", + "name": "deactivatedAt", + "type": "DateTime" + }, + { + "description": "General information about your application.", + "name": "description", + "type": "String" + }, + { + "description": "The globally-unique ID of the Application.", + "name": "id", + "type": "ID!" + }, + { + "description": "A human-readable label of the Application for display purposes.", + "name": "label", + "type": "String!" + }, + { + "description": "A url-friendly name for the Application.", + "name": "name", + "type": "String!" + }, + { + "description": "The ID of the organization that owns this Application.", + "name": "organizationId", + "type": "ID!" + }, + { + "description": "The base URL of your application. This is the endpoint that the GoDaddy Commerce platform will call when integrated with actions.", + "name": "proxyUrl", + "type": "String" + }, + { + "description": "The public key (base64 encoded) for verifying action requests to your application.", + "name": "publicKey", + "type": "String" + }, + { + "description": "Additional OAuth redirect URIs on the application's OAuth client(s). URL-derived defaults (the application URL and {url}/api/godaddy/callback) are omitted so this list can be submitted unchanged on create or update.", + "name": "redirectUris", + "type": "[String!]!" + }, + { + "description": "The release/version enabled for this application", + "name": "release", + "type": "ApplicationRelease" + }, + { + "description": "The secret for verifying webhook requests to your application. Copy and secure this secret. You will not see it again.", + "name": "secret", + "type": "String" + }, + { + "description": "The status of the application.", + "name": "status", + "type": "ApplicationStatus!" + }, + { + "description": "The timestamp of when the Application was updated.", + "name": "updatedAt", + "type": "DateTime!" + }, + { + "description": "The public website of the application.", + "name": "url", + "type": "String" + } + ], + "kind": "object", + "name": "EnabledApplication" + }, + { + "fields": [ + { + "description": "Order enablements by creation date.", + "name": "createdAt", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order enablements by ID.", + "name": "id", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order enablements by last update date.", + "name": "updatedAt", + "type": "OrderByDirectionEnum" + } + ], + "kind": "input", + "name": "EnablementOrderBy" + }, + { + "fields": [ + { + "description": "The feature name from the available features list.", + "name": "name", + "type": "String!" + }, + { + "description": "The declared state for this feature.", + "name": "state", + "type": "FeatureDependencyState!" + } + ], + "kind": "object", + "name": "FeatureDependency" + }, + { + "fields": [ + { + "description": "The feature name from the available features list.", + "name": "name", + "type": "String!" + }, + { + "description": "The declared state for this feature.", + "name": "state", + "type": "FeatureDependencyState!" + } + ], + "kind": "input", + "name": "FeatureDependencyInput" + }, + { + "kind": "enum", + "name": "FeatureDependencyState", + "values": [ + "DISABLED", + "ENABLED", + "EXCLUDED", + "REQUIRED" + ] + }, + { + "fields": [ + { + "description": "The timestamp when the upload URL expires.", + "name": "expiresAt", + "type": "DateTime!" + }, + { + "description": "The S3 key where the file should be uploaded.", + "name": "key", + "type": "String!" + }, + { + "description": "Maximum allowed file size in bytes.", + "name": "maxSizeBytes", + "type": "Int!" + }, + { + "description": "Required headers for the upload as key:value pairs.", + "name": "requiredHeaders", + "type": "[String!]!" + }, + { + "description": "The unique upload identifier for tracking.", + "name": "uploadId", + "type": "String!" + }, + { + "description": "The pre-signed URL for uploading the file.", + "name": "url", + "type": "String!" + } + ], + "kind": "object", + "name": "GenerateReleaseUploadUrlResponse" + }, + { + "kind": "enum", + "name": "ListingMediaContentType", + "values": [ + "IMAGE_JPEG", + "IMAGE_PNG", + "VIDEO_MP4" + ] + }, + { + "kind": "enum", + "name": "ListingMediaRole", + "values": [ + "HIGHLIGHT", + "ICON", + "SCREENSHOT", + "VIDEO" + ] + }, + { + "kind": "enum", + "name": "ListingMediaStatus", + "values": [ + "CONFIRMED", + "FAILED", + "PENDING", + "REMOVED", + "SUPERSEDED" + ] + }, + { + "kind": "enum", + "name": "ListingStatus", + "values": [ + "ACTIVE", + "INACTIVE" + ] + }, + { + "kind": "enum", + "name": "ListingVisibility", + "values": [ + "HIDDEN", + "LISTED", + "UNLISTED" + ] + }, + { + "fields": [ + { + "name": "applicationId", + "type": "ID!" + }, + { + "name": "contentType", + "type": "String" + }, + { + "name": "createdAt", + "type": "DateTime!" + }, + { + "name": "failureReason", + "type": "String" + }, + { + "name": "filename", + "type": "String" + }, + { + "name": "id", + "type": "ID!" + }, + { + "name": "listingId", + "type": "ID" + }, + { + "name": "role", + "type": "ListingMediaRole" + }, + { + "name": "sortOrder", + "type": "Int!" + }, + { + "name": "status", + "type": "ListingMediaStatus!" + }, + { + "name": "updatedAt", + "type": "DateTime!" + }, + { + "name": "url", + "type": "String" + } + ], + "kind": "object", + "name": "MediaObject" + }, + { + "fields": [ + { + "description": "The authorization scopes of the application. Defines the GoDaddy permissions for the application.", + "name": "authorizationScopes", + "type": "[String!]" + }, + { + "description": "The description of the application.", + "name": "description", + "type": "String" + }, + { + "description": "The distribution type for the application. Defaults to PUBLIC.", + "name": "distributionType", + "type": "DistributionType" + }, + { + "description": "The label of the application.", + "name": "label", + "type": "String!" + }, + { + "description": "The name of the application.", + "name": "name", + "type": "String!" + }, + { + "description": "Optional organization ID for multi-org users. If omitted, the organization is auto-resolved from the user's session. If provided, the user must be an admin of the specified organization.", + "name": "organizationId", + "type": "String" + }, + { + "description": "The proxy URL of the application. This is the base URL for all API endpoints.", + "name": "proxyUrl", + "type": "String" + }, + { + "description": "Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the application URL or its default callback are rejected; those defaults are registered automatically.", + "name": "redirectUris", + "type": "[String!]" + }, + { + "description": "The public URL of the application. This is typically the URL of the application's website.", + "name": "url", + "type": "String" + } + ], + "kind": "input", + "name": "MutationCreateApplicationInput" + }, + { + "fields": [ + { + "description": "The actions associated with the release.", + "name": "actions", + "type": "[ApplicationActionCreateInput!]" + }, + { + "description": "The other applications this release depends on, and the state it declares for each.", + "name": "applicationDependencies", + "type": "[ApplicationDependencyInput!]" + }, + { + "description": "The ID of the application.", + "name": "applicationId", + "type": "ID!" + }, + { + "description": "The description of the release.", + "name": "description", + "type": "String" + }, + { + "description": "The feature dependencies declared by the release.", + "name": "featureDependencies", + "type": "[FeatureDependencyInput!]" + }, + { + "description": "The MCP servers associated with the release.", + "name": "mcpServers", + "type": "[ApplicationMcpServerCreateInput!]" + }, + { + "description": "The native extensions associated with the release.", + "name": "nativeExtensions", + "type": "[ApplicationNativeExtensionCreateInput!]" + }, + { + "description": "The settings capabilities associated with the release.", + "name": "settings", + "type": "[ApplicationSettingCreateInput!]" + }, + { + "description": "The subscriptions associated with the release.", + "name": "subscriptions", + "type": "[ApplicationSubscriptionCreateInput!]" + }, + { + "description": "The UI extensions associated with the release.", + "name": "uiExtensions", + "type": "[ApplicationUiExtensionCreateInput!]" + }, + { + "description": "The version of the release.", + "name": "version", + "type": "String!" + } + ], + "kind": "input", + "name": "MutationCreateReleaseInput" + }, + { + "fields": [ + { + "description": "The name of the application to disable.", + "name": "applicationName", + "type": "String!" + }, + { + "description": "The ID of the customer to disable the application on.", + "name": "customerId", + "type": "String!" + } + ], + "kind": "input", + "name": "MutationDisableCustomerApplicationInput" + }, + { + "fields": [ + { + "description": "The name of the application to disable.", + "name": "applicationName", + "type": "String!" + }, + { + "description": "The ID of the store to disable the application on.", + "name": "storeId", + "type": "String!" + } + ], + "kind": "input", + "name": "MutationDisableStoreApplicationInput" + }, + { + "fields": [ + { + "description": "The name of the application to enable.", + "name": "applicationName", + "type": "String!" + }, + { + "description": "The ID of the customer to enable the application on.", + "name": "customerId", + "type": "String!" + } + ], + "kind": "input", + "name": "MutationEnableCustomerApplicationInput" + }, + { + "fields": [ + { + "description": "The name of the application to enable.", + "name": "applicationName", + "type": "String!" + }, + { + "description": "The ID of the store to enable the application on.", + "name": "storeId", + "type": "String!" + } + ], + "kind": "input", + "name": "MutationEnableStoreApplicationInput" + }, + { + "fields": [ + { + "description": "The ID of the application.", + "name": "applicationId", + "type": "ID!" + }, + { + "description": "The content type of the file to be uploaded.", + "name": "contentType", + "type": "UploadContentType!" + }, + { + "description": "The ID of the release.", + "name": "releaseId", + "type": "ID!" + }, + { + "description": "The target for the upload.", + "name": "target", + "type": "String" + } + ], + "kind": "input", + "name": "MutationGenerateReleaseUploadUrlInput" + }, + { + "fields": [ + { + "description": "The authorization scopes of the application. Defines the GoDaddy permissions for the application.", + "name": "authorizationScopes", + "type": "[String!]" + }, + { + "description": "The description of the application.", + "name": "description", + "type": "String" + }, + { + "description": "The distribution type for the application (PUBLIC or CUSTOM). Omit to leave unchanged.", + "name": "distributionType", + "type": "DistributionType" + }, + { + "description": "The label of the application.", + "name": "label", + "type": "String" + }, + { + "description": "The name of the application.", + "name": "name", + "type": "String" + }, + { + "description": "The proxy URL of the application. This is the base URL for all API endpoints.", + "name": "proxyUrl", + "type": "String" + }, + { + "description": "Additional OAuth redirect URI allowlist. Supports up to 5 unique HTTPS URLs. Entries equal to the effective application URL or its default callback are rejected. On URL changes, both old defaults are replaced; submit any old URI explicitly to retain it as an extra.", + "name": "redirectUris", + "type": "[String!]" + }, + { + "description": "The public URL of the application. This is typically the URL of the application's website.", + "name": "url", + "type": "String" + } + ], + "kind": "input", + "name": "MutationUpdateApplicationInput" + }, + { + "kind": "enum", + "name": "NativeExtensionPlatform", + "values": [ + "ANDROID" + ] + }, + { + "kind": "enum", + "name": "OrderByDirectionEnum", + "values": [ + "ASC", + "DESC" + ] + }, + { + "fields": [ + { + "name": "endCursor", + "type": "String" + }, + { + "name": "hasNextPage", + "type": "Boolean!" + }, + { + "name": "hasPreviousPage", + "type": "Boolean!" + }, + { + "name": "startCursor", + "type": "String" + } + ], + "kind": "object", + "name": "PageInfo" + }, + { + "fields": [ + { + "name": "edges", + "type": "[QueryApplicationListingsConnectionEdge]" + }, + { + "name": "pageInfo", + "type": "PageInfo!" + } + ], + "kind": "object", + "name": "QueryApplicationListingsConnection" + }, + { + "fields": [ + { + "name": "cursor", + "type": "String!" + }, + { + "name": "node", + "type": "ApplicationListing" + } + ], + "kind": "object", + "name": "QueryApplicationListingsConnectionEdge" + }, + { + "fields": [ + { + "name": "edges", + "type": "[QueryApplicationsConnectionEdge]" + }, + { + "name": "pageInfo", + "type": "PageInfo!" + } + ], + "kind": "object", + "name": "QueryApplicationsConnection" + }, + { + "fields": [ + { + "name": "cursor", + "type": "String!" + }, + { + "name": "node", + "type": "Application" + } + ], + "kind": "object", + "name": "QueryApplicationsConnectionEdge" + }, + { + "fields": [ + { + "name": "edges", + "type": "[QueryEnablementsByReleaseConnectionEdge]" + }, + { + "name": "pageInfo", + "type": "PageInfo!" + } + ], + "kind": "object", + "name": "QueryEnablementsByReleaseConnection" + }, + { + "fields": [ + { + "name": "cursor", + "type": "String!" + }, + { + "name": "node", + "type": "ApplicationEnablement" + } + ], + "kind": "object", + "name": "QueryEnablementsByReleaseConnectionEdge" + }, + { + "fields": [ + { + "description": "Order releases by activation date.", + "name": "activatedAt", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order releases by creation date.", + "name": "createdAt", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order releases by ID.", + "name": "id", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order releases by last update date.", + "name": "updatedAt", + "type": "OrderByDirectionEnum" + }, + { + "description": "Order releases by version number. Must be in {major}.{minor} format.", + "name": "version", + "type": "OrderByDirectionEnum" + } + ], + "kind": "input", + "name": "ReleaseOrderBy" + }, + { + "fields": [ + { + "description": "Filter by exact status match.", + "name": "eq", + "type": "ApplicationReleaseStatus" + }, + { + "description": "Filter by any of the specified statuses.", + "name": "in", + "type": "[ApplicationReleaseStatus!]" + }, + { + "description": "Filter by status not equal to the specified value.", + "name": "ne", + "type": "ApplicationReleaseStatus" + } + ], + "kind": "input", + "name": "ReleaseStatusFilter" + }, + { + "fields": [ + { + "description": "The ID of the associated application.", + "name": "applicationId", + "type": "String!" + }, + { + "description": "The timestamp when the upload was completed or failed.", + "name": "completedAt", + "type": "DateTime" + }, + { + "description": "The MIME type of the uploaded content.", + "name": "contentType", + "type": "UploadContentType!" + }, + { + "description": "The timestamp when the upload was requested.", + "name": "createdAt", + "type": "DateTime!" + }, + { + "description": "The reason for failure if status is FAILED.", + "name": "failureReason", + "type": "String" + }, + { + "description": "The globally-unique ID of the upload record.", + "name": "id", + "type": "String!" + }, + { + "description": "The ID of the associated release.", + "name": "releaseId", + "type": "String!" + }, + { + "description": "The size of the uploaded file in bytes.", + "name": "sizeBytes", + "type": "Int" + }, + { + "description": "The current status of the upload.", + "name": "status", + "type": "ReleaseUploadStatus!" + }, + { + "description": "The unique upload identifier used for correlation.", + "name": "uploadId", + "type": "String!" + } + ], + "kind": "object", + "name": "ReleaseUpload" + }, + { + "kind": "enum", + "name": "ReleaseUploadStatus", + "values": [ + "FAILED", + "PROCESSED", + "REQUESTED", + "UPLOADED" + ] + }, + { + "fields": [ + { + "description": "Field must contain this value.", + "name": "has", + "type": "String" + }, + { + "description": "Field must contain at least one of these values.", + "name": "hasAny", + "type": "[String!]" + } + ], + "kind": "input", + "name": "StringListFilter" + }, + { + "kind": "enum", + "name": "UploadContentType", + "values": [ + "JS", + "TAR", + "ZIP" + ] + } + ] + }, + "method": "POST", + "operationId": "graphql", + "path": "/v1/apps/app-registry-subgraph", + "responses": { + "200": { + "description": "GraphQL response" + } + }, + "scopes": [], + "summary": "GraphQL API" + } + ], + "name": "app-registry", + "title": "app-registry GraphQL API", + "version": "570a114" +} \ No newline at end of file diff --git a/rust/schemas/api/manifest.json b/rust/schemas/api/manifest.json index 67553fa3..580c13a3 100644 --- a/rust/schemas/api/manifest.json +++ b/rust/schemas/api/manifest.json @@ -1,6 +1,11 @@ { - "generated": "2026-09-18T19:23:39.098256354+00:00", + "generated": "2026-09-18T22:12:16.502715618+00:00", "domains": { + "app-registry": { + "file": "app-registry.json", + "title": "app-registry GraphQL API", + "endpointCount": 1 + }, "bulk-operations": { "file": "bulk-operations.json", "title": "Bulk Operations API", diff --git a/rust/src/api/catalog.rs b/rust/src/api/catalog.rs index 3d8832cd..65013655 100644 --- a/rust/src/api/catalog.rs +++ b/rust/src/api/catalog.rs @@ -127,6 +127,10 @@ pub(super) struct GraphqlSchema { static CATALOG: OnceLock> = OnceLock::new(); const DOMAIN_FILES: &[(&str, &str)] = &[ + ( + "app-registry", + include_str!("../../schemas/api/app-registry.json"), + ), ( "bulk-operations", include_str!("../../schemas/api/bulk-operations.json"), @@ -596,6 +600,36 @@ pub(super) fn graphql_valid_arg_names(g: &GraphqlOpRef<'_>) -> Vec { mod tests { use super::{Domain, catalog, find_endpoint, locate_by_path}; + /// Every committed catalog file must be embedded, or that domain is + /// silently absent from `gddy api` (this is how `app-registry` was missed). + #[test] + fn every_committed_catalog_file_is_embedded() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/api"); + let mut on_disk: Vec = std::fs::read_dir(&dir) + .expect("read schemas/api") + .filter_map(|entry| { + let name = entry.ok()?.file_name().to_string_lossy().into_owned(); + let stem = name.strip_suffix(".json")?.to_owned(); + (stem != "manifest").then_some(stem) + }) + .collect(); + on_disk.sort(); + + let embedded: Vec<&str> = catalog().iter().map(|d| d.name.as_str()).collect(); + assert_eq!(on_disk, embedded, "schemas/api vs embedded DOMAIN_FILES"); + } + + #[test] + fn app_registry_is_served_from_its_subgraph_endpoint() { + let domain = catalog() + .iter() + .find(|d| d.name == "app-registry") + .expect("app-registry is in the catalog"); + assert_eq!(domain.base_url, "https://api.godaddy.com"); + assert_eq!(domain.endpoints.len(), 1); + assert_eq!(domain.endpoints[0].path, "/v1/apps/app-registry-subgraph"); + } + /// `catalog()` sorts once so every listing (`api domain list`, `api /// search`, `api operation get`) sees the same stable, alphabetical order. #[test] 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 d2105a58..c6c7a7bc 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 2288dc16..74ecb5d4 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 de81a09a..33febab6 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 4f2a7b2b..32855773 100644 --- a/rust/src/platform/app/commands/list.rs +++ b/rust/src/platform/app/commands/list.rs @@ -26,10 +26,14 @@ pub(super) fn command() -> RuntimeCommandSpec { }), |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!( diff --git a/rust/tools/generate-api-catalog/src/app_registry_spec.rs b/rust/tools/generate-api-catalog/src/app_registry_spec.rs new file mode 100644 index 00000000..26b7df62 --- /dev/null +++ b/rust/tools/generate-api-catalog/src/app_registry_spec.rs @@ -0,0 +1,31 @@ +//! Refreshes the vendored App Registry GraphQL schema used by +//! `platform-app-client`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +pub(crate) fn app_registry_client_schema_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../platform-app-client/graphql/schema.graphql") +} + +pub(crate) fn refresh(spec_path: &Path, out_path: &Path) -> Result<()> { + let sdl = std::fs::read_to_string(spec_path) + .with_context(|| format!("failed to read App Registry schema {}", spec_path.display()))?; + + // Fail fast on a malformed vendor pull rather than committing something + // `platform-app-client`'s build.rs can't parse either. + async_graphql_parser::parse_schema(&sdl) + .map_err(|e| anyhow::anyhow!("failed to parse App Registry schema: {e}"))?; + if !sdl.contains("type Query") { + bail!("App Registry schema has no `Query` type — source may have changed shape"); + } + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(out_path, &sdl).with_context(|| format!("write {}", out_path.display()))?; + eprintln!(" wrote {}", out_path.display()); + Ok(()) +} diff --git a/rust/tools/generate-api-catalog/src/github.rs b/rust/tools/generate-api-catalog/src/github.rs index f2713fd3..cf5b3415 100644 --- a/rust/tools/generate-api-catalog/src/github.rs +++ b/rust/tools/generate-api-catalog/src/github.rs @@ -35,6 +35,9 @@ pub(crate) struct SpecSource { pub(crate) spec_version: String, pub(crate) graphql_only: bool, pub(crate) catalog: bool, + /// Where a GraphQL-only source is served; always set when `graphql_only`. + pub(crate) graphql_base_url: Option, + pub(crate) graphql_endpoint_path: Option, } fn github_client() -> Result { @@ -67,12 +70,13 @@ fn github_client() -> Result { fn list_repos_for_owner_path( client: &reqwest::blocking::Client, owner_path: &str, + org: &str, ) -> Result> { let mut repos = Vec::new(); let mut page = 1u32; loop { let url = format!( - "{GITHUB_API_BASE}/{owner_path}/{GITHUB_ORG}/repos?per_page={GITHUB_PAGE_SIZE}&page={page}&type=public&sort=full_name&direction=asc" + "{GITHUB_API_BASE}/{owner_path}/{org}/repos?per_page={GITHUB_PAGE_SIZE}&page={page}&type=public&sort=full_name&direction=asc" ); let resp = client .get(&url) @@ -96,13 +100,13 @@ fn list_repos_for_owner_path( Ok(repos) } -fn list_org_repos(client: &reqwest::blocking::Client) -> Vec { - match list_repos_for_owner_path(client, "orgs") { +fn list_org_repos(client: &reqwest::blocking::Client, org: &str) -> Vec { + match list_repos_for_owner_path(client, "orgs", org) { Ok(repos) if !repos.is_empty() => return repos, Ok(_) => {} Err(e) => eprintln!("WARNING: GitHub orgs API failed: {e}"), } - match list_repos_for_owner_path(client, "users") { + match list_repos_for_owner_path(client, "users", org) { Ok(repos) => repos, Err(e) => { eprintln!("WARNING: GitHub users API also failed: {e}"); @@ -227,6 +231,29 @@ fn clone_repo(clone_url: &str, target: &Path, git_ref: Option<&str>) -> Result<( Ok(()) } +/// Short commit SHA `repo_dir` is checked out at — used as the `spec_version` +/// for a `specPath`-driven source, which (unlike a `-specification` repo's +/// `v{N}` directory) carries no version number of its own; the SHA at least +/// records which commit a vendored spec came from. Falls back to `"HEAD"` if +/// `git` can't resolve it (shouldn't happen against a repo we just cloned). +fn git_short_sha(repo_dir: &Path) -> String { + git_command() + .args([ + "-C", + &repo_dir.to_string_lossy(), + "rev-parse", + "--short", + "HEAD", + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "HEAD".to_owned()) +} + fn parse_repo_overrides() -> Option> { let raw = std::env::var("API_CATALOG_REPOS").ok()?; let repos: Vec = raw @@ -257,13 +284,85 @@ fn parse_repo_ref_overrides() -> HashMap { map } +/// A GraphQL schema carries no server URL, so a GraphQL-only source must say +/// where it is served (otherwise the explorer would build calls against an +/// empty host); OpenAPI sources already carry `servers` and must not set it. +fn validate_graphql_endpoint(source: &RemoteCatalogSource, graphql_only: bool) -> Result<()> { + let repo = &source.repository; + match (graphql_only, &source.base_url, &source.endpoint_path) { + (true, Some(_), Some(path)) if path.starts_with('/') => Ok(()), + (true, Some(_), Some(_)) => { + bail!("catalog source '{repo}': endpointPath must start with '/'") + } + (true, _, _) => bail!( + "catalog source '{repo}' is a GraphQL schema, so api-catalog-sources.json must set both baseUrl and endpointPath for it" + ), + (false, None, None) => Ok(()), + (false, _, _) => bail!( + "catalog source '{repo}' is not a GraphQL schema; baseUrl/endpointPath only apply to GraphQL sources" + ), + } +} + +/// Resolves one `API_CATALOG_REPOS` entry to a declared source. Accepts +/// `org/repository`, or a bare `repository` when exactly one declared source +/// has that name (repository names are only unique within an org). +fn find_override_source<'a>( + remote: &'a [RemoteCatalogSource], + entry: &str, +) -> Result<&'a RemoteCatalogSource> { + let matches: Vec<&RemoteCatalogSource> = remote + .iter() + .filter(|source| match entry.split_once('/') { + Some((org, repository)) => source.org == org && source.repository == repository, + None => source.repository == entry, + }) + .collect(); + match matches.as_slice() { + [source] => Ok(source), + [] => bail!( + "API_CATALOG_REPOS contains '{entry}', which is not declared in api-catalog-sources.json" + ), + _ => bail!( + "API_CATALOG_REPOS entry '{entry}' is ambiguous; use org/repository (one of: {})", + matches + .iter() + .map(|source| format!("{}/{}", source.org, source.repository)) + .collect::>() + .join(", ") + ), + } +} + +/// Looks up the `API_CATALOG_REPO_REFS` ref for one source. `org/repository` +/// is always honored; the legacy bare `repository` key applies only when no +/// other declared source shares that repository name, so a ref meant for one +/// org can never make another org's same-named repo check out the wrong commit. +fn find_ref_override<'a>( + remote: &[RemoteCatalogSource], + ref_overrides: &'a HashMap, + org: &str, + repository: &str, +) -> Option<&'a str> { + if let Some(git_ref) = ref_overrides.get(&format!("{org}/{repository}")) { + return Some(git_ref.as_str()); + } + let shared = remote + .iter() + .filter(|source| source.repository == repository) + .count() + > 1; + if shared { + return None; + } + ref_overrides.get(repository).map(String::as_str) +} + pub(crate) fn discover_spec_sources( manifest: &CatalogSourceManifest, ) -> Result<(Vec, PathBuf)> { let client = github_client()?; - let all_repos = list_org_repos(&client); - let repo_map: HashMap<&str, &GithubRepo> = - all_repos.iter().map(|r| (r.name.as_str(), r)).collect(); + let mut repos_by_org: HashMap> = HashMap::new(); let overrides = parse_repo_overrides(); let ref_overrides = parse_repo_ref_overrides(); @@ -271,17 +370,7 @@ pub(crate) fn discover_spec_sources( Some(repositories) => { let selected: Vec<&RemoteCatalogSource> = repositories .iter() - .map(|repository| { - manifest - .remote - .iter() - .find(|source| source.repository == *repository) - .with_context(|| { - format!( - "API_CATALOG_REPOS contains '{repository}', which is not declared in api-catalog-sources.json" - ) - }) - }) + .map(|entry| find_override_source(&manifest.remote, entry)) .collect::>()?; selected } @@ -307,22 +396,42 @@ pub(crate) fn discover_spec_sources( for source in selected { let repo_name = &source.repository; - let clone_url = if let Some(repo) = repo_map.get(repo_name.as_str()) { + let org = source.org.as_str(); + let repos = repos_by_org + .entry(org.to_owned()) + .or_insert_with(|| list_org_repos(&client, org)); + let clone_url = if let Some(repo) = repos.iter().find(|r| r.name == *repo_name) { if repo.archived || repo.disabled || repo.private { bail!("catalog source repository '{repo_name}' is unavailable"); } repo.clone_url.clone() } else { - format!("https://github.com/{GITHUB_ORG}/{repo_name}.git") + format!("https://github.com/{org}/{repo_name}.git") }; - let repo_dir = tmpdir.join(repo_name); - let git_ref = ref_overrides.get(repo_name.as_str()).map(String::as_str); + // Nested under `org` (not just `repo_name`) so two sources named the + // same repo in different orgs — now possible since `org` is + // per-source — don't clone into, and clobber, the same temp path. + let repo_dir = tmpdir.join(org).join(repo_name); + let git_ref = find_ref_override(&manifest.remote, &ref_overrides, org, repo_name); clone_repo(&clone_url, &repo_dir, git_ref) .with_context(|| format!("failed to clone declared catalog source '{repo_name}'"))?; - let (version, spec_file, graphql_only) = find_latest_spec_file(&repo_dir) - .with_context(|| format!("catalog source '{repo_name}' has no versioned spec file"))?; + let (version, spec_file, graphql_only) = match &source.spec_path { + Some(explicit) => { + let path = repo_dir.join(explicit); + if !path.exists() { + bail!("catalog source '{repo_name}' has no spec file at '{explicit}'"); + } + let graphql_only = path.extension().and_then(|e| e.to_str()) == Some("graphql"); + (git_short_sha(&repo_dir), path, graphql_only) + } + None => find_latest_spec_file(&repo_dir).with_context(|| { + format!("catalog source '{repo_name}' has no versioned spec file") + })?, + }; + + validate_graphql_endpoint(source, graphql_only)?; let domain = source.domain.clone(); if !used_domains.insert(domain.clone()) { @@ -337,6 +446,8 @@ pub(crate) fn discover_spec_sources( spec_version: version, graphql_only, catalog: source.catalog, + graphql_base_url: source.base_url.clone(), + graphql_endpoint_path: source.endpoint_path.clone(), }); } @@ -372,6 +483,8 @@ pub(crate) fn local_spec_sources(manifest: &CatalogSourceManifest) -> Result RemoteCatalogSource { + RemoteCatalogSource { + domain: repository.to_owned(), + repository: repository.to_owned(), + catalog: true, + org: org.to_owned(), + spec_path: None, + base_url: None, + endpoint_path: None, + } + } + + #[test] + fn graphql_sources_must_declare_where_they_are_served() { + let mut graphql = source("org-a", "gql"); + assert!(validate_graphql_endpoint(&graphql, true).is_err()); + + graphql.base_url = Some("https://api.example.com".to_owned()); + assert!( + validate_graphql_endpoint(&graphql, true).is_err(), + "endpointPath is required too" + ); + + graphql.endpoint_path = Some("graphql".to_owned()); + assert!( + validate_graphql_endpoint(&graphql, true).is_err(), + "endpointPath must be absolute" + ); + + graphql.endpoint_path = Some("/graphql".to_owned()); + validate_graphql_endpoint(&graphql, true).expect("fully specified GraphQL source"); + } + + #[test] + fn openapi_sources_must_not_set_graphql_endpoint_fields() { + let mut openapi = source("org-a", "rest"); + validate_graphql_endpoint(&openapi, false).expect("plain OpenAPI source"); + + openapi.endpoint_path = Some("/graphql".to_owned()); + assert!(validate_graphql_endpoint(&openapi, false).is_err()); + } + + #[test] + fn override_resolves_bare_and_org_qualified_names() { + let remote = [source("org-a", "alpha"), source("org-b", "beta")]; + + assert_eq!( + find_override_source(&remote, "alpha") + .expect("bare name") + .org, + "org-a" + ); + assert_eq!( + find_override_source(&remote, "org-b/beta") + .expect("qualified name") + .repository, + "beta" + ); + assert!(find_override_source(&remote, "org-a/beta").is_err()); + assert!(find_override_source(&remote, "missing").is_err()); + } + + #[test] + fn override_requires_org_when_a_repository_name_is_shared() { + let remote = [source("org-a", "shared"), source("org-b", "shared")]; + + let error = find_override_source(&remote, "shared") + .expect_err("bare name is ambiguous") + .to_string(); + assert!(error.contains("org-a/shared") && error.contains("org-b/shared")); + assert_eq!( + find_override_source(&remote, "org-b/shared") + .expect("qualified name") + .org, + "org-b" + ); + } + + #[test] + fn bare_ref_override_is_ignored_when_a_repository_name_is_shared() { + let refs: HashMap = [ + ("unique".to_owned(), "v1".to_owned()), + ("shared".to_owned(), "v2".to_owned()), + ("org-b/shared".to_owned(), "v3".to_owned()), + ] + .into(); + let remote = [ + source("org-a", "unique"), + source("org-a", "shared"), + source("org-b", "shared"), + ]; + + assert_eq!( + find_ref_override(&remote, &refs, "org-a", "unique"), + Some("v1") + ); + assert_eq!( + find_ref_override(&remote, &refs, "org-a", "shared"), + None, + "a bare key must not leak onto an ambiguous repository name" + ); + assert_eq!( + find_ref_override(&remote, &refs, "org-b", "shared"), + Some("v3") + ); + } } diff --git a/rust/tools/generate-api-catalog/src/graphql.rs b/rust/tools/generate-api-catalog/src/graphql.rs index c5a19929..10476977 100644 --- a/rust/tools/generate-api-catalog/src/graphql.rs +++ b/rust/tools/generate-api-catalog/src/graphql.rs @@ -283,11 +283,14 @@ pub(crate) fn synthesize_graphql_domain( .strip_prefix('v') .unwrap_or(&source.spec_version) .to_owned(), - base_url: String::new(), + base_url: source.graphql_base_url.clone().unwrap_or_default(), endpoints: vec![CatalogEndpoint { operation_id: "graphql".to_owned(), method: "POST".to_owned(), - path: "/graphql".to_owned(), + path: source + .graphql_endpoint_path + .clone() + .unwrap_or_else(|| "/graphql".to_owned()), summary: "GraphQL API".to_owned(), description: Some(format!("GraphQL endpoint with {op_count} operations")), parameters: None, diff --git a/rust/tools/generate-api-catalog/src/main.rs b/rust/tools/generate-api-catalog/src/main.rs index d48c01da..fed990a3 100644 --- a/rust/tools/generate-api-catalog/src/main.rs +++ b/rust/tools/generate-api-catalog/src/main.rs @@ -1,3 +1,4 @@ +mod app_registry_spec; mod dereference; mod domains_spec; mod email_spec; @@ -120,6 +121,13 @@ fn main() -> Result<()> { ) .context("failed to refresh email-client codegen spec")?; } + if let Some(app_registry_source) = sources.iter().find(|s| s.domain == "app-registry") { + app_registry_spec::refresh( + &app_registry_source.spec_file, + &app_registry_spec::app_registry_client_schema_path(), + ) + .context("failed to refresh platform-app-client codegen schema")?; + } sources.extend(local_spec_sources(&source_manifest)?); @@ -300,4 +308,41 @@ mod tests { ); } } + + /// A GraphQL source's committed catalog entry must serve from the host and + /// path declared in `api-catalog-sources.json`; otherwise `gddy api call` + /// would send requests to the wrong place. + #[test] + fn committed_graphql_catalogs_use_the_declared_endpoint() { + let dir = resolve_output_dir(); + let source_manifest = load_source_manifest().expect("load source manifest"); + + let mut checked = 0; + for source in source_manifest + .remote + .iter() + .filter(|source| source.endpoint_path.is_some()) + { + let catalog: Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(format!("{}.json", source.domain))) + .unwrap_or_else(|e| panic!("read {}.json: {e}", source.domain)), + ) + .unwrap_or_else(|e| panic!("parse {}.json: {e}", source.domain)); + + assert_eq!( + catalog["baseUrl"].as_str(), + source.base_url.as_deref(), + "baseUrl drift for '{}'", + source.domain + ); + assert_eq!( + catalog["endpoints"][0]["path"].as_str(), + source.endpoint_path.as_deref(), + "endpoint path drift for '{}'", + source.domain + ); + checked += 1; + } + assert!(checked > 0, "expected at least one GraphQL catalog source"); + } } diff --git a/rust/tools/generate-api-catalog/src/manifest.rs b/rust/tools/generate-api-catalog/src/manifest.rs index e1202fe9..d997a661 100644 --- a/rust/tools/generate-api-catalog/src/manifest.rs +++ b/rust/tools/generate-api-catalog/src/manifest.rs @@ -15,12 +15,27 @@ pub(crate) struct RemoteCatalogSource { pub(crate) repository: String, #[serde(default = "default_catalog")] pub(crate) catalog: bool, + #[serde(default = "default_org")] + pub(crate) org: String, + #[serde(default, rename = "specPath")] + pub(crate) spec_path: Option, + /// Production API host for a GraphQL-only source (OpenAPI specs carry + /// their own `servers`). Required alongside `endpointPath` for GraphQL. + #[serde(default, rename = "baseUrl")] + pub(crate) base_url: Option, + /// Path of the GraphQL endpoint under `baseUrl`. + #[serde(default, rename = "endpointPath")] + pub(crate) endpoint_path: Option, } const fn default_catalog() -> bool { true } +fn default_org() -> String { + "gdcorp-platform".to_owned() +} + #[derive(Debug, Deserialize)] pub(crate) struct LocalCatalogSource { pub(crate) domain: String, @@ -77,8 +92,8 @@ mod tests { let manifest = load_source_manifest().expect("load source manifest"); let expected = manifest.expected_domains(); - assert_eq!(expected.len(), 23); - assert_eq!(manifest.remote.len(), 24); + assert_eq!(expected.len(), 24); + assert_eq!(manifest.remote.len(), 25); assert_eq!( manifest .remote