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> {