Follow links.next on every list endpoint
App Store Connect pages collections (default 20 rows, max 200) and points at the next page in links.next. Every list call read only the first page, so a team with more than one page of apps, bundle IDs, certificates or profiles got a short list with no error. A bundle ID past row 200 looked unregistered. Client::list_all walks links.next and returns all rows. ListDocument now deserializes links, and path_and_query moves from the pricing crate into core so the pricing paginator and list_all share it. All list endpoints in aso and signing go through list_all. The existing limit=200 on bundle IDs, certificates and profiles stays, so those still need fewer round trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 8, 2026 at 23:10 UTC
b22aee15916478fa70a786d5705b01505886d04e
13 files changed
+298
-78
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
+4
-6
@@ -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;
@@ -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/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/core/src/client.rs
+127
-1
@@ -1,12 +1,37 @@
1
use crate::auth::ApiKey;
2
use crate::error::{Error, Result};
3
-use crate::jsonapi::ErrorDocument;
3
+use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
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.
@@ -90,6 +115,40 @@ impl Client {
115
Ok(serde_json::from_slice(&bytes)?)
116
}
117
118
+ /// Read an entire collection by following `links.next`, so a caller sees
119
+ /// every row rather than Apple's first page.
120
+ ///
121
+ /// `path`/`query` describe the first page only. Each later page is
122
+ /// requested from the absolute `links.next` Apple returns, which already
123
+ /// carries the original `include`/`filter`/`limit` alongside its cursor —
124
+ /// re-appending `query` there would duplicate every parameter.
125
+ ///
126
+ /// No page size is chosen here. Apple's per-collection maximums differ and
127
+ /// only the caller knows which collection it is reading; picking a size
128
+ /// shapes the request, where following the cursor does not. Callers that
129
+ /// want 200-row pages pass `("limit", "200")` in `query`, as they already
130
+ /// do.
131
+ pub async fn list_all<A>(&self, path: &str, query: &[(&str, &str)]) -> Result<Vec<Resource<A>>>
132
+ where
133
+ A: DeserializeOwned,
134
+ {
135
+ let mut rows = Vec::new();
136
+ let mut page = Page::First { path, query };
137
+
138
+ loop {
139
+ let (page_path, page_query) = page.request();
140
+ let doc: ListDocument<A> = self
141
+ .request(Method::GET, page_path, page_query, None::<&()>)
142
+ .await?;
143
+ rows.extend(doc.data);
144
+
145
+ let Some(next) = next_page(doc.links.and_then(|links| links.next)) else {
146
+ return Ok(rows);
147
+ };
148
+ page = next;
149
+ }
150
+ }
151
+
152
/// Send a request that returns no body on success (typically `DELETE`).
153
pub async fn request_no_content<B: Serialize>(
154
&self,
@@ -177,6 +236,17 @@ impl Client {
236
}
237
}
238
239
+/// Decide the page that follows one carrying this `links.next`.
240
+///
241
+/// Returns `None` when the collection is done. Two ways that happens:
242
+/// Apple offered no `next` link, or it offered one we cannot turn into a
243
+/// path. The second case ends the walk instead of failing it — stopping
244
+/// early shows up as a short list, where a hard failure would take down a
245
+/// command that was otherwise working. See [`path_and_query`].
246
+fn next_page(next_link: Option<String>) -> Option<Page<'static>> {
247
+ Some(Page::Next(path_and_query(&next_link?)?))
248
+}
249
+
250
fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
251
let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
252
Ok(doc) if !doc.errors.is_empty() => doc
@@ -192,3 +262,59 @@ fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
262
detail,
263
}
264
}
265
+
266
+#[cfg(test)]
267
+mod tests {
268
+ use super::*;
269
+
270
+ // `links.next` is the whole paging decision, so it is pinned against the
271
+ // URL shape App Store Connect actually sends rather than a hand-made one.
272
+ #[test]
273
+ fn follows_a_next_link_as_a_path() {
274
+ let link =
275
+ "https://api.appstoreconnect.apple.com/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D";
276
+ assert_eq!(
277
+ next_page(Some(link.into())),
278
+ Some(Page::Next(
279
+ "/v1/apps?limit%5Bapps%5D=200&cursor=AQ%3D%3D".into()
280
+ ))
281
+ );
282
+ }
283
+
284
+ /// A collection is done when Apple stops offering a `next` link — which
285
+ /// is also how it signals a single-page collection.
286
+ #[test]
287
+ fn ends_the_walk_on_a_last_page() {
288
+ assert_eq!(next_page(None), None);
289
+ }
290
+
291
+ /// Stopping early shows up as a short list; failing here would take down
292
+ /// a command that was otherwise working.
293
+ #[test]
294
+ fn ends_the_walk_on_a_link_it_cannot_parse() {
295
+ assert_eq!(next_page(Some("/v1/apps?cursor=AQ".into())), None);
296
+ assert_eq!(
297
+ next_page(Some("https://api.appstoreconnect.apple.com".into())),
298
+ None
299
+ );
300
+ }
301
+
302
+ /// The first page is the caller's request, unchanged.
303
+ #[test]
304
+ fn first_page_keeps_the_callers_query() {
305
+ let query = [("limit", "200"), ("filter[bundleId]", "com.x.y")];
306
+ let page = Page::First {
307
+ path: "/v1/apps",
308
+ query: &query,
309
+ };
310
+ assert_eq!(page.request(), ("/v1/apps", &query[..]));
311
+ }
312
+
313
+ /// Apple's `next` URL already carries `include`/`filter`/`limit`, so
314
+ /// re-appending the original query would duplicate every parameter.
315
+ #[test]
316
+ fn later_pages_send_only_the_path_apple_named() {
317
+ let page = Page::Next("/v1/apps?limit=200&cursor=AQ".into());
318
+ assert_eq!(page.request(), ("/v1/apps?limit=200&cursor=AQ", &[][..]));
319
+ }
320
+}
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/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> {