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 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)]
24pub 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)]
38pub 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.
46/// Relationships are intentionally left untyped (`serde_json::Value`) —
47/// this crate builds relationship payloads explicitly on requests rather
48/// than typing every relationship shape App Store Connect can return.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Resource<A> {
51    pub id: String,
52    #[serde(rename = "type")]
53    pub resource_type: String,
54    pub attributes: A,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub relationships: Option<serde_json::Value>,
57}
58
59/// A bare `{ type, id }` resource identifier, used to point a create
60/// request's relationships at an existing resource.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ResourceId {
63    #[serde(rename = "type")]
64    pub resource_type: &'static str,
65    pub id: String,
66}
67
68/// A to-one relationship payload, e.g. `relationships.app` on a
69/// `POST /v1/appStoreVersions` body.
70#[derive(Debug, Clone, Serialize)]
71pub struct ToOne {
72    pub data: ResourceId,
73}
74
75/// A to-many relationship payload, e.g. `relationships.certificates` on a
76/// `POST /v1/profiles` body.
77///
78/// Apple distinguishes an empty array from an absent relationship: sending
79/// `{"data": []}` for `devices` means "no devices", which a development
80/// profile rejects, while omitting the key entirely is what an App Store
81/// profile wants. Callers that mean "absent" should pass `None` for the
82/// whole field rather than an empty `ToMany`.
83#[derive(Debug, Clone, Serialize)]
84pub struct ToMany {
85    pub data: Vec<ResourceId>,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct CreateBody<A, R> {
90    pub data: CreateData<A, R>,
91}
92
93#[derive(Debug, Clone, Serialize)]
94pub struct CreateData<A, R> {
95    #[serde(rename = "type")]
96    pub resource_type: &'static str,
97    pub attributes: A,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub relationships: Option<R>,
100}
101
102#[derive(Debug, Clone, Serialize)]
103pub struct UpdateBody<A> {
104    pub data: UpdateData<A>,
105}
106
107/// A `PATCH` body that updates only relationships (no attributes) — e.g.
108/// attaching a `Build` to an `AppStoreVersion`.
109#[derive(Debug, Clone, Serialize)]
110pub struct UpdateRelationshipsBody<R> {
111    pub data: UpdateRelationshipsData<R>,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct UpdateRelationshipsData<R> {
116    #[serde(rename = "type")]
117    pub resource_type: &'static str,
118    pub id: String,
119    pub relationships: R,
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct UpdateData<A> {
124    #[serde(rename = "type")]
125    pub resource_type: &'static str,
126    pub id: String,
127    pub attributes: A,
128}
129
130/// The `errors` envelope App Store Connect returns on non-2xx responses.
131#[derive(Debug, Clone, Deserialize, Default)]
132pub struct ErrorDocument {
133    #[serde(default)]
134    pub errors: Vec<ApiError>,
135}
136
137#[derive(Debug, Clone, Deserialize)]
138pub struct ApiError {
139    pub status: String,
140    pub code: String,
141    pub title: String,
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.
154pub 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)]
161mod 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}