Skip to main content

smbcloud_ascapi_core/
jsonapi.rs

1//! Shared JSON:API envelope types used across every App Metadata resource.
2//! Resource-specific attribute structs live in their own modules
3//! (`app`, `app_store_version`, ...) and plug into these generics.
4
5use serde::{Deserialize, Serialize};
6
7/// A single-resource JSON:API document — the body of e.g. `GET
8/// /v1/apps/{id}` or the response to a `POST`/`PATCH`.
9#[derive(Debug, Clone, Deserialize)]
10pub struct Document<A> {
11    pub data: Resource<A>,
12}
13
14/// A single-resource document whose `data` may be `null`, as Apple returns
15/// for a to-one related resource that isn't set, e.g. `GET
16/// /v1/appStoreVersions/{id}/build` on a version with no build attached.
17#[derive(Debug, Clone, Deserialize)]
18pub struct OptionalDocument<A> {
19    pub data: Option<Resource<A>>,
20}
21
22/// A resource-collection JSON:API document — the body of e.g. `GET
23/// /v1/apps`.
24///
25/// Collections are paginated. Apple caps `limit` at 200 per page and
26/// advertises the following page in `links.next`; a list endpoint that
27/// reads only `data` therefore silently truncates to one page, which is
28/// indistinguishable from "there were only N of these". Use
29/// [`Client::list_all`](crate::Client::list_all) rather than a bare
30/// `request` for any collection that can exceed 200 rows.
31#[derive(Debug, Clone, Deserialize)]
32pub struct ListDocument<A> {
33    pub data: Vec<Resource<A>>,
34    #[serde(default)]
35    pub links: Option<Links>,
36}
37
38/// The JSON:API pagination links on a collection response.
39///
40/// Only `next` is read: paging stops the moment Apple stops offering a
41/// following page. `self`/`first`/`prev` are absent rather than ignored
42/// because nothing here would use them, and a `prev` pointer invites a
43/// caller to loop backwards through a collection that can change
44/// underneath it.
45#[derive(Debug, Clone, Deserialize)]
46pub struct Links {
47    /// Absolute URL of the next page, already carrying the caller's
48    /// `include`/`filter`/`limit` parameters plus a `cursor`.
49    #[serde(default)]
50    pub next: Option<String>,
51}
52
53/// A single JSON:API resource object: its id, type, and typed attributes.
54/// Relationships are intentionally left untyped (`serde_json::Value`) —
55/// this crate builds relationship payloads explicitly on requests rather
56/// than typing every relationship shape App Store Connect can return.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Resource<A> {
59    pub id: String,
60    #[serde(rename = "type")]
61    pub resource_type: String,
62    pub attributes: A,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub relationships: Option<serde_json::Value>,
65}
66
67/// A bare `{ type, id }` resource identifier, used to point a create
68/// request's relationships at an existing resource.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ResourceId {
71    #[serde(rename = "type")]
72    pub resource_type: &'static str,
73    pub id: String,
74}
75
76/// A to-one relationship payload, e.g. `relationships.app` on a
77/// `POST /v1/appStoreVersions` body.
78#[derive(Debug, Clone, Serialize)]
79pub struct ToOne {
80    pub data: ResourceId,
81}
82
83/// A to-many relationship payload, e.g. `relationships.certificates` on a
84/// `POST /v1/profiles` body.
85///
86/// Apple distinguishes an empty array from an absent relationship: sending
87/// `{"data": []}` for `devices` means "no devices", which a development
88/// profile rejects, while omitting the key entirely is what an App Store
89/// profile wants. Callers that mean "absent" should pass `None` for the
90/// whole field rather than an empty `ToMany`.
91#[derive(Debug, Clone, Serialize)]
92pub struct ToMany {
93    pub data: Vec<ResourceId>,
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct CreateBody<A, R> {
98    pub data: CreateData<A, R>,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct CreateData<A, R> {
103    #[serde(rename = "type")]
104    pub resource_type: &'static str,
105    pub attributes: A,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub relationships: Option<R>,
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct UpdateBody<A> {
112    pub data: UpdateData<A>,
113}
114
115/// A `PATCH` body that updates only relationships (no attributes) — e.g.
116/// attaching a `Build` to an `AppStoreVersion`.
117#[derive(Debug, Clone, Serialize)]
118pub struct UpdateRelationshipsBody<R> {
119    pub data: UpdateRelationshipsData<R>,
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct UpdateRelationshipsData<R> {
124    #[serde(rename = "type")]
125    pub resource_type: &'static str,
126    pub id: String,
127    pub relationships: R,
128}
129
130#[derive(Debug, Clone, Serialize)]
131pub struct UpdateData<A> {
132    #[serde(rename = "type")]
133    pub resource_type: &'static str,
134    pub id: String,
135    pub attributes: A,
136}
137
138/// The `errors` envelope App Store Connect returns on non-2xx responses.
139#[derive(Debug, Clone, Deserialize, Default)]
140pub struct ErrorDocument {
141    #[serde(default)]
142    pub errors: Vec<ApiError>,
143}
144
145#[derive(Debug, Clone, Deserialize)]
146pub struct ApiError {
147    pub status: String,
148    pub code: String,
149    pub title: String,
150    #[serde(default)]
151    pub detail: String,
152}
153
154/// Turn an absolute [`Links::next`] URL into the path-and-query form
155/// [`Client::request`](crate::Client::request) wants, since that method
156/// prefixes its own base URL and cannot be handed a whole URL.
157///
158/// Returns `None` for anything unparseable, which a paging caller treats
159/// as "no more pages" rather than an error — stopping early is recoverable
160/// and shows up as a short list, where a hard failure would take down a
161/// command that was otherwise working.
162pub fn path_and_query(url: &str) -> Option<String> {
163    let after_scheme = url.split_once("://")?.1;
164    let (_host, rest) = after_scheme.split_once('/')?;
165    Some(format!("/{rest}"))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use serde::Deserialize;
172
173    #[derive(Debug, Deserialize)]
174    struct Attrs {
175        name: String,
176    }
177
178    /// A real collection page. `links.next` is what tells a paging caller
179    /// there is more, so it is pinned against the shape Apple sends.
180    #[test]
181    fn reads_a_next_link_from_a_collection_page() {
182        let doc: ListDocument<Attrs> = serde_json::from_value(serde_json::json!({
183            "data": [{ "id": "1", "type": "apps", "attributes": { "name": "Klepon" } }],
184            "links": {
185                "self": "https://api.appstoreconnect.apple.com/v1/apps?limit=200",
186                "next": "https://api.appstoreconnect.apple.com/v1/apps?limit=200&cursor=AQ%3D%3D"
187            }
188        }))
189        .unwrap();
190
191        assert_eq!(doc.data.len(), 1);
192        assert_eq!(doc.data[0].attributes.name, "Klepon");
193        assert_eq!(
194            doc.links.expect("links present").next.as_deref(),
195            Some("https://api.appstoreconnect.apple.com/v1/apps?limit=200&cursor=AQ%3D%3D")
196        );
197    }
198
199    /// Both spellings of "last page" Apple uses: `links` present with only a
200    /// `self`, and `links` missing entirely. A single-page collection looks
201    /// exactly like this, so neither may be an error.
202    #[test]
203    fn a_page_without_a_next_link_is_the_last_one() {
204        let with_self_only: ListDocument<Attrs> = serde_json::from_value(serde_json::json!({
205            "data": [],
206            "links": { "self": "https://api.appstoreconnect.apple.com/v1/apps?limit=200" }
207        }))
208        .unwrap();
209        assert!(with_self_only.links.expect("links present").next.is_none());
210
211        let without_links: ListDocument<Attrs> =
212            serde_json::from_value(serde_json::json!({ "data": [] })).unwrap();
213        assert!(without_links.links.is_none());
214    }
215
216    /// `request` prefixes its own base URL, so handing it a whole URL would
217    /// produce `https://api.appstoreconnect.apple.comhttps://...`.
218    #[test]
219    fn strips_the_host_off_a_next_link() {
220        assert_eq!(
221            path_and_query("https://api.appstoreconnect.apple.com/v1/apps?cursor=AQ").as_deref(),
222            Some("/v1/apps?cursor=AQ")
223        );
224        // Percent-encoded query values must survive untouched — decoding
225        // them would let `&` inside a cursor split the parameter.
226        assert_eq!(
227            path_and_query("https://api.appstoreconnect.apple.com/v1/apps?cursor=AQ%3D%3D")
228                .as_deref(),
229            Some("/v1/apps?cursor=AQ%3D%3D")
230        );
231    }
232
233    #[test]
234    fn gives_up_on_a_link_that_is_not_a_whole_url() {
235        assert_eq!(path_and_query("/v1/apps?cursor=AQ"), None);
236        assert_eq!(
237            path_and_query("https://api.appstoreconnect.apple.com"),
238            None
239        );
240    }
241}