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}" + ); + } +}