main
rs 293 lines 8.77 KB
Raw
1 use reqwest::Method;
2 use serde::de::DeserializeOwned;
3 use serde::{Deserialize, Serialize};
4
5 use crate::auth::{ServiceAccount, TokenCache};
6 use crate::error::{Error, Result};
7 use crate::image::{Image, ImageType};
8 use crate::listing::{Listing, ListingFields};
9 use crate::track::Track;
10
11 const 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)]
15 pub 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 `:`.
23 pub(crate) fn edits_path(package: &str, suffix: &str) -> String {
24 format!("/androidpublisher/v3/applications/{package}/edits{suffix}")
25 }
26
27 pub(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
34 pub struct PlayClient {
35 http: reqwest::Client,
36 account: ServiceAccount,
37 tokens: TokenCache,
38 root: String,
39 }
40
41 impl 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)]
267 mod 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 }