Skip to main content

smbcloud_ascapi_play/
client.rs

1use reqwest::Method;
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4
5use crate::auth::{ServiceAccount, TokenCache};
6use crate::error::{Error, Result};
7use crate::image::{Image, ImageType};
8use crate::listing::{Listing, ListingFields};
9use crate::track::Track;
10
11const API_ROOT: &str = "https://androidpublisher.googleapis.com";
12
13/// An open edit. Nothing in it is visible to users until it is committed.
14#[derive(Debug, Clone, Deserialize)]
15pub struct Edit {
16    pub id: String,
17    #[serde(default, rename = "expiryTimeSeconds")]
18    pub expiry_time_seconds: Option<String>,
19}
20
21/// Build `/androidpublisher/v3/applications/{package}/edits…`. `suffix` is
22/// appended verbatim, so it carries its own leading `/` or `:`.
23pub(crate) fn edits_path(package: &str, suffix: &str) -> String {
24    format!("/androidpublisher/v3/applications/{package}/edits{suffix}")
25}
26
27pub(crate) fn upload_path(package: &str, edit_id: &str, locale: &str, image: ImageType) -> String {
28    format!(
29        "/upload/androidpublisher/v3/applications/{package}/edits/{edit_id}/listings/{locale}/{}?uploadType=media",
30        image.api_name()
31    )
32}
33
34pub struct PlayClient {
35    http: reqwest::Client,
36    account: ServiceAccount,
37    tokens: TokenCache,
38    root: String,
39}
40
41impl PlayClient {
42    pub fn new(account: ServiceAccount) -> Self {
43        Self {
44            http: reqwest::Client::new(),
45            account,
46            tokens: TokenCache::new(),
47            root: API_ROOT.to_string(),
48        }
49    }
50
51    /// The service account's email, for diagnostics.
52    pub fn account_email(&self) -> &str {
53        &self.account.client_email
54    }
55
56    async fn send(
57        &self,
58        method: Method,
59        path_and_query: &str,
60        body: Option<(&str, Vec<u8>)>,
61    ) -> Result<reqwest::Response> {
62        let token = self.tokens.get(&self.http, &self.account).await?;
63        let mut request = self
64            .http
65            .request(method, format!("{}{path_and_query}", self.root))
66            .bearer_auth(token);
67        if let Some((content_type, bytes)) = body {
68            request = request.header("Content-Type", content_type).body(bytes);
69        }
70        let response = request.send().await?;
71        let status = response.status();
72        if status.is_success() {
73            Ok(response)
74        } else {
75            Err(Error::Api {
76                status: status.as_u16(),
77                detail: response.text().await.unwrap_or_default(),
78            })
79        }
80    }
81
82    async fn json<T: DeserializeOwned>(
83        &self,
84        method: Method,
85        path: &str,
86        body: Option<&impl Serialize>,
87    ) -> Result<T> {
88        let body = match body {
89            Some(value) => Some(("application/json", serde_json::to_vec(value)?)),
90            None => (method == Method::POST).then_some(("application/json", b"{}".to_vec())),
91        };
92        let response = self.send(method, path, body).await?;
93        Ok(response.json().await?)
94    }
95
96    async fn no_body(&self, method: Method, path: &str) -> Result<()> {
97        self.send(method, path, None).await?;
98        Ok(())
99    }
100
101    pub async fn insert_edit(&self, package: &str) -> Result<Edit> {
102        self.json(Method::POST, &edits_path(package, ""), None::<&()>)
103            .await
104    }
105
106    pub async fn get_edit(&self, package: &str, edit_id: &str) -> Result<Edit> {
107        self.json(
108            Method::GET,
109            &edits_path(package, &format!("/{edit_id}")),
110            None::<&()>,
111        )
112        .await
113    }
114
115    pub async fn validate_edit(&self, package: &str, edit_id: &str) -> Result<()> {
116        let path = edits_path(package, &format!("/{edit_id}:validate"));
117        self.send(
118            Method::POST,
119            &path,
120            Some(("application/json", b"{}".to_vec())),
121        )
122        .await?;
123        Ok(())
124    }
125
126    pub async fn commit_edit(&self, package: &str, edit_id: &str) -> Result<()> {
127        let path = edits_path(package, &format!("/{edit_id}:commit"));
128        self.send(
129            Method::POST,
130            &path,
131            Some(("application/json", b"{}".to_vec())),
132        )
133        .await?;
134        Ok(())
135    }
136
137    pub async fn delete_edit(&self, package: &str, edit_id: &str) -> Result<()> {
138        self.no_body(Method::DELETE, &edits_path(package, &format!("/{edit_id}")))
139            .await
140    }
141
142    pub async fn list_listings(&self, package: &str, edit_id: &str) -> Result<Vec<Listing>> {
143        #[derive(Deserialize)]
144        struct Page {
145            #[serde(default)]
146            listings: Vec<Listing>,
147        }
148        let page: Page = self
149            .json(
150                Method::GET,
151                &edits_path(package, &format!("/{edit_id}/listings")),
152                None::<&()>,
153            )
154            .await?;
155        Ok(page.listings)
156    }
157
158    pub async fn get_listing(&self, package: &str, edit_id: &str, locale: &str) -> Result<Listing> {
159        self.json(
160            Method::GET,
161            &edits_path(package, &format!("/{edit_id}/listings/{locale}")),
162            None::<&()>,
163        )
164        .await
165    }
166
167    /// `listings.patch`; a locale the app does not have yet is created with
168    /// `listings.update` (PUT), which Play treats as an upsert.
169    pub async fn patch_listing(
170        &self,
171        package: &str,
172        edit_id: &str,
173        locale: &str,
174        fields: &ListingFields,
175    ) -> Result<Listing> {
176        let path = edits_path(package, &format!("/{edit_id}/listings/{locale}"));
177        match self.json(Method::PATCH, &path, Some(fields)).await {
178            Err(Error::Api { status: 404, .. }) => {
179                self.json(Method::PUT, &path, Some(fields)).await
180            }
181            other => other,
182        }
183    }
184
185    pub async fn list_images(
186        &self,
187        package: &str,
188        edit_id: &str,
189        locale: &str,
190        image_type: ImageType,
191    ) -> Result<Vec<Image>> {
192        #[derive(Deserialize)]
193        struct Page {
194            #[serde(default)]
195            images: Vec<Image>,
196        }
197        let path = edits_path(
198            package,
199            &format!("/{edit_id}/listings/{locale}/{}", image_type.api_name()),
200        );
201        let page: Page = self.json(Method::GET, &path, None::<&()>).await?;
202        Ok(page.images)
203    }
204
205    pub async fn upload_image(
206        &self,
207        package: &str,
208        edit_id: &str,
209        locale: &str,
210        image_type: ImageType,
211        content_type: &str,
212        bytes: Vec<u8>,
213    ) -> Result<Image> {
214        #[derive(Deserialize)]
215        struct Uploaded {
216            image: Image,
217        }
218        let path = upload_path(package, edit_id, locale, image_type);
219        let response = self
220            .send(Method::POST, &path, Some((content_type, bytes)))
221            .await?;
222        let uploaded: Uploaded = response.json().await?;
223        Ok(uploaded.image)
224    }
225
226    /// Remove every image in a bucket; returns how many Play reports deleted.
227    pub async fn delete_all_images(
228        &self,
229        package: &str,
230        edit_id: &str,
231        locale: &str,
232        image_type: ImageType,
233    ) -> Result<usize> {
234        #[derive(Deserialize)]
235        struct Deleted {
236            #[serde(default)]
237            deleted: Vec<Image>,
238        }
239        let path = edits_path(
240            package,
241            &format!("/{edit_id}/listings/{locale}/{}", image_type.api_name()),
242        );
243        let deleted: Deleted = self.json(Method::DELETE, &path, None::<&()>).await?;
244        Ok(deleted.deleted.len())
245    }
246
247    pub async fn get_track(&self, package: &str, edit_id: &str, track: &str) -> Result<Track> {
248        self.json(
249            Method::GET,
250            &edits_path(package, &format!("/{edit_id}/tracks/{track}")),
251            None::<&()>,
252        )
253        .await
254    }
255
256    pub async fn update_track(&self, package: &str, edit_id: &str, track: &Track) -> Result<Track> {
257        self.json(
258            Method::PUT,
259            &edits_path(package, &format!("/{edit_id}/tracks/{}", track.track)),
260            Some(track),
261        )
262        .await
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn edit_urls_follow_the_v3_layout() {
272        assert_eq!(
273            edits_path("xyz.smbcloud.SiGit", ""),
274            "/androidpublisher/v3/applications/xyz.smbcloud.SiGit/edits"
275        );
276        assert_eq!(
277            edits_path("p", "/E1:commit"),
278            "/androidpublisher/v3/applications/p/edits/E1:commit"
279        );
280        assert_eq!(
281            edits_path("p", "/E1/listings/en-US"),
282            "/androidpublisher/v3/applications/p/edits/E1/listings/en-US"
283        );
284    }
285
286    #[test]
287    fn uploads_use_the_upload_host_path_with_media_type() {
288        assert_eq!(
289            upload_path("p", "E1", "en-US", ImageType::PhoneScreenshots),
290            "/upload/androidpublisher/v3/applications/p/edits/E1/listings/en-US/phoneScreenshots?uploadType=media"
291        );
292    }
293}