backup

Seto Elkahfi committed Oct 8, 2026 at 23:31 UTC 5ea62bcb58bef447fccd9d1244155fa65670424b
21 files changed +880 -111
.agents/plans/feature-plan-from-klepon-xcode-release-session.md new
+64
@@ -0,0 +1,64 @@
1 +# smbcloud-ascapi — token-source + builds API: open plan items
2 +
3 +This file tracks the remaining work from the builds-API / token-source
4 +session on `smbcloud-ascapi` (repo: `github.com:smbcloudXYZ/smbcloud-ascapi`,
5 +base branch `development`).
6 +
7 +Shipped as gh stack #9 (draft PRs, bottom to top):
8 +#6 `fix/list-pagination` → #7 `feature/token-source` →
9 +#8 `feature/builds-api` → #10 `feature/altool-upload`.
10 +
11 +## Verification (done)
12 +- [x] `cargo build` — clean. (Fixed: `--platform` must use the `CliPlatform`
13 + ValueEnum wrapper, not `Platform` directly, then `.map(Platform::from)`
14 + in the match arm.)
15 +- [x] `cargo clippy --all-targets` — clean, on every stack layer.
16 +- [x] `cargo test` — all passing (incl. doc-tests), on every stack layer.
17 +
18 +## Feature work
19 +- [x] altool upload orchestration (frontend crate): `frontend::upload`,
20 + CLI `ascapi apps upload <pkg> [--wait]` (#10). Passes
21 + `--p8-file-path` so altool uses the same key as the API calls.
22 + CLI-only; deliberately not an MCP tool.
23 +- [x] Unit tests for the new builds API (filter keys, list/pre-release
24 + deserialization) (#8).
25 +- [x] Fixed: builds/pre-release filters were sent to
26 + `/v1/apps/{id}/builds` and `/v1/apps/{id}/preReleaseVersions`, which
27 + don't accept `filter[...]`. Now `/v1/builds` and
28 + `/v1/preReleaseVersions` with `filter[app]` (#8).
29 +- [ ] Unit tests for `token.rs` (`StaticToken`, `Client::new` over a
30 + custom `TokenSource`) (#7).
31 +- [ ] MCP `list_builds` tool doesn't expose `BuildFilter` yet, so it lags
32 + the CLI.
33 +
34 +## Docs
35 +- [x] README crate table — builds API surface, altool upload.
36 +- [x] CLAUDE.md — builds, build, pre-release-versions, upload.
37 +
38 +## Release
39 +- [x] Release build + offline smoke test (`--help`, `--dry-run apps upload`,
40 + package validation errors).
41 +- [ ] Live smoke test against ASC (needs `ASC_ISSUER_ID`): run
42 + `apps builds <id> --pre-release-version …` and
43 + `apps pre-release-versions <id> --platform ios` to confirm the
44 + `/v1/builds` filter fix, then one real `apps upload`.
45 +- [x] Commits with co-author trailer, signed (GitHub: verified).
46 +- [x] `gh stack` PRs (`remote.pushDefault` is `setoelkahfi`, there is no
47 + `origin`).
48 +- [ ] Mark PRs ready for review and merge (`gh stack merge --yes`).
49 +
50 +## Separate / unaddressed bug
51 +- [ ] ASC API 401 on builds query. Not in this repo: Klepon has no
52 + `build-ios.sh`. `onde-store/scripts/build-ios.sh` and
53 + `smbcloud-browser/scripts/build-ios.sh` default `ASC_PRIVATE_KEY_PATH`
54 + to `~/private_keys/AuthKey_<id>.p8`, while ascapi defaults to
55 + `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`. Two copies with
56 + the same id can drift. Decide which file is current, then either
57 + point the scripts at it or set `ASC_PRIVATE_KEY_PATH` once for both.
58 + `ascapi apps upload` avoids the drift by passing the key explicitly.
59 +
60 +## Cleanup
61 +- [ ] Main working tree on `development` still holds the uncommitted copy
62 + of #6–#8. It's now on the branches, so it can be discarded.
63 +- [ ] Old local `feature/token` (`0eac45b`, unpushed) is superseded by #7.
64 +- [ ] Worktree at `/tmp/ascapi-stack/wt` (`git worktree remove`).
CLAUDE.md new
+74
@@ -0,0 +1,74 @@
1 +# CLAUDE.md
2 +
3 +Guidance for Claude Code (and other agents) working in this repository.
4 +
5 +## What this is
6 +
7 +`smbcloud-ascapi` is a Rust workspace wrapping the App Store Connect API. It
8 +ships two front ends over one shared core:
9 +
10 +- the `ascapi` CLI (`crates/cli`), and
11 +- an MCP stdio server (`crates/mcp`, reachable via `ascapi --mcp`).
12 +
13 +Both go through `smbcloud-ascapi-frontend` so they can't drift. See `README.md`
14 +for the full crate table.
15 +
16 +## Workspace layout
17 +
18 +Domain crates (`aso`, `pricing`, `signing`, `reports`) know nothing about each
19 +other or the front ends. Each extends `smbcloud_ascapi_core::Client` with an
20 +**extension trait** (Rust only allows inherent impls in the crate that owns the
21 +type). Import a domain's `prelude::*` to pull its traits in at once.
22 +
23 +## Conventions
24 +
25 +- JSON:API responses deserialize through `ListDocument<T>` / the core
26 + envelopes; `Resource.id` is the ASC id.
27 +- CLI `ValueEnum` wrappers (`CliPlatform`, `CliBundleIdPlatform`,
28 + `CliProfileType`, …) wrap the domain enums so clap owns the arg parsing;
29 + map them to the domain type in the match arm (e.g. `.map(Platform::from)`).
30 +- Tests live in-module: `#[cfg(test)] mod tests { use super::*; … }` with plain
31 + `#[test]` fns (see `crates/aso/src/bundle_id.rs`, `crates/aso/src/build.rs`).
32 +- Pin exact ASC filter keys in assertions — e.g.
33 + `filter[preReleaseVersion.version]` vs `filter[version]`; the wrong key
34 + silently returns the wrong rows.
35 +
36 +## Builds API (CLI)
37 +
38 +All three are subcommands of `apps` (clap kebab-cases the command names):
39 +
40 +```sh
41 +# GET /v1/builds?filter[app]={id} — newest first. Check processingState
42 +# (VALID / INVALID / PROCESSING / FAILED).
43 +ascapi apps builds <APP_ID> \
44 + [--version <BUILD_NUMBER>] \ # filter[version], e.g. 42
45 + [--pre-release-version <MARKETING>] \ # filter[preReleaseVersion.version], e.g. 1.1.0
46 + [--processing-state <STATE>] # filter[processingState]
47 +
48 +# GET /v1/builds/{id} — a single build by its ASC id.
49 +ascapi apps build <BUILD_ID>
50 +
51 +# GET /v1/preReleaseVersions?filter[app]={id} — the marketing version strings
52 +# TestFlight groups builds under.
53 +ascapi apps pre-release-versions <APP_ID> [--platform <ios|mac-os|tv-os|vision-os>]
54 +```
55 +
56 +The underlying API lives in `crates/aso/src/build.rs`: `BuildsApi` (in the
57 +`aso` prelude) plus `BuildFilter`, whose private `query_pairs` maps each set
58 +field to its ASC filter key.
59 +
60 +## Verify before committing
61 +
62 +```sh
63 +cargo fmt --all
64 +cargo clippy --all-targets
65 +cargo test
66 +```
67 +
68 +## Credentials
69 +
70 +The CLI needs an ASC API key: `--key-id`/`ASC_API_KEY`,
71 +`--issuer-id`/`ASC_ISSUER_ID`, and the `.p8` at
72 +`--private-key-path`/`ASC_PRIVATE_KEY_PATH` (defaults to
73 +`~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`). `--mcp` resolves
74 +credentials per tool call, so the server can list tools unconfigured.
README.md
+1 -1
@@ -8,7 +8,7 @@ from the other:
8 | Crate | What it is |
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 -| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
11 +| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, builds, pre-release versions, bundle IDs, localizations, screenshots |
12 | `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies |
13 | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
14 | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
crates/aso/src/app.rs
+2 -5
@@ -9,7 +9,7 @@
9 use async_trait::async_trait;
10 use reqwest::Method;
11 use serde::{Deserialize, Serialize};
12 -use smbcloud_ascapi_core::jsonapi::{Document, ListDocument, Resource, UpdateBody, UpdateData};
12 +use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
13 use smbcloud_ascapi_core::Client;
14 use smbcloud_ascapi_core::Result;
15
@@ -62,10 +62,7 @@ impl AppsApi for Client {
62 if let Some(bundle_id) = filter_bundle_id {
63 query.push(("filter[bundleId]", bundle_id));
64 }
65 - let doc: ListDocument<AppAttributes> = self
66 - .request(Method::GET, "/v1/apps", &query, None::<&()>)
67 - .await?;
68 - Ok(doc.data)
65 + self.list_all::<AppAttributes>("/v1/apps", &query).await
66 }
67
68 async fn get_app(&self, app_id: &str) -> Result<App> {
crates/aso/src/app_info.rs
+2 -4
@@ -7,7 +7,7 @@
7 use async_trait::async_trait;
8 use reqwest::Method;
9 use serde::{Deserialize, Serialize};
10 -use smbcloud_ascapi_core::jsonapi::{Document, ListDocument, Resource};
10 +use smbcloud_ascapi_core::jsonapi::{Document, Resource};
11 use smbcloud_ascapi_core::Client;
12 use smbcloud_ascapi_core::Result;
13
@@ -45,9 +45,7 @@ pub trait AppInfosApi {
45 impl AppInfosApi for Client {
46 async fn list_app_infos(&self, app_id: &str) -> Result<Vec<AppInfo>> {
47 let path = format!("/v1/apps/{app_id}/appInfos");
48 - let doc: ListDocument<AppInfoAttributes> =
49 - self.request(Method::GET, &path, &[], None::<&()>).await?;
50 - Ok(doc.data)
48 + self.list_all::<AppInfoAttributes>(&path, &[]).await
49 }
50
51 async fn get_app_info(&self, id: &str) -> Result<AppInfo> {
crates/aso/src/app_info_localization.rs
+3 -5
@@ -2,8 +2,7 @@ use async_trait::async_trait;
2 use reqwest::Method;
3 use serde::{Deserialize, Serialize};
4 use smbcloud_ascapi_core::jsonapi::{
5 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
6 - UpdateData,
5 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
6 };
7 use smbcloud_ascapi_core::Client;
8 use smbcloud_ascapi_core::Result;
@@ -88,9 +87,8 @@ impl AppInfoLocalizationsApi for Client {
87 app_info_id: &str,
88 ) -> Result<Vec<AppInfoLocalization>> {
89 let path = format!("/v1/appInfos/{app_info_id}/appInfoLocalizations");
91 - let doc: ListDocument<AppInfoLocalizationAttributes> =
92 - self.request(Method::GET, &path, &[], None::<&()>).await?;
93 - Ok(doc.data)
90 + self.list_all::<AppInfoLocalizationAttributes>(&path, &[])
91 + .await
92 }
93
94 async fn create_app_info_localization(
crates/aso/src/app_screenshot.rs
+2 -5
@@ -16,8 +16,7 @@ use async_trait::async_trait;
16 use reqwest::Method;
17 use serde::{Deserialize, Deserializer, Serialize};
18 use smbcloud_ascapi_core::jsonapi::{
19 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
20 - UpdateData,
19 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
20 };
21 use smbcloud_ascapi_core::Client;
22 use smbcloud_ascapi_core::Result;
@@ -171,9 +170,7 @@ impl AppScreenshotsApi for Client {
170 app_screenshot_set_id: &str,
171 ) -> Result<Vec<AppScreenshot>> {
172 let path = format!("/v1/appScreenshotSets/{app_screenshot_set_id}/appScreenshots");
174 - let doc: ListDocument<AppScreenshotAttributes> =
175 - self.request(Method::GET, &path, &[], None::<&()>).await?;
176 - Ok(doc.data)
173 + self.list_all::<AppScreenshotAttributes>(&path, &[]).await
174 }
175
176 async fn create_app_screenshot(
crates/aso/src/app_screenshot_set.rs
+3 -4
@@ -7,7 +7,7 @@ use async_trait::async_trait;
7 use reqwest::Method;
8 use serde::{Deserialize, Serialize};
9 use smbcloud_ascapi_core::jsonapi::{
10 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne,
10 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne,
11 };
12 use smbcloud_ascapi_core::Client;
13 use smbcloud_ascapi_core::Result;
@@ -121,9 +121,8 @@ impl AppScreenshotSetsApi for Client {
121 let path = format!(
122 "/v1/appStoreVersionLocalizations/{app_store_version_localization_id}/appScreenshotSets"
123 );
124 - let doc: ListDocument<AppScreenshotSetAttributes> =
125 - self.request(Method::GET, &path, &[], None::<&()>).await?;
126 - Ok(doc.data)
124 + self.list_all::<AppScreenshotSetAttributes>(&path, &[])
125 + .await
126 }
127
128 async fn create_app_screenshot_set(
crates/aso/src/app_store_version.rs
+5 -7
@@ -9,8 +9,8 @@ use async_trait::async_trait;
9 use reqwest::Method;
10 use serde::{Deserialize, Serialize};
11 use smbcloud_ascapi_core::jsonapi::{
12 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne,
13 - UpdateRelationshipsBody, UpdateRelationshipsData,
12 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateRelationshipsBody,
13 + UpdateRelationshipsData,
14 };
15 use smbcloud_ascapi_core::Client;
16 use smbcloud_ascapi_core::Result;
@@ -30,7 +30,7 @@ pub enum Platform {
30 }
31
32 impl Platform {
33 - fn as_query_value(self) -> &'static str {
33 + pub(crate) fn as_query_value(self) -> &'static str {
34 match self {
35 Platform::Ios => "IOS",
36 Platform::MacOs => "MAC_OS",
@@ -122,10 +122,8 @@ impl AppStoreVersionsApi for Client {
122 if let Some(platform) = filter_platform {
123 query.push(("filter[platform]", platform.as_query_value()));
124 }
125 - let doc: ListDocument<AppStoreVersionAttributes> = self
126 - .request(Method::GET, &path, &query, None::<&()>)
127 - .await?;
128 - Ok(doc.data)
125 + self.list_all::<AppStoreVersionAttributes>(&path, &query)
126 + .await
127 }
128
129 async fn get_app_store_version(&self, id: &str) -> Result<AppStoreVersion> {
crates/aso/src/app_store_version_localization.rs
+3 -5
@@ -2,8 +2,7 @@ use async_trait::async_trait;
2 use reqwest::Method;
3 use serde::{Deserialize, Serialize};
4 use smbcloud_ascapi_core::jsonapi::{
5 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
6 - UpdateData,
5 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
6 };
7 use smbcloud_ascapi_core::Client;
8 use smbcloud_ascapi_core::Result;
@@ -93,9 +92,8 @@ impl AppStoreVersionLocalizationsApi for Client {
92 ) -> Result<Vec<AppStoreVersionLocalization>> {
93 let path =
94 format!("/v1/appStoreVersions/{app_store_version_id}/appStoreVersionLocalizations");
96 - let doc: ListDocument<AppStoreVersionLocalizationAttributes> =
97 - self.request(Method::GET, &path, &[], None::<&()>).await?;
98 - Ok(doc.data)
95 + self.list_all::<AppStoreVersionLocalizationAttributes>(&path, &[])
96 + .await
97 }
98
99 async fn create_app_store_version_localization(
crates/aso/src/build.rs
+225 -19
@@ -1,20 +1,26 @@
1 //! Read-only lookup for `Build` — the processed artifact behind an uploaded
2 -//! `.ipa`/`.pkg`. Uploading itself is out of this crate's scope (done via
3 -//! `xcrun altool`/Xcode, see the CLI's sibling build scripts); this module
4 -//! exists so a caller can check whether App Store Connect finished
5 -//! processing a build as `VALID` or `INVALID` after upload — the signal that
6 -//! actually confirms a binary-rejection fix worked, independent of an
7 -//! `AppStoreVersion`'s `appVersionState` (which only changes once a build is
8 -//! attached to the version and resubmitted — outside this crate's scope).
2 +//! `.ipa`/`.pkg` — and `PreReleaseVersion`, the version string App Store
3 +//! Connect groups builds under for TestFlight.
4 +//!
5 +//! Uploading the binary itself is out of this crate's scope (done via
6 +//! `xcrun altool`/Xcode); this module exists so a caller can check whether App
7 +//! Store Connect finished processing a build as `VALID` or `INVALID` after
8 +//! upload — the signal that actually confirms a binary-rejection fix worked,
9 +//! independent of an `AppStoreVersion`'s `appVersionState` (which only
10 +//! changes once a build is attached to the version and resubmitted — outside
11 +//! this crate's scope).
12
13 use async_trait::async_trait;
14 use reqwest::Method;
15 use serde::{Deserialize, Serialize};
13 -use smbcloud_ascapi_core::jsonapi::{ListDocument, Resource};
16 +use smbcloud_ascapi_core::jsonapi::{Document, Resource};
17 use smbcloud_ascapi_core::Client;
18 use smbcloud_ascapi_core::Result;
19
20 +use crate::app_store_version::Platform;
21 +
22 pub const RESOURCE_TYPE: &str = "builds";
23 +pub const PRE_RELEASE_VERSION_RESOURCE_TYPE: &str = "preReleaseVersions";
24
25 #[derive(Debug, Clone, Serialize, Deserialize)]
26 #[serde(rename_all = "camelCase")]
@@ -28,6 +34,50 @@ pub struct BuildAttributes {
34
35 pub type Build = Resource<BuildAttributes>;
36
37 +#[derive(Debug, Clone, Serialize, Deserialize)]
38 +#[serde(rename_all = "camelCase")]
39 +pub struct PreReleaseVersionAttributes {
40 + pub version: Option<String>,
41 + pub platform: Option<Platform>,
42 +}
43 +
44 +pub type PreReleaseVersion = Resource<PreReleaseVersionAttributes>;
45 +
46 +/// How to narrow a `list_builds` query. `None` fields are left off the
47 +/// request, so the default — all fields `None` — lists every build for the
48 +/// app, matching the original parameter-free behavior.
49 +#[derive(Debug, Clone, Default)]
50 +pub struct BuildFilter {
51 + /// `filter[version]` — the build (upload) number, e.g. `"42"`. Note this
52 + /// is the build's own `version`, *not* the marketing `preReleaseVersion`
53 + /// string; use [`BuildFilter::pre_release_version`] for the latter.
54 + pub version: Option<String>,
55 + /// `filter[preReleaseVersion.version]` — the marketing version the build
56 + /// sits under, e.g. `"1.1.0"`.
57 + pub pre_release_version: Option<String>,
58 + /// `filter[processingState]` — one of `PROCESSING`, `FAILED`, `INVALID`,
59 + /// `VALID`.
60 + pub processing_state: Option<String>,
61 +}
62 +
63 +impl BuildFilter {
64 + /// Collect the set fields into `filter[...]` query pairs. Returns owned
65 + /// strings because the values come from the caller at runtime.
66 + fn query_pairs(&self) -> Vec<(&'static str, String)> {
67 + let mut pairs = Vec::new();
68 + if let Some(v) = &self.version {
69 + pairs.push(("filter[version]", v.clone()));
70 + }
71 + if let Some(v) = &self.pre_release_version {
72 + pairs.push(("filter[preReleaseVersion.version]", v.clone()));
73 + }
74 + if let Some(v) = &self.processing_state {
75 + pairs.push(("filter[processingState]", v.clone()));
76 + }
77 + pairs
78 + }
79 +}
80 +
81 /// Uploaded builds, which a version must have attached.
82 ///
83 /// An extension trait rather than inherent methods, because `Client`
@@ -36,22 +86,178 @@ pub type Build = Resource<BuildAttributes>;
86 /// to call these on a `Client`.
87 #[async_trait]
88 pub trait BuildsApi {
39 - /// `GET /v1/apps/{app_id}/builds`, sorted newest-first by
40 - /// `uploadedDate` (client-side — this endpoint's `sort` query parameter
41 - /// is rejected by the API, unlike most other list endpoints), so the
42 - /// build most recently uploaded (e.g. by a fixplist re-upload) is
43 - /// `list_builds(app_id).await?.first()`.
44 - async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>>;
89 + /// `GET /v1/builds?filter[app]={app_id}`, sorted newest-first by
90 + /// `uploadedDate` client-side, so the build most recently uploaded (e.g.
91 + /// by a fixplist re-upload) is `list_builds(app_id, &filter).await?.first()`.
92 + ///
93 + /// The top-level collection rather than `/v1/apps/{app_id}/builds`:
94 + /// that related-resource endpoint only accepts `fields`/`limit` and
95 + /// rejects `filter[...]` and `sort`.
96 + ///
97 + /// Pass `&BuildFilter::default()` for every build, or set fields to
98 + /// narrow by build number, pre-release (marketing) version, or
99 + /// processing state.
100 + async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>>;
101 +
102 + /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
103 + async fn get_build(&self, id: &str) -> Result<Build>;
104 +
105 + /// `GET /v1/preReleaseVersions?filter[app]={app_id}`, optionally narrowed
106 + /// to one platform (the related `/v1/apps/{app_id}/preReleaseVersions`
107 + /// endpoint does not accept `filter[platform]`). These are the marketing version strings (e.g. `1.1.0`)
108 + /// TestFlight groups builds under; a build's
109 + /// `filter[preReleaseVersion.version]` matches one of these.
110 + async fn list_pre_release_versions(
111 + &self,
112 + app_id: &str,
113 + filter_platform: Option<Platform>,
114 + ) -> Result<Vec<PreReleaseVersion>>;
115 }
116
117 #[async_trait]
118 impl BuildsApi for Client {
49 - async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>> {
50 - let path = format!("/v1/apps/{app_id}/builds");
51 - let doc: ListDocument<BuildAttributes> =
52 - self.request(Method::GET, &path, &[], None::<&()>).await?;
53 - let mut builds = doc.data;
119 + async fn list_builds(&self, app_id: &str, filter: &BuildFilter) -> Result<Vec<Build>> {
120 + let owned = filter.query_pairs();
121 + let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
122 + query.extend(owned.iter().map(|(k, v)| (*k, v.as_str())));
123 + let mut builds = self
124 + .list_all::<BuildAttributes>("/v1/builds", &query)
125 + .await?;
126 builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
127 Ok(builds)
128 }
129 +
130 + async fn get_build(&self, id: &str) -> Result<Build> {
131 + let path = format!("/v1/builds/{id}");
132 + let doc: Document<BuildAttributes> =
133 + self.request(Method::GET, &path, &[], None::<&()>).await?;
134 + Ok(doc.data)
135 + }
136 +
137 + async fn list_pre_release_versions(
138 + &self,
139 + app_id: &str,
140 + filter_platform: Option<Platform>,
141 + ) -> Result<Vec<PreReleaseVersion>> {
142 + let mut query: Vec<(&str, &str)> = vec![("filter[app]", app_id), ("limit", "200")];
143 + if let Some(platform) = filter_platform {
144 + query.push(("filter[platform]", platform.as_query_value()));
145 + }
146 + self.list_all::<PreReleaseVersionAttributes>("/v1/preReleaseVersions", &query)
147 + .await
148 + }
149 +}
150 +
151 +#[cfg(test)]
152 +mod tests {
153 + use super::*;
154 + use smbcloud_ascapi_core::jsonapi::ListDocument;
155 +
156 + #[test]
157 + fn default_filter_sends_no_query_pairs() {
158 + // `BuildFilter::default()` must list every build for the app — the
159 + // original parameter-free behavior — so it adds no `filter[...]`.
160 + assert!(BuildFilter::default().query_pairs().is_empty());
161 + }
162 +
163 + #[test]
164 + fn each_field_maps_to_its_asc_filter_key() {
165 + // The ASC filter keys are load-bearing: `version` is the build
166 + // number, but the marketing version is `preReleaseVersion.version`,
167 + // not `preReleaseVersion`. Getting these wrong silently returns the
168 + // wrong builds (or all of them), so pin every key and value.
169 + assert_eq!(
170 + BuildFilter {
171 + version: Some("42".into()),
172 + ..Default::default()
173 + }
174 + .query_pairs(),
175 + vec![("filter[version]", "42".to_string())],
176 + );
177 + assert_eq!(
178 + BuildFilter {
179 + pre_release_version: Some("1.1.0".into()),
180 + ..Default::default()
181 + }
182 + .query_pairs(),
183 + vec![("filter[preReleaseVersion.version]", "1.1.0".to_string())],
184 + );
185 + assert_eq!(
186 + BuildFilter {
187 + processing_state: Some("VALID".into()),
188 + ..Default::default()
189 + }
190 + .query_pairs(),
191 + vec![("filter[processingState]", "VALID".to_string())],
192 + );
193 + }
194 +
195 + #[test]
196 + fn all_fields_set_are_all_sent() {
197 + let pairs = BuildFilter {
198 + version: Some("42".into()),
199 + pre_release_version: Some("1.1.0".into()),
200 + processing_state: Some("VALID".into()),
201 + }
202 + .query_pairs();
203 + assert_eq!(
204 + pairs,
205 + vec![
206 + ("filter[version]", "42".to_string()),
207 + ("filter[preReleaseVersion.version]", "1.1.0".to_string()),
208 + ("filter[processingState]", "VALID".to_string()),
209 + ],
210 + );
211 + }
212 +
213 + #[test]
214 + fn deserializes_a_builds_list_response() {
215 + let body = serde_json::json!({
216 + "data": [
217 + {
218 + "id": "BUILD1",
219 + "type": "builds",
220 + "attributes": {
221 + "version": "42",
222 + "uploadedDate": "2024-01-02T03:04:05Z",
223 + "expired": false,
224 + "processingState": "VALID",
225 + "minOsVersion": "17.0"
226 + }
227 + }
228 + ]
229 + });
230 +
231 + let doc: ListDocument<BuildAttributes> =
232 + serde_json::from_value(body).expect("a builds list must deserialize");
233 + assert_eq!(doc.data.len(), 1);
234 + let build = &doc.data[0];
235 + assert_eq!(build.id, "BUILD1");
236 + assert_eq!(build.attributes.version.as_deref(), Some("42"));
237 + assert_eq!(build.attributes.processing_state.as_deref(), Some("VALID"));
238 + assert_eq!(build.attributes.expired, Some(false));
239 + }
240 +
241 + #[test]
242 + fn deserializes_a_pre_release_versions_response() {
243 + let body = serde_json::json!({
244 + "data": [
245 + {
246 + "id": "PRV1",
247 + "type": "preReleaseVersions",
248 + "attributes": {
249 + "version": "1.1.0",
250 + "platform": "IOS"
251 + }
252 + }
253 + ]
254 + });
255 +
256 + let doc: ListDocument<PreReleaseVersionAttributes> =
257 + serde_json::from_value(body).expect("a preReleaseVersions list must deserialize");
258 + assert_eq!(doc.data.len(), 1);
259 + let prv = &doc.data[0];
260 + assert_eq!(prv.attributes.version.as_deref(), Some("1.1.0"));
261 + assert_eq!(prv.attributes.platform, Some(Platform::Ios));
262 + }
263 }
crates/aso/src/bundle_id.rs
+7 -8
@@ -1,7 +1,7 @@
1 use async_trait::async_trait;
2 use reqwest::Method;
3 use serde::{Deserialize, Serialize};
4 -use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource};
4 +use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
5 use smbcloud_ascapi_core::Client;
6 use smbcloud_ascapi_core::Result;
7
@@ -79,15 +79,13 @@ impl BundleIdsApi for Client {
79 if let Some(identifier) = filter_identifier {
80 query.push(("filter[identifier]", identifier));
81 }
82 - // Apple's default page size is 20. Without this an unfiltered list
83 - // silently stops at the first 20 identifiers, which reads as "that
84 - // bundle ID is not registered" for anything further down.
82 + // 200 rows per page instead of Apple's default 20 — fewer round
83 + // trips. `list_all` follows `links.next` so no page boundary can
84 + // truncate the result.
85 query.push(("limit", "200"));
86
87 - let doc: ListDocument<BundleIdAttributes> = self
88 - .request(Method::GET, "/v1/bundleIds", &query, None::<&()>)
89 - .await?;
90 - Ok(doc.data)
87 + self.list_all::<BundleIdAttributes>("/v1/bundleIds", &query)
88 + .await
89 }
90
91 async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId> {
@@ -108,6 +106,7 @@ impl BundleIdsApi for Client {
106 #[cfg(test)]
107 mod tests {
108 use super::*;
109 + use smbcloud_ascapi_core::jsonapi::ListDocument;
110
111 #[test]
112 fn a_services_identifier_does_not_break_the_list() {
crates/cli/src/main.rs
+43 -3
@@ -11,6 +11,7 @@ use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Pl
11 use smbcloud_ascapi_aso::app_store_version_localization::{
12 AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13 };
14 +use smbcloud_ascapi_aso::build::BuildFilter;
15 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
16 use smbcloud_ascapi_aso::prelude::*;
17 use smbcloud_ascapi_core::{ApiKey, Client};
@@ -388,10 +389,32 @@ enum AppsCommand {
389 },
390 /// `GET /v1/apps/{id}/appInfos`.
391 Infos { app_id: String },
391 - /// `GET /v1/apps/{id}/builds` — newest first, so the
392 + /// `GET /v1/builds?filter[app]={id}` — newest first, so the
393 /// most recently uploaded build (e.g. after a fixplist re-upload) is
394 /// first. Check `processingState` (VALID/INVALID/PROCESSING/FAILED).
394 - Builds { app_id: String },
395 + Builds {
396 + app_id: String,
397 + /// `filter[version]` — the build (upload) number, e.g. `42`.
398 + #[arg(long)]
399 + version: Option<String>,
400 + /// `filter[preReleaseVersion.version]` — the marketing version the
401 + /// build sits under, e.g. `1.1.0`.
402 + #[arg(long)]
403 + pre_release_version: Option<String>,
404 + /// `filter[processingState]` — PROCESSING, FAILED, INVALID, or VALID.
405 + #[arg(long)]
406 + processing_state: Option<String>,
407 + },
408 + /// `GET /v1/builds/{id}` — a single build by its App Store Connect id.
409 + Build { id: String },
410 + /// `GET /v1/preReleaseVersions?filter[app]={id}` — the marketing version strings
411 + /// (e.g. `1.1.0`) TestFlight groups builds under.
412 + PreReleaseVersions {
413 + app_id: String,
414 + /// Restrict to one platform.
415 + #[arg(long, value_enum)]
416 + platform: Option<CliPlatform>,
417 + },
418 }
419
420 #[derive(Subcommand)]
@@ -841,7 +864,24 @@ async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Resul
864 print_json(&client.update_app(&app_id, attributes).await?)
865 }
866 AppsCommand::Infos { app_id } => print_json(&client.list_app_infos(&app_id).await?),
844 - AppsCommand::Builds { app_id } => print_json(&client.list_builds(&app_id).await?),
867 + AppsCommand::Builds {
868 + app_id,
869 + version,
870 + pre_release_version,
871 + processing_state,
872 + } => {
873 + let filter = BuildFilter {
874 + version,
875 + pre_release_version,
876 + processing_state,
877 + };
878 + print_json(&client.list_builds(&app_id, &filter).await?)
879 + }
880 + AppsCommand::Build { id } => print_json(&client.get_build(&id).await?),
881 + AppsCommand::PreReleaseVersions { app_id, platform } => {
882 + let platform = platform.map(Platform::from);
883 + print_json(&client.list_pre_release_versions(&app_id, platform).await?)
884 + }
885 }
886 }
887
crates/core/src/client.rs
+139 -9
@@ -1,12 +1,37 @@
1 -use crate::auth::ApiKey;
1 use crate::error::{Error, Result};
3 -use crate::jsonapi::ErrorDocument;
2 +use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
3 +use crate::token::{IntoTokenSource, TokenSource};
4 use reqwest::{Method, StatusCode};
5 use serde::de::DeserializeOwned;
6 use serde::Serialize;
7 use std::sync::Mutex;
8 use std::time::{Duration, Instant};
9
10 +/// Which request to send next while walking a paginated collection: the
11 +/// caller's original `path`/`query`, or the page Apple's `links.next` named.
12 +#[derive(Debug, PartialEq, Eq)]
13 +enum Page<'a> {
14 + /// The first page, as the caller asked for it.
15 + First {
16 + path: &'a str,
17 + query: &'a [(&'a str, &'a str)],
18 + },
19 + /// A following page. Carries only a path because Apple's `next` URL
20 + /// already embeds the original `include`/`filter`/`limit` plus its
21 + /// `cursor`; re-appending `query` would duplicate every parameter.
22 + Next(String),
23 +}
24 +
25 +impl<'a> Page<'a> {
26 + /// The `path`/`query` pair to request this page with.
27 + fn request(&self) -> (&str, &[(&str, &str)]) {
28 + match self {
29 + Page::First { path, query } => (*path, *query),
30 + Page::Next(path) => (path.as_str(), &[]),
31 + }
32 + }
33 +}
34 +
35 const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
36 // Refresh a bit before the token's real expiry so an in-flight request never
37 // races a signature Apple has already started rejecting.
@@ -30,16 +55,20 @@ struct CachedToken {
55 /// them directly.
56 pub struct Client {
57 http: reqwest::Client,
33 - api_key: ApiKey,
58 + token_source: Box<dyn TokenSource>,
59 base_url: String,
60 token: Mutex<Option<CachedToken>>,
61 }
62
63 impl Client {
39 - pub fn new(api_key: ApiKey) -> Self {
64 + /// Build a client over anything that can mint a bearer token. An
65 + /// [`ApiKey`](crate::ApiKey) is the usual argument and still works
66 + /// unchanged, since it converts into a [`TokenSource`]; a
67 + /// [`StaticToken`](crate::StaticToken) or any custom source works too.
68 + pub fn new(token_source: impl IntoTokenSource) -> Self {
69 Self {
70 http: reqwest::Client::new(),
42 - api_key,
71 + token_source: token_source.into_token_source(),
72 base_url: BASE_URL.to_string(),
73 token: Mutex::new(None),
74 }
@@ -64,13 +93,13 @@ impl Client {
93 }
94 }
95
67 - let value = self.api_key.token()?;
96 + let minted = self.token_source.mint()?;
97 *guard = Some(CachedToken {
69 - value: value.clone(),
98 + value: minted.value.clone(),
99 minted_at: Instant::now(),
71 - lifetime: Duration::from_secs(self.api_key.lifetime_secs()),
100 + lifetime: minted.lifetime,
101 });
73 - Ok(value)
102 + Ok(minted.value)
103 }
104
105 /// Send a request and decode a JSON:API response body into `T`. Use
@@ -90,6 +119,40 @@ impl Client {
119 Ok(serde_json::from_slice(&bytes)?)
120 }
121
122 + /// Read an entire collection by following `links.next`, so a caller sees
123 + /// every row rather than Apple's first page.
124 + ///
125 + /// `path`/`query` describe the first page only. Each later page is
126 + /// requested from the absolute `links.next` Apple returns, which already
127 + /// carries the original `include`/`filter`/`limit` alongside its cursor —
128 + /// re-appending `query` there would duplicate every parameter.
129 + ///
130 + /// No page size is chosen here. Apple's per-collection maximums differ and
131 + /// only the caller knows which collection it is reading; picking a size
132 + /// shapes the request, where following the cursor does not. Callers that
133 + /// want 200-row pages pass `("limit", "200")` in `query`, as they already
134 + /// do.
135 + pub async fn list_all<A>(&self, path: &str, query: &[(&str, &str)]) -> Result<Vec<Resource<A>>>
136 + where
137 + A: DeserializeOwned,
138 + {
139 + let mut rows = Vec::new();
140 + let mut page = Page::First { path, query };
141 +
142 + loop {
143 + let (page_path, page_query) = page.request();
144 + let doc: ListDocument<A> = self
145 + .request(Method::GET, page_path, page_query, None::<&()>)
146 + .await?;
147 + rows.extend(doc.data);
148 +
149 + let Some(next) = next_page(doc.links.and_then(|links| links.next)) else {
150 + return Ok(rows);
151 + };
152 + page = next;
153 + }
154 + }
155 +
156 /// Send a request that returns no body on success (typically `DELETE`).
157 pub async fn request_no_content<B: Serialize>(
158 &self,
@@ -177,6 +240,17 @@ impl Client {
240 }
241 }
242
243 +/// Decide the page that follows one carrying this `links.next`.
244 +///
245 +/// Returns `None` when the collection is done. Two ways that happens:
246 +/// Apple offered no `next` link, or it offered one we cannot turn into a
247 +/// path. The second case ends the walk instead of failing it — stopping
248 +/// early shows up as a short list, where a hard failure would take down a
249 +/// command that was otherwise working. See [`path_and_query`].
250 +fn next_page(next_link: Option<String>) -> Option<Page<'static>> {
251 + Some(Page::Next(path_and_query(&next_link?)?))
252 +}
253 +
254 fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
255 let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
256 Ok(doc) if !doc.errors.is_empty() => doc
@@ -192,3 +266,59 @@ fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
266 detail,
267 }
268 }
269 +
270 +#[cfg(test)]
271 +mod tests {
272 + use super::*;
273 +
274 + // `links.next` is the whole paging decision, so it is pinned against the
275 + // URL shape App Store Connect actually sends rather than a hand-made one.
276 + #[test]
277 + fn follows_a_next_link_as_a_path() {
278 + let link =
279 + "https://api.appstoreconnect.apple.com/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D";
280 + assert_eq!(
281 + next_page(Some(link.into())),
282 + Some(Page::Next(
283 + "/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D".into()
284 + ))
285 + );
286 + }
287 +
288 + /// A collection is done when Apple stops offering a `next` link — which
289 + /// is also how it signals a single-page collection.
290 + #[test]
291 + fn ends_the_walk_on_a_last_page() {
292 + assert_eq!(next_page(None), None);
293 + }
294 +
295 + /// Stopping early shows up as a short list; failing here would take down
296 + /// a command that was otherwise working.
297 + #[test]
298 + fn ends_the_walk_on_a_link_it_cannot_parse() {
299 + assert_eq!(next_page(Some("/v1/apps?cursor=AQ".into())), None);
300 + assert_eq!(
301 + next_page(Some("https://api.appstoreconnect.apple.com".into())),
302 + None
303 + );
304 + }
305 +
306 + /// The first page is the caller's request, unchanged.
307 + #[test]
308 + fn first_page_keeps_the_callers_query() {
309 + let query = [("limit", "200"), ("filter[bundleId]", "com.x.y")];
310 + let page = Page::First {
311 + path: "/v1/apps",
312 + query: &query,
313 + };
314 + assert_eq!(page.request(), ("/v1/apps", &query[..]));
315 + }
316 +
317 + /// Apple's `next` URL already carries `include`/`filter`/`limit`, so
318 + /// re-appending the original query would duplicate every parameter.
319 + #[test]
320 + fn later_pages_send_only_the_path_apple_named() {
321 + let page = Page::Next("/v1/apps?limit=200&cursor=AQ".into());
322 + assert_eq!(page.request(), ("/v1/apps?limit=200&cursor=AQ", &[][..]));
323 + }
324 +}
crates/core/src/jsonapi.rs
+113
@@ -13,9 +13,33 @@ pub struct Document<A> {
13
14 /// A resource-collection JSON:API document — the body of e.g. `GET
15 /// /v1/apps`.
16 +///
17 +/// Collections are paginated. Apple caps `limit` at 200 per page and
18 +/// advertises the following page in `links.next`; a list endpoint that
19 +/// reads only `data` therefore silently truncates to one page, which is
20 +/// indistinguishable from "there were only N of these". Use
21 +/// [`Client::list_all`](crate::Client::list_all) rather than a bare
22 +/// `request` for any collection that can exceed 200 rows.
23 #[derive(Debug, Clone, Deserialize)]
24 pub struct ListDocument<A> {
25 pub data: Vec<Resource<A>>,
26 + #[serde(default)]
27 + pub links: Option<Links>,
28 +}
29 +
30 +/// The JSON:API pagination links on a collection response.
31 +///
32 +/// Only `next` is read: paging stops the moment Apple stops offering a
33 +/// following page. `self`/`first`/`prev` are absent rather than ignored
34 +/// because nothing here would use them, and a `prev` pointer invites a
35 +/// caller to loop backwards through a collection that can change
36 +/// underneath it.
37 +#[derive(Debug, Clone, Deserialize)]
38 +pub struct Links {
39 + /// Absolute URL of the next page, already carrying the caller's
40 + /// `include`/`filter`/`limit` parameters plus a `cursor`.
41 + #[serde(default)]
42 + pub next: Option<String>,
43 }
44
45 /// A single JSON:API resource object: its id, type, and typed attributes.
@@ -118,3 +142,92 @@ pub struct ApiError {
142 #[serde(default)]
143 pub detail: String,
144 }
145 +
146 +/// Turn an absolute [`Links::next`] URL into the path-and-query form
147 +/// [`Client::request`](crate::Client::request) wants, since that method
148 +/// prefixes its own base URL and cannot be handed a whole URL.
149 +///
150 +/// Returns `None` for anything unparseable, which a paging caller treats
151 +/// as "no more pages" rather than an error — stopping early is recoverable
152 +/// and shows up as a short list, where a hard failure would take down a
153 +/// command that was otherwise working.
154 +pub fn path_and_query(url: &str) -> Option<String> {
155 + let after_scheme = url.split_once("://")?.1;
156 + let (_host, rest) = after_scheme.split_once('/')?;
157 + Some(format!("/{rest}"))
158 +}
159 +
160 +#[cfg(test)]
161 +mod tests {
162 + use super::*;
163 + use serde::Deserialize;
164 +
165 + #[derive(Debug, Deserialize)]
166 + struct Attrs {
167 + name: String,
168 + }
169 +
170 + /// A real collection page. `links.next` is what tells a paging caller
171 + /// there is more, so it is pinned against the shape Apple sends.
172 + #[test]
173 + fn reads_a_next_link_from_a_collection_page() {
174 + let doc: ListDocument<Attrs> = serde_json::from_value(serde_json::json!({
175 + "data": [{ "id": "1", "type": "apps", "attributes": { "name": "Klepon" } }],
176 + "links": {
177 + "self": "https://api.appstoreconnect.apple.com/v1/apps?limit=200",
178 + "next": "https://api.appstoreconnect.apple.com/v1/apps?limit=200&cursor=AQ%3D%3D"
179 + }
180 + }))
181 + .unwrap();
182 +
183 + assert_eq!(doc.data.len(), 1);
184 + assert_eq!(doc.data[0].attributes.name, "Klepon");
185 + assert_eq!(
186 + doc.links.expect("links present").next.as_deref(),
187 + Some("https://api.appstoreconnect.apple.com/v1/apps?limit=200&cursor=AQ%3D%3D")
188 + );
189 + }
190 +
191 + /// Both spellings of "last page" Apple uses: `links` present with only a
192 + /// `self`, and `links` missing entirely. A single-page collection looks
193 + /// exactly like this, so neither may be an error.
194 + #[test]
195 + fn a_page_without_a_next_link_is_the_last_one() {
196 + let with_self_only: ListDocument<Attrs> = serde_json::from_value(serde_json::json!({
197 + "data": [],
198 + "links": { "self": "https://api.appstoreconnect.apple.com/v1/apps?limit=200" }
199 + }))
200 + .unwrap();
201 + assert!(with_self_only.links.expect("links present").next.is_none());
202 +
203 + let without_links: ListDocument<Attrs> =
204 + serde_json::from_value(serde_json::json!({ "data": [] })).unwrap();
205 + assert!(without_links.links.is_none());
206 + }
207 +
208 + /// `request` prefixes its own base URL, so handing it a whole URL would
209 + /// produce `https://api.appstoreconnect.apple.comhttps://...`.
210 + #[test]
211 + fn strips_the_host_off_a_next_link() {
212 + assert_eq!(
213 + path_and_query("https://api.appstoreconnect.apple.com/v1/apps?cursor=AQ").as_deref(),
214 + Some("/v1/apps?cursor=AQ")
215 + );
216 + // Percent-encoded query values must survive untouched — decoding
217 + // them would let `&` inside a cursor split the parameter.
218 + assert_eq!(
219 + path_and_query("https://api.appstoreconnect.apple.com/v1/apps?cursor=AQ%3D%3D")
220 + .as_deref(),
221 + Some("/v1/apps?cursor=AQ%3D%3D")
222 + );
223 + }
224 +
225 + #[test]
226 + fn gives_up_on_a_link_that_is_not_a_whole_url() {
227 + assert_eq!(path_and_query("/v1/apps?cursor=AQ"), None);
228 + assert_eq!(
229 + path_and_query("https://api.appstoreconnect.apple.com"),
230 + None
231 + );
232 + }
233 +}
crates/core/src/lib.rs
+2
@@ -17,7 +17,9 @@ pub mod auth;
17 pub mod client;
18 pub mod error;
19 pub mod jsonapi;
20 +pub mod token;
21
22 pub use auth::ApiKey;
23 pub use client::Client;
24 pub use error::{Error, Result};
25 +pub use token::{IntoTokenSource, MintedToken, StaticToken, TokenSource};
crates/core/src/token.rs new
+159
@@ -0,0 +1,159 @@
1 +//! The bearer-token abstraction the [`Client`](crate::Client) mints from.
2 +//!
3 +//! App Store Connect wants a short-lived ES256 JWT on every request. The
4 +//! usual way to produce one is an [`ApiKey`](crate::ApiKey) signing its own
5 +//! claims, but it isn't the only way: CI often injects a token minted
6 +//! elsewhere, and tests want a fixed string. [`TokenSource`] is the seam
7 +//! that lets all three flow through the same client without the client
8 +//! caring which it holds.
9 +//!
10 +//! [`Client::new`](crate::Client::new) takes `impl IntoTokenSource`, so
11 +//! `Client::new(api_key)` keeps working unchanged while a
12 +//! [`StaticToken`] or any custom source is equally accepted.
13 +
14 +use crate::auth::ApiKey;
15 +use crate::error::Result;
16 +use std::fmt;
17 +use std::time::Duration;
18 +
19 +/// A freshly minted bearer token and how long it stays valid.
20 +///
21 +/// The client caches the `value` and re-mints shortly before `lifetime`
22 +/// elapses, so a source is free to return a long or short window as suits
23 +/// it. Apple caps real JWTs at 20 minutes; a static token can claim more.
24 +#[derive(Clone)]
25 +pub struct MintedToken {
26 + /// The bearer token string, sent verbatim as `Authorization: Bearer`.
27 + pub value: String,
28 + /// How long the token remains valid from the moment it was minted.
29 + pub lifetime: Duration,
30 +}
31 +
32 +impl MintedToken {
33 + /// Convenience constructor from a token string and a lifetime in seconds.
34 + pub fn new(value: impl Into<String>, lifetime_secs: u64) -> Self {
35 + Self {
36 + value: value.into(),
37 + lifetime: Duration::from_secs(lifetime_secs),
38 + }
39 + }
40 +}
41 +
42 +impl fmt::Debug for MintedToken {
43 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 + // Never print the token material itself.
45 + f.debug_struct("MintedToken")
46 + .field("value", &"<redacted>")
47 + .field("lifetime", &self.lifetime)
48 + .finish()
49 + }
50 +}
51 +
52 +/// Anything that can produce a bearer token for the client.
53 +///
54 +/// Implementors must be `Send + Sync` because the client caches tokens
55 +/// behind a mutex and is shared across tasks.
56 +pub trait TokenSource: Send + Sync {
57 + /// Mint a fresh token. The client calls this lazily and caches the
58 + /// result until shortly before it expires, so this need not be cheap.
59 + fn mint(&self) -> Result<MintedToken>;
60 +
61 + /// A short, secret-free description for diagnostics and MCP
62 + /// transcripts. Must never contain key or token material — it may be
63 + /// logged or surfaced to a user. Defaults to the type's shape.
64 + fn describe(&self) -> String {
65 + "token source".to_string()
66 + }
67 +}
68 +
69 +impl fmt::Debug for dyn TokenSource {
70 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 + f.debug_struct("TokenSource")
72 + .field("describe", &self.describe())
73 + .finish()
74 + }
75 +}
76 +
77 +impl TokenSource for ApiKey {
78 + fn mint(&self) -> Result<MintedToken> {
79 + Ok(MintedToken {
80 + value: self.token()?,
81 + lifetime: Duration::from_secs(self.lifetime_secs()),
82 + })
83 + }
84 +
85 + fn describe(&self) -> String {
86 + // key_id is an identifier, not a secret, and is invaluable when
87 + // diagnosing which of several keys a request was signed with.
88 + format!("api key {}", self.key_id())
89 + }
90 +}
91 +
92 +/// A fixed, already-minted bearer token.
93 +///
94 +/// For CI that injects a token from elsewhere, or tests that want a known
95 +/// string. Reports a generous default lifetime so the client doesn't try
96 +/// to re-mint it; override with [`StaticToken::with_lifetime_secs`].
97 +#[derive(Clone)]
98 +pub struct StaticToken {
99 + value: String,
100 + lifetime: Duration,
101 +}
102 +
103 +impl StaticToken {
104 + /// Wrap a pre-minted token string. Defaults to a 20 minute lifetime,
105 + /// matching Apple's ceiling for real JWTs.
106 + pub fn new(value: impl Into<String>) -> Self {
107 + Self {
108 + value: value.into(),
109 + lifetime: Duration::from_secs(20 * 60),
110 + }
111 + }
112 +
113 + /// Override how long the client treats the token as valid.
114 + pub fn with_lifetime_secs(mut self, secs: u64) -> Self {
115 + self.lifetime = Duration::from_secs(secs);
116 + self
117 + }
118 +}
119 +
120 +impl fmt::Debug for StaticToken {
121 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 + f.debug_struct("StaticToken")
123 + .field("value", &"<redacted>")
124 + .field("lifetime", &self.lifetime)
125 + .finish()
126 + }
127 +}
128 +
129 +impl TokenSource for StaticToken {
130 + fn mint(&self) -> Result<MintedToken> {
131 + Ok(MintedToken {
132 + value: self.value.clone(),
133 + lifetime: self.lifetime,
134 + })
135 + }
136 +
137 + fn describe(&self) -> String {
138 + "static token".to_string()
139 + }
140 +}
141 +
142 +/// Conversion into a boxed [`TokenSource`], so [`Client::new`](crate::Client::new)
143 +/// can accept an [`ApiKey`], a [`StaticToken`], a `Box<dyn TokenSource>`, or
144 +/// any custom source with one signature.
145 +pub trait IntoTokenSource {
146 + fn into_token_source(self) -> Box<dyn TokenSource>;
147 +}
148 +
149 +impl<T: TokenSource + 'static> IntoTokenSource for T {
150 + fn into_token_source(self) -> Box<dyn TokenSource> {
151 + Box::new(self)
152 + }
153 +}
154 +
155 +impl IntoTokenSource for Box<dyn TokenSource> {
156 + fn into_token_source(self) -> Box<dyn TokenSource> {
157 + self
158 + }
159 +}
crates/mcp/src/lib.rs
+1 -1
@@ -197,7 +197,7 @@ macro_rules! ascapi_mcp_tools {
197 let client = Self::client()?;
198 let request = parameters.0;
199 let value = client
200 - .list_builds(&request.app_id)
200 + .list_builds(&request.app_id, &::smbcloud_ascapi_aso::build::BuildFilter::default())
201 .await
202 .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
203 Ok(::rmcp::model::CallToolResult::success(vec![
crates/pricing/src/app_price.rs
+20 -21
@@ -18,6 +18,7 @@
18 use async_trait::async_trait;
19 use reqwest::Method;
20 use serde::{Deserialize, Serialize};
21 +use smbcloud_ascapi_core::jsonapi::{path_and_query, Links};
22 use smbcloud_ascapi_core::Client;
23 use smbcloud_ascapi_core::Result;
24 use std::collections::HashMap;
@@ -209,17 +210,11 @@ fn join(doc: &PricesDocument) -> Vec<TerritoryPrice> {
210 .collect()
211 }
212
212 -/// Turn an absolute `links.next` URL into the path+query
213 -/// [`Client::request`] wants, since that method prefixes its own base
214 -/// URL. Returns `None` for anything unparseable, which the caller treats
215 -/// as "no more pages" rather than an error — a missing last page is
216 -/// better than a panic on a link Apple changed the shape of.
217 -fn path_and_query(url: &str) -> Option<String> {
218 - let after_scheme = url.split_once("://")?.1;
219 - let (_host, rest) = after_scheme.split_once('/')?;
220 - Some(format!("/{rest}"))
221 -}
222 -
213 +/// The pricing collection body. Not [`ListDocument`](smbcloud_ascapi_core::jsonapi::ListDocument)
214 +/// because these responses carry an `included` array the join depends on,
215 +/// and a `PriceResource` here tolerates a missing `attributes` key that
216 +/// core's stricter `Resource` would reject. `links` and the URL-stripping
217 +/// helper are shared with core so the two paginators cannot drift.
218 #[derive(Debug, Clone, Deserialize)]
219 struct PricesDocument {
220 data: Vec<PriceResource>,
@@ -229,12 +224,6 @@ struct PricesDocument {
224 links: Option<Links>,
225 }
226
232 -#[derive(Debug, Clone, Deserialize)]
233 -struct Links {
234 - #[serde(default)]
235 - next: Option<String>,
236 -}
237 -
227 #[derive(Debug, Clone, Deserialize)]
228 struct PriceResource {
229 id: String,
@@ -380,12 +369,22 @@ mod tests {
369 assert_eq!(price.end_date.as_deref(), Some("2026-09-14"));
370 }
371
372 + // `links` now comes from core, so this pins that the shared type still
373 + // reads a real App Store Connect body — including the case where only
374 + // `self` is present, which means "last page".
375 #[test]
384 - fn strips_the_host_off_a_next_link() {
376 + fn a_next_link_is_absent_on_the_last_page() {
377 + let doc: PricesDocument = serde_json::from_str(MANUAL_PAGE).unwrap();
378 + assert!(doc.links.expect("links present").next.is_none());
379 +
380 + let with_next = MANUAL_PAGE.replace(
381 + r#""links": { "self": "https://api.appstoreconnect.apple.com/v1/x" }"#,
382 + r#""links": { "next": "https://api.appstoreconnect.apple.com/v1/y?cursor=AQ" }"#,
383 + );
384 + let doc: PricesDocument = serde_json::from_str(&with_next).unwrap();
385 assert_eq!(
386 - path_and_query("https://api.appstoreconnect.apple.com/v1/appPriceSchedules/1/automaticPrices?cursor=AQ").as_deref(),
387 - Some("/v1/appPriceSchedules/1/automaticPrices?cursor=AQ")
386 + path_and_query(&doc.links.unwrap().next.unwrap()).as_deref(),
387 + Some("/v1/y?cursor=AQ")
388 );
389 - assert_eq!(path_and_query("not-a-url"), None);
389 }
390 }
crates/signing/src/certificate.rs
+6 -7
@@ -23,7 +23,7 @@
23 use async_trait::async_trait;
24 use reqwest::Method;
25 use serde::{Deserialize, Serialize};
26 -use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource};
26 +use smbcloud_ascapi_core::jsonapi::{CreateBody, CreateData, Document, Resource};
27 use smbcloud_ascapi_core::Client;
28 use smbcloud_ascapi_core::Result;
29
@@ -180,14 +180,13 @@ impl CertificatesApi for Client {
180 if let Some(certificate_type) = filter_type {
181 query.push(("filter[certificateType]", certificate_type.as_api_str()));
182 }
183 - // Apple's default page size is 20, which a team with several
184 - // platforms and a few years of history will exceed.
183 + // 200 rows per page instead of Apple's default 20; `list_all` follows
184 + // `links.next` so several platforms plus years of history never
185 + // truncate.
186 query.push(("limit", "200"));
187
187 - let doc: ListDocument<CertificateAttributes> = self
188 - .request(Method::GET, "/v1/certificates", &query, None::<&()>)
189 - .await?;
190 - Ok(doc.data)
188 + self.list_all::<CertificateAttributes>("/v1/certificates", &query)
189 + .await
190 }
191
192 async fn create_certificate(
crates/signing/src/profile.rs
+6 -7
@@ -34,7 +34,7 @@ use async_trait::async_trait;
34 use reqwest::Method;
35 use serde::{Deserialize, Serialize};
36 use smbcloud_ascapi_core::jsonapi::{
37 - CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToMany, ToOne,
37 + CreateBody, CreateData, Document, Resource, ResourceId, ToMany, ToOne,
38 };
39 use smbcloud_ascapi_core::Client;
40 use smbcloud_ascapi_core::Result;
@@ -303,14 +303,13 @@ impl ProfilesApi for Client {
303 if let Some(profile_type) = filter_type {
304 query.push(("filter[profileType]", profile_type.as_api_str()));
305 }
306 - // Apple's default page size is 20, which a team carrying a profile
307 - // per app per platform will exceed.
306 + // 200 rows per page instead of Apple's default 20; `list_all` follows
307 + // `links.next` so a team with one profile per app per platform is
308 + // never truncated.
309 query.push(("limit", "200"));
310
310 - let doc: ListDocument<ProfileAttributes> = self
311 - .request(Method::GET, "/v1/profiles", &query, None::<&()>)
312 - .await?;
313 - Ok(doc.data)
311 + self.list_all::<ProfileAttributes>("/v1/profiles", &query)
312 + .await
313 }
314
315 async fn get_profile_content(&self, id: &str) -> Result<Profile> {