From b3f54bac27384aeeb060719f929b3fef5800fc6e Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 14 Oct 2026 12:29:02 -0700 Subject: [PATCH] chore(platform-app): add the generated App Registry GraphQL client crate [DEVEX-1173] Adds platform-app-client, a typed client for the App Registry subgraph generated at build time by graphql_client from the pinned schema and one .graphql document per operation, matching the generated-client pattern used for domains/hosting/shopping/email. It is not used by the CLI yet. Unset optional mutation inputs are omitted from the request rather than sent as explicit nulls, since the service treats an omitted field as "leave unchanged" and a null as "clear". Responses to credential-bearing operations (CreateApplication) are never shown to the transport observer, so `--debug transport` cannot log the secrets they carry. Co-Authored-By: Claude Sonnet 5.5 --- rust/Cargo.lock | 162 +++++-- rust/Cargo.toml | 1 + rust/platform-app-client/Cargo.toml | 29 ++ rust/platform-app-client/build.rs | 132 ++++++ .../graphql/ActivateRelease.graphql | 11 + .../graphql/Application.graphql | 11 + .../ApplicationWithLatestRelease.graphql | 29 ++ .../graphql/ApplicationsList.graphql | 15 + .../graphql/ArchiveApplication.graphql | 10 + .../graphql/CreateApplication.graphql | 17 + .../graphql/CreateRelease.graphql | 39 ++ .../graphql/DisableApplication.graphql | 5 + .../graphql/EnableApplication.graphql | 5 + .../graphql/EnabledStoreApplications.graphql | 12 + .../graphql/GenerateReleaseUploadUrl.graphql | 10 + .../graphql/UpdateApplication.graphql | 14 + rust/platform-app-client/src/lib.rs | 401 ++++++++++++++++++ 17 files changed, 879 insertions(+), 24 deletions(-) create mode 100644 rust/platform-app-client/Cargo.toml create mode 100644 rust/platform-app-client/build.rs create mode 100644 rust/platform-app-client/graphql/ActivateRelease.graphql create mode 100644 rust/platform-app-client/graphql/Application.graphql create mode 100644 rust/platform-app-client/graphql/ApplicationWithLatestRelease.graphql create mode 100644 rust/platform-app-client/graphql/ApplicationsList.graphql create mode 100644 rust/platform-app-client/graphql/ArchiveApplication.graphql create mode 100644 rust/platform-app-client/graphql/CreateApplication.graphql create mode 100644 rust/platform-app-client/graphql/CreateRelease.graphql create mode 100644 rust/platform-app-client/graphql/DisableApplication.graphql create mode 100644 rust/platform-app-client/graphql/EnableApplication.graphql create mode 100644 rust/platform-app-client/graphql/EnabledStoreApplications.graphql create mode 100644 rust/platform-app-client/graphql/GenerateReleaseUploadUrl.graphql create mode 100644 rust/platform-app-client/graphql/UpdateApplication.graphql create mode 100644 rust/platform-app-client/src/lib.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 59164096..3eeab2c2 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", @@ -604,7 +604,7 @@ dependencies = [ "sha2", "strsim", "termimad", - "thiserror", + "thiserror 2.0.19", "tokio", "toml", "toml_edit 0.22.27", @@ -639,7 +639,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1040,7 +1040,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1085,7 +1085,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1415,7 +1415,7 @@ dependencies = [ "http", "httpmock", "reqwest 0.13.4", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -1524,7 +1524,7 @@ dependencies = [ "shopping-client", "tar", "tempfile", - "thiserror", + "thiserror 2.0.19", "tokio", "tokio-tungstenite", "toml", @@ -1535,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" @@ -1621,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" @@ -1673,7 +1737,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", "url", ] @@ -1751,7 +1815,7 @@ dependencies = [ "similar", "stringmetrics", "tabwriter", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "url", @@ -2070,7 +2134,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -2582,7 +2646,7 @@ dependencies = [ "owo-colors", "oxc-miette-derive", "textwrap", - "thiserror", + "thiserror 2.0.19", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -2902,7 +2966,7 @@ dependencies = [ "serde", "serde_derive", "strum", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -2928,6 +2992,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" @@ -3033,7 +3116,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", @@ -3044,7 +3127,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "typify", "unicode-ident", ] @@ -3090,7 +3173,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -3113,7 +3196,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -3256,7 +3339,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -3926,7 +4009,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror", + "thiserror 2.0.19", "tokio", ] @@ -4096,7 +4179,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", @@ -4108,6 +4191,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" @@ -4194,7 +4288,7 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror", + "thiserror 2.0.19", "unicode-width 0.1.14", ] @@ -4209,13 +4303,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]] @@ -4549,7 +4663,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -4580,7 +4694,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", @@ -4590,7 +4704,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 a7ad91dd..23114848 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]] 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/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}" + ); + } +}