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