main
rs 241 lines 8.35 KB
Raw
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
5 use 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)]
10 pub 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)]
18 pub 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)]
32 pub 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)]
46 pub 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)]
58 pub 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)]
70 pub 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)]
79 pub 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)]
92 pub struct ToMany {
93 pub data: Vec<ResourceId>,
94 }
95
96 #[derive(Debug, Clone, Serialize)]
97 pub struct CreateBody<A, R> {
98 pub data: CreateData<A, R>,
99 }
100
101 #[derive(Debug, Clone, Serialize)]
102 pub 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)]
111 pub 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)]
118 pub struct UpdateRelationshipsBody<R> {
119 pub data: UpdateRelationshipsData<R>,
120 }
121
122 #[derive(Debug, Clone, Serialize)]
123 pub 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)]
131 pub 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)]
140 pub struct ErrorDocument {
141 #[serde(default)]
142 pub errors: Vec<ApiError>,
143 }
144
145 #[derive(Debug, Clone, Deserialize)]
146 pub 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.
162 pub 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)]
169 mod 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 }