Initial commit

Seto Elkahfi committed Jul 16, 2026 at 20:48 UTC f34c8067f24de05b873258fc11624d36be5b6c47
21 files changed +3851
.gitignore new
+1
@@ -0,0 +1 @@
1 +/target
Cargo.lock new
+1592
@@ -0,0 +1,1592 @@
1 +# This file is automatically @generated by Cargo.
2 +# It is not intended for manual editing.
3 +version = 4
4 +
5 +[[package]]
6 +name = "anstream"
7 +version = "1.0.0"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
10 +dependencies = [
11 + "anstyle",
12 + "anstyle-parse",
13 + "anstyle-query",
14 + "anstyle-wincon",
15 + "colorchoice",
16 + "is_terminal_polyfill",
17 + "utf8parse",
18 +]
19 +
20 +[[package]]
21 +name = "anstyle"
22 +version = "1.0.14"
23 +source = "registry+https://github.com/rust-lang/crates.io-index"
24 +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
25 +
26 +[[package]]
27 +name = "anstyle-parse"
28 +version = "1.0.0"
29 +source = "registry+https://github.com/rust-lang/crates.io-index"
30 +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
31 +dependencies = [
32 + "utf8parse",
33 +]
34 +
35 +[[package]]
36 +name = "anstyle-query"
37 +version = "1.1.5"
38 +source = "registry+https://github.com/rust-lang/crates.io-index"
39 +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
40 +dependencies = [
41 + "windows-sys 0.61.2",
42 +]
43 +
44 +[[package]]
45 +name = "anstyle-wincon"
46 +version = "3.0.11"
47 +source = "registry+https://github.com/rust-lang/crates.io-index"
48 +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
49 +dependencies = [
50 + "anstyle",
51 + "once_cell_polyfill",
52 + "windows-sys 0.61.2",
53 +]
54 +
55 +[[package]]
56 +name = "anyhow"
57 +version = "1.0.103"
58 +source = "registry+https://github.com/rust-lang/crates.io-index"
59 +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
60 +
61 +[[package]]
62 +name = "atomic-waker"
63 +version = "1.1.2"
64 +source = "registry+https://github.com/rust-lang/crates.io-index"
65 +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
66 +
67 +[[package]]
68 +name = "autocfg"
69 +version = "1.5.1"
70 +source = "registry+https://github.com/rust-lang/crates.io-index"
71 +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
72 +
73 +[[package]]
74 +name = "base64"
75 +version = "0.22.1"
76 +source = "registry+https://github.com/rust-lang/crates.io-index"
77 +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
78 +
79 +[[package]]
80 +name = "bitflags"
81 +version = "2.13.1"
82 +source = "registry+https://github.com/rust-lang/crates.io-index"
83 +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
84 +
85 +[[package]]
86 +name = "bumpalo"
87 +version = "3.20.3"
88 +source = "registry+https://github.com/rust-lang/crates.io-index"
89 +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
90 +
91 +[[package]]
92 +name = "bytes"
93 +version = "1.12.1"
94 +source = "registry+https://github.com/rust-lang/crates.io-index"
95 +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
96 +
97 +[[package]]
98 +name = "cc"
99 +version = "1.2.67"
100 +source = "registry+https://github.com/rust-lang/crates.io-index"
101 +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
102 +dependencies = [
103 + "find-msvc-tools",
104 + "shlex",
105 +]
106 +
107 +[[package]]
108 +name = "cfg-if"
109 +version = "1.0.4"
110 +source = "registry+https://github.com/rust-lang/crates.io-index"
111 +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
112 +
113 +[[package]]
114 +name = "cfg_aliases"
115 +version = "0.2.1"
116 +source = "registry+https://github.com/rust-lang/crates.io-index"
117 +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
118 +
119 +[[package]]
120 +name = "chacha20"
121 +version = "0.10.1"
122 +source = "registry+https://github.com/rust-lang/crates.io-index"
123 +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
124 +dependencies = [
125 + "cfg-if",
126 + "cpufeatures",
127 + "rand_core",
128 +]
129 +
130 +[[package]]
131 +name = "clap"
132 +version = "4.6.2"
133 +source = "registry+https://github.com/rust-lang/crates.io-index"
134 +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
135 +dependencies = [
136 + "clap_builder",
137 + "clap_derive",
138 +]
139 +
140 +[[package]]
141 +name = "clap_builder"
142 +version = "4.6.2"
143 +source = "registry+https://github.com/rust-lang/crates.io-index"
144 +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
145 +dependencies = [
146 + "anstream",
147 + "anstyle",
148 + "clap_lex",
149 + "strsim",
150 +]
151 +
152 +[[package]]
153 +name = "clap_derive"
154 +version = "4.6.1"
155 +source = "registry+https://github.com/rust-lang/crates.io-index"
156 +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
157 +dependencies = [
158 + "heck",
159 + "proc-macro2",
160 + "quote",
161 + "syn",
162 +]
163 +
164 +[[package]]
165 +name = "clap_lex"
166 +version = "1.1.0"
167 +source = "registry+https://github.com/rust-lang/crates.io-index"
168 +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
169 +
170 +[[package]]
171 +name = "colorchoice"
172 +version = "1.0.5"
173 +source = "registry+https://github.com/rust-lang/crates.io-index"
174 +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
175 +
176 +[[package]]
177 +name = "core-foundation"
178 +version = "0.10.1"
179 +source = "registry+https://github.com/rust-lang/crates.io-index"
180 +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
181 +dependencies = [
182 + "core-foundation-sys",
183 + "libc",
184 +]
185 +
186 +[[package]]
187 +name = "core-foundation-sys"
188 +version = "0.8.7"
189 +source = "registry+https://github.com/rust-lang/crates.io-index"
190 +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
191 +
192 +[[package]]
193 +name = "cpufeatures"
194 +version = "0.3.0"
195 +source = "registry+https://github.com/rust-lang/crates.io-index"
196 +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
197 +dependencies = [
198 + "libc",
199 +]
200 +
201 +[[package]]
202 +name = "deranged"
203 +version = "0.5.8"
204 +source = "registry+https://github.com/rust-lang/crates.io-index"
205 +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
206 +
207 +[[package]]
208 +name = "displaydoc"
209 +version = "0.2.6"
210 +source = "registry+https://github.com/rust-lang/crates.io-index"
211 +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
212 +dependencies = [
213 + "proc-macro2",
214 + "quote",
215 + "syn",
216 +]
217 +
218 +[[package]]
219 +name = "find-msvc-tools"
220 +version = "0.1.9"
221 +source = "registry+https://github.com/rust-lang/crates.io-index"
222 +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
223 +
224 +[[package]]
225 +name = "form_urlencoded"
226 +version = "1.2.2"
227 +source = "registry+https://github.com/rust-lang/crates.io-index"
228 +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
229 +dependencies = [
230 + "percent-encoding",
231 +]
232 +
233 +[[package]]
234 +name = "futures-channel"
235 +version = "0.3.32"
236 +source = "registry+https://github.com/rust-lang/crates.io-index"
237 +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
238 +dependencies = [
239 + "futures-core",
240 +]
241 +
242 +[[package]]
243 +name = "futures-core"
244 +version = "0.3.32"
245 +source = "registry+https://github.com/rust-lang/crates.io-index"
246 +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
247 +
248 +[[package]]
249 +name = "futures-task"
250 +version = "0.3.32"
251 +source = "registry+https://github.com/rust-lang/crates.io-index"
252 +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
253 +
254 +[[package]]
255 +name = "futures-util"
256 +version = "0.3.32"
257 +source = "registry+https://github.com/rust-lang/crates.io-index"
258 +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
259 +dependencies = [
260 + "futures-core",
261 + "futures-task",
262 + "pin-project-lite",
263 + "slab",
264 +]
265 +
266 +[[package]]
267 +name = "getrandom"
268 +version = "0.2.17"
269 +source = "registry+https://github.com/rust-lang/crates.io-index"
270 +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
271 +dependencies = [
272 + "cfg-if",
273 + "js-sys",
274 + "libc",
275 + "wasi",
276 + "wasm-bindgen",
277 +]
278 +
279 +[[package]]
280 +name = "getrandom"
281 +version = "0.4.3"
282 +source = "registry+https://github.com/rust-lang/crates.io-index"
283 +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
284 +dependencies = [
285 + "cfg-if",
286 + "js-sys",
287 + "libc",
288 + "r-efi",
289 + "rand_core",
290 + "wasm-bindgen",
291 +]
292 +
293 +[[package]]
294 +name = "heck"
295 +version = "0.5.0"
296 +source = "registry+https://github.com/rust-lang/crates.io-index"
297 +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
298 +
299 +[[package]]
300 +name = "http"
301 +version = "1.4.2"
302 +source = "registry+https://github.com/rust-lang/crates.io-index"
303 +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
304 +dependencies = [
305 + "bytes",
306 + "itoa",
307 +]
308 +
309 +[[package]]
310 +name = "http-body"
311 +version = "1.1.0"
312 +source = "registry+https://github.com/rust-lang/crates.io-index"
313 +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
314 +dependencies = [
315 + "bytes",
316 + "http",
317 +]
318 +
319 +[[package]]
320 +name = "http-body-util"
321 +version = "0.1.4"
322 +source = "registry+https://github.com/rust-lang/crates.io-index"
323 +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
324 +dependencies = [
325 + "bytes",
326 + "futures-core",
327 + "http",
328 + "http-body",
329 + "pin-project-lite",
330 +]
331 +
332 +[[package]]
333 +name = "httparse"
334 +version = "1.10.1"
335 +source = "registry+https://github.com/rust-lang/crates.io-index"
336 +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
337 +
338 +[[package]]
339 +name = "hyper"
340 +version = "1.10.1"
341 +source = "registry+https://github.com/rust-lang/crates.io-index"
342 +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
343 +dependencies = [
344 + "atomic-waker",
345 + "bytes",
346 + "futures-channel",
347 + "futures-core",
348 + "http",
349 + "http-body",
350 + "httparse",
351 + "itoa",
352 + "pin-project-lite",
353 + "smallvec",
354 + "tokio",
355 + "want",
356 +]
357 +
358 +[[package]]
359 +name = "hyper-rustls"
360 +version = "0.27.9"
361 +source = "registry+https://github.com/rust-lang/crates.io-index"
362 +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
363 +dependencies = [
364 + "http",
365 + "hyper",
366 + "hyper-util",
367 + "rustls",
368 + "rustls-native-certs",
369 + "tokio",
370 + "tokio-rustls",
371 + "tower-service",
372 +]
373 +
374 +[[package]]
375 +name = "hyper-util"
376 +version = "0.1.20"
377 +source = "registry+https://github.com/rust-lang/crates.io-index"
378 +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
379 +dependencies = [
380 + "base64",
381 + "bytes",
382 + "futures-channel",
383 + "futures-util",
384 + "http",
385 + "http-body",
386 + "hyper",
387 + "ipnet",
388 + "libc",
389 + "percent-encoding",
390 + "pin-project-lite",
391 + "socket2",
392 + "tokio",
393 + "tower-service",
394 + "tracing",
395 +]
396 +
397 +[[package]]
398 +name = "icu_collections"
399 +version = "2.2.0"
400 +source = "registry+https://github.com/rust-lang/crates.io-index"
401 +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
402 +dependencies = [
403 + "displaydoc",
404 + "potential_utf",
405 + "utf8_iter",
406 + "yoke",
407 + "zerofrom",
408 + "zerovec",
409 +]
410 +
411 +[[package]]
412 +name = "icu_locale_core"
413 +version = "2.2.0"
414 +source = "registry+https://github.com/rust-lang/crates.io-index"
415 +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
416 +dependencies = [
417 + "displaydoc",
418 + "litemap",
419 + "tinystr",
420 + "writeable",
421 + "zerovec",
422 +]
423 +
424 +[[package]]
425 +name = "icu_normalizer"
426 +version = "2.2.0"
427 +source = "registry+https://github.com/rust-lang/crates.io-index"
428 +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
429 +dependencies = [
430 + "icu_collections",
431 + "icu_normalizer_data",
432 + "icu_properties",
433 + "icu_provider",
434 + "smallvec",
435 + "zerovec",
436 +]
437 +
438 +[[package]]
439 +name = "icu_normalizer_data"
440 +version = "2.2.0"
441 +source = "registry+https://github.com/rust-lang/crates.io-index"
442 +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
443 +
444 +[[package]]
445 +name = "icu_properties"
446 +version = "2.2.0"
447 +source = "registry+https://github.com/rust-lang/crates.io-index"
448 +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
449 +dependencies = [
450 + "icu_collections",
451 + "icu_locale_core",
452 + "icu_properties_data",
453 + "icu_provider",
454 + "zerotrie",
455 + "zerovec",
456 +]
457 +
458 +[[package]]
459 +name = "icu_properties_data"
460 +version = "2.2.0"
461 +source = "registry+https://github.com/rust-lang/crates.io-index"
462 +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
463 +
464 +[[package]]
465 +name = "icu_provider"
466 +version = "2.2.0"
467 +source = "registry+https://github.com/rust-lang/crates.io-index"
468 +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
469 +dependencies = [
470 + "displaydoc",
471 + "icu_locale_core",
472 + "writeable",
473 + "yoke",
474 + "zerofrom",
475 + "zerotrie",
476 + "zerovec",
477 +]
478 +
479 +[[package]]
480 +name = "idna"
481 +version = "1.1.0"
482 +source = "registry+https://github.com/rust-lang/crates.io-index"
483 +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
484 +dependencies = [
485 + "idna_adapter",
486 + "smallvec",
487 + "utf8_iter",
488 +]
489 +
490 +[[package]]
491 +name = "idna_adapter"
492 +version = "1.2.2"
493 +source = "registry+https://github.com/rust-lang/crates.io-index"
494 +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
495 +dependencies = [
496 + "icu_normalizer",
497 + "icu_properties",
498 +]
499 +
500 +[[package]]
501 +name = "ipnet"
502 +version = "2.12.0"
503 +source = "registry+https://github.com/rust-lang/crates.io-index"
504 +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
505 +
506 +[[package]]
507 +name = "is_terminal_polyfill"
508 +version = "1.70.2"
509 +source = "registry+https://github.com/rust-lang/crates.io-index"
510 +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
511 +
512 +[[package]]
513 +name = "itoa"
514 +version = "1.0.18"
515 +source = "registry+https://github.com/rust-lang/crates.io-index"
516 +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
517 +
518 +[[package]]
519 +name = "js-sys"
520 +version = "0.3.103"
521 +source = "registry+https://github.com/rust-lang/crates.io-index"
522 +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
523 +dependencies = [
524 + "cfg-if",
525 + "futures-util",
526 + "wasm-bindgen",
527 +]
528 +
529 +[[package]]
530 +name = "jsonwebtoken"
531 +version = "9.3.1"
532 +source = "registry+https://github.com/rust-lang/crates.io-index"
533 +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
534 +dependencies = [
535 + "base64",
536 + "js-sys",
537 + "pem",
538 + "ring",
539 + "serde",
540 + "serde_json",
541 + "simple_asn1",
542 +]
543 +
544 +[[package]]
545 +name = "libc"
546 +version = "0.2.186"
547 +source = "registry+https://github.com/rust-lang/crates.io-index"
548 +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
549 +
550 +[[package]]
551 +name = "litemap"
552 +version = "0.8.2"
553 +source = "registry+https://github.com/rust-lang/crates.io-index"
554 +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
555 +
556 +[[package]]
557 +name = "log"
558 +version = "0.4.33"
559 +source = "registry+https://github.com/rust-lang/crates.io-index"
560 +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
561 +
562 +[[package]]
563 +name = "lru-slab"
564 +version = "0.1.2"
565 +source = "registry+https://github.com/rust-lang/crates.io-index"
566 +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
567 +
568 +[[package]]
569 +name = "md5"
570 +version = "0.7.0"
571 +source = "registry+https://github.com/rust-lang/crates.io-index"
572 +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
573 +
574 +[[package]]
575 +name = "memchr"
576 +version = "2.8.3"
577 +source = "registry+https://github.com/rust-lang/crates.io-index"
578 +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
579 +
580 +[[package]]
581 +name = "mio"
582 +version = "1.2.2"
583 +source = "registry+https://github.com/rust-lang/crates.io-index"
584 +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
585 +dependencies = [
586 + "libc",
587 + "wasi",
588 + "windows-sys 0.61.2",
589 +]
590 +
591 +[[package]]
592 +name = "num-bigint"
593 +version = "0.4.8"
594 +source = "registry+https://github.com/rust-lang/crates.io-index"
595 +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
596 +dependencies = [
597 + "num-integer",
598 + "num-traits",
599 +]
600 +
601 +[[package]]
602 +name = "num-conv"
603 +version = "0.2.2"
604 +source = "registry+https://github.com/rust-lang/crates.io-index"
605 +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
606 +
607 +[[package]]
608 +name = "num-integer"
609 +version = "0.1.46"
610 +source = "registry+https://github.com/rust-lang/crates.io-index"
611 +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
612 +dependencies = [
613 + "num-traits",
614 +]
615 +
616 +[[package]]
617 +name = "num-traits"
618 +version = "0.2.19"
619 +source = "registry+https://github.com/rust-lang/crates.io-index"
620 +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
621 +dependencies = [
622 + "autocfg",
623 +]
624 +
625 +[[package]]
626 +name = "once_cell"
627 +version = "1.21.4"
628 +source = "registry+https://github.com/rust-lang/crates.io-index"
629 +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
630 +
631 +[[package]]
632 +name = "once_cell_polyfill"
633 +version = "1.70.2"
634 +source = "registry+https://github.com/rust-lang/crates.io-index"
635 +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
636 +
637 +[[package]]
638 +name = "openssl-probe"
639 +version = "0.2.1"
640 +source = "registry+https://github.com/rust-lang/crates.io-index"
641 +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
642 +
643 +[[package]]
644 +name = "pem"
645 +version = "3.0.6"
646 +source = "registry+https://github.com/rust-lang/crates.io-index"
647 +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
648 +dependencies = [
649 + "base64",
650 + "serde_core",
651 +]
652 +
653 +[[package]]
654 +name = "percent-encoding"
655 +version = "2.3.2"
656 +source = "registry+https://github.com/rust-lang/crates.io-index"
657 +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
658 +
659 +[[package]]
660 +name = "pin-project-lite"
661 +version = "0.2.17"
662 +source = "registry+https://github.com/rust-lang/crates.io-index"
663 +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
664 +
665 +[[package]]
666 +name = "potential_utf"
667 +version = "0.1.5"
668 +source = "registry+https://github.com/rust-lang/crates.io-index"
669 +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
670 +dependencies = [
671 + "zerovec",
672 +]
673 +
674 +[[package]]
675 +name = "powerfmt"
676 +version = "0.2.0"
677 +source = "registry+https://github.com/rust-lang/crates.io-index"
678 +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
679 +
680 +[[package]]
681 +name = "proc-macro2"
682 +version = "1.0.106"
683 +source = "registry+https://github.com/rust-lang/crates.io-index"
684 +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
685 +dependencies = [
686 + "unicode-ident",
687 +]
688 +
689 +[[package]]
690 +name = "quinn"
691 +version = "0.11.11"
692 +source = "registry+https://github.com/rust-lang/crates.io-index"
693 +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
694 +dependencies = [
695 + "bytes",
696 + "cfg_aliases",
697 + "pin-project-lite",
698 + "quinn-proto",
699 + "quinn-udp",
700 + "rustc-hash",
701 + "rustls",
702 + "socket2",
703 + "thiserror",
704 + "tokio",
705 + "tracing",
706 + "web-time",
707 +]
708 +
709 +[[package]]
710 +name = "quinn-proto"
711 +version = "0.11.16"
712 +source = "registry+https://github.com/rust-lang/crates.io-index"
713 +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
714 +dependencies = [
715 + "bytes",
716 + "getrandom 0.4.3",
717 + "lru-slab",
718 + "rand",
719 + "rand_pcg",
720 + "ring",
721 + "rustc-hash",
722 + "rustls",
723 + "rustls-pki-types",
724 + "slab",
725 + "thiserror",
726 + "tinyvec",
727 + "tracing",
728 + "web-time",
729 +]
730 +
731 +[[package]]
732 +name = "quinn-udp"
733 +version = "0.5.15"
734 +source = "registry+https://github.com/rust-lang/crates.io-index"
735 +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
736 +dependencies = [
737 + "cfg_aliases",
738 + "libc",
739 + "once_cell",
740 + "socket2",
741 + "tracing",
742 + "windows-sys 0.61.2",
743 +]
744 +
745 +[[package]]
746 +name = "quote"
747 +version = "1.0.46"
748 +source = "registry+https://github.com/rust-lang/crates.io-index"
749 +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
750 +dependencies = [
751 + "proc-macro2",
752 +]
753 +
754 +[[package]]
755 +name = "r-efi"
756 +version = "6.0.0"
757 +source = "registry+https://github.com/rust-lang/crates.io-index"
758 +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
759 +
760 +[[package]]
761 +name = "rand"
762 +version = "0.10.2"
763 +source = "registry+https://github.com/rust-lang/crates.io-index"
764 +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
765 +dependencies = [
766 + "chacha20",
767 + "getrandom 0.4.3",
768 + "rand_core",
769 +]
770 +
771 +[[package]]
772 +name = "rand_core"
773 +version = "0.10.1"
774 +source = "registry+https://github.com/rust-lang/crates.io-index"
775 +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
776 +
777 +[[package]]
778 +name = "rand_pcg"
779 +version = "0.10.2"
780 +source = "registry+https://github.com/rust-lang/crates.io-index"
781 +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
782 +dependencies = [
783 + "rand_core",
784 +]
785 +
786 +[[package]]
787 +name = "reqwest"
788 +version = "0.12.28"
789 +source = "registry+https://github.com/rust-lang/crates.io-index"
790 +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
791 +dependencies = [
792 + "base64",
793 + "bytes",
794 + "futures-core",
795 + "http",
796 + "http-body",
797 + "http-body-util",
798 + "hyper",
799 + "hyper-rustls",
800 + "hyper-util",
801 + "js-sys",
802 + "log",
803 + "percent-encoding",
804 + "pin-project-lite",
805 + "quinn",
806 + "rustls",
807 + "rustls-native-certs",
808 + "rustls-pki-types",
809 + "serde",
810 + "serde_json",
811 + "serde_urlencoded",
812 + "sync_wrapper",
813 + "tokio",
814 + "tokio-rustls",
815 + "tower",
816 + "tower-http",
817 + "tower-service",
818 + "url",
819 + "wasm-bindgen",
820 + "wasm-bindgen-futures",
821 + "web-sys",
822 +]
823 +
824 +[[package]]
825 +name = "ring"
826 +version = "0.17.14"
827 +source = "registry+https://github.com/rust-lang/crates.io-index"
828 +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
829 +dependencies = [
830 + "cc",
831 + "cfg-if",
832 + "getrandom 0.2.17",
833 + "libc",
834 + "untrusted",
835 + "windows-sys 0.52.0",
836 +]
837 +
838 +[[package]]
839 +name = "rustc-hash"
840 +version = "2.1.3"
841 +source = "registry+https://github.com/rust-lang/crates.io-index"
842 +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
843 +
844 +[[package]]
845 +name = "rustls"
846 +version = "0.23.42"
847 +source = "registry+https://github.com/rust-lang/crates.io-index"
848 +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
849 +dependencies = [
850 + "once_cell",
851 + "ring",
852 + "rustls-pki-types",
853 + "rustls-webpki",
854 + "subtle",
855 + "zeroize",
856 +]
857 +
858 +[[package]]
859 +name = "rustls-native-certs"
860 +version = "0.8.4"
861 +source = "registry+https://github.com/rust-lang/crates.io-index"
862 +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
863 +dependencies = [
864 + "openssl-probe",
865 + "rustls-pki-types",
866 + "schannel",
867 + "security-framework",
868 +]
869 +
870 +[[package]]
871 +name = "rustls-pki-types"
872 +version = "1.15.0"
873 +source = "registry+https://github.com/rust-lang/crates.io-index"
874 +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
875 +dependencies = [
876 + "web-time",
877 + "zeroize",
878 +]
879 +
880 +[[package]]
881 +name = "rustls-webpki"
882 +version = "0.103.13"
883 +source = "registry+https://github.com/rust-lang/crates.io-index"
884 +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
885 +dependencies = [
886 + "ring",
887 + "rustls-pki-types",
888 + "untrusted",
889 +]
890 +
891 +[[package]]
892 +name = "rustversion"
893 +version = "1.0.23"
894 +source = "registry+https://github.com/rust-lang/crates.io-index"
895 +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
896 +
897 +[[package]]
898 +name = "ryu"
899 +version = "1.0.23"
900 +source = "registry+https://github.com/rust-lang/crates.io-index"
901 +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
902 +
903 +[[package]]
904 +name = "schannel"
905 +version = "0.1.29"
906 +source = "registry+https://github.com/rust-lang/crates.io-index"
907 +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
908 +dependencies = [
909 + "windows-sys 0.61.2",
910 +]
911 +
912 +[[package]]
913 +name = "security-framework"
914 +version = "3.7.0"
915 +source = "registry+https://github.com/rust-lang/crates.io-index"
916 +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
917 +dependencies = [
918 + "bitflags",
919 + "core-foundation",
920 + "core-foundation-sys",
921 + "libc",
922 + "security-framework-sys",
923 +]
924 +
925 +[[package]]
926 +name = "security-framework-sys"
927 +version = "2.17.0"
928 +source = "registry+https://github.com/rust-lang/crates.io-index"
929 +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
930 +dependencies = [
931 + "core-foundation-sys",
932 + "libc",
933 +]
934 +
935 +[[package]]
936 +name = "serde"
937 +version = "1.0.228"
938 +source = "registry+https://github.com/rust-lang/crates.io-index"
939 +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
940 +dependencies = [
941 + "serde_core",
942 + "serde_derive",
943 +]
944 +
945 +[[package]]
946 +name = "serde_core"
947 +version = "1.0.228"
948 +source = "registry+https://github.com/rust-lang/crates.io-index"
949 +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
950 +dependencies = [
951 + "serde_derive",
952 +]
953 +
954 +[[package]]
955 +name = "serde_derive"
956 +version = "1.0.228"
957 +source = "registry+https://github.com/rust-lang/crates.io-index"
958 +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
959 +dependencies = [
960 + "proc-macro2",
961 + "quote",
962 + "syn",
963 +]
964 +
965 +[[package]]
966 +name = "serde_json"
967 +version = "1.0.150"
968 +source = "registry+https://github.com/rust-lang/crates.io-index"
969 +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
970 +dependencies = [
971 + "itoa",
972 + "memchr",
973 + "serde",
974 + "serde_core",
975 + "zmij",
976 +]
977 +
978 +[[package]]
979 +name = "serde_urlencoded"
980 +version = "0.7.1"
981 +source = "registry+https://github.com/rust-lang/crates.io-index"
982 +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
983 +dependencies = [
984 + "form_urlencoded",
985 + "itoa",
986 + "ryu",
987 + "serde",
988 +]
989 +
990 +[[package]]
991 +name = "shlex"
992 +version = "2.0.1"
993 +source = "registry+https://github.com/rust-lang/crates.io-index"
994 +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
995 +
996 +[[package]]
997 +name = "simple_asn1"
998 +version = "0.6.4"
999 +source = "registry+https://github.com/rust-lang/crates.io-index"
1000 +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
1001 +dependencies = [
1002 + "num-bigint",
1003 + "num-traits",
1004 + "thiserror",
1005 + "time",
1006 +]
1007 +
1008 +[[package]]
1009 +name = "slab"
1010 +version = "0.4.12"
1011 +source = "registry+https://github.com/rust-lang/crates.io-index"
1012 +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1013 +
1014 +[[package]]
1015 +name = "smallvec"
1016 +version = "1.15.2"
1017 +source = "registry+https://github.com/rust-lang/crates.io-index"
1018 +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1019 +
1020 +[[package]]
1021 +name = "smbcloud-ascapi"
1022 +version = "0.1.0"
1023 +dependencies = [
1024 + "jsonwebtoken",
1025 + "md5",
1026 + "reqwest",
1027 + "serde",
1028 + "serde_json",
1029 + "thiserror",
1030 + "tokio",
1031 +]
1032 +
1033 +[[package]]
1034 +name = "smbcloud-ascapi-cli"
1035 +version = "0.1.0"
1036 +dependencies = [
1037 + "anyhow",
1038 + "clap",
1039 + "serde",
1040 + "serde_json",
1041 + "smbcloud-ascapi",
1042 + "tokio",
1043 +]
1044 +
1045 +[[package]]
1046 +name = "socket2"
1047 +version = "0.6.5"
1048 +source = "registry+https://github.com/rust-lang/crates.io-index"
1049 +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1050 +dependencies = [
1051 + "libc",
1052 + "windows-sys 0.61.2",
1053 +]
1054 +
1055 +[[package]]
1056 +name = "stable_deref_trait"
1057 +version = "1.2.1"
1058 +source = "registry+https://github.com/rust-lang/crates.io-index"
1059 +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1060 +
1061 +[[package]]
1062 +name = "strsim"
1063 +version = "0.11.1"
1064 +source = "registry+https://github.com/rust-lang/crates.io-index"
1065 +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1066 +
1067 +[[package]]
1068 +name = "subtle"
1069 +version = "2.6.1"
1070 +source = "registry+https://github.com/rust-lang/crates.io-index"
1071 +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1072 +
1073 +[[package]]
1074 +name = "syn"
1075 +version = "2.0.119"
1076 +source = "registry+https://github.com/rust-lang/crates.io-index"
1077 +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1078 +dependencies = [
1079 + "proc-macro2",
1080 + "quote",
1081 + "unicode-ident",
1082 +]
1083 +
1084 +[[package]]
1085 +name = "sync_wrapper"
1086 +version = "1.0.2"
1087 +source = "registry+https://github.com/rust-lang/crates.io-index"
1088 +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1089 +dependencies = [
1090 + "futures-core",
1091 +]
1092 +
1093 +[[package]]
1094 +name = "synstructure"
1095 +version = "0.13.2"
1096 +source = "registry+https://github.com/rust-lang/crates.io-index"
1097 +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1098 +dependencies = [
1099 + "proc-macro2",
1100 + "quote",
1101 + "syn",
1102 +]
1103 +
1104 +[[package]]
1105 +name = "thiserror"
1106 +version = "2.0.18"
1107 +source = "registry+https://github.com/rust-lang/crates.io-index"
1108 +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1109 +dependencies = [
1110 + "thiserror-impl",
1111 +]
1112 +
1113 +[[package]]
1114 +name = "thiserror-impl"
1115 +version = "2.0.18"
1116 +source = "registry+https://github.com/rust-lang/crates.io-index"
1117 +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1118 +dependencies = [
1119 + "proc-macro2",
1120 + "quote",
1121 + "syn",
1122 +]
1123 +
1124 +[[package]]
1125 +name = "time"
1126 +version = "0.3.53"
1127 +source = "registry+https://github.com/rust-lang/crates.io-index"
1128 +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
1129 +dependencies = [
1130 + "deranged",
1131 + "num-conv",
1132 + "powerfmt",
1133 + "serde_core",
1134 + "time-core",
1135 + "time-macros",
1136 +]
1137 +
1138 +[[package]]
1139 +name = "time-core"
1140 +version = "0.1.9"
1141 +source = "registry+https://github.com/rust-lang/crates.io-index"
1142 +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
1143 +
1144 +[[package]]
1145 +name = "time-macros"
1146 +version = "0.2.31"
1147 +source = "registry+https://github.com/rust-lang/crates.io-index"
1148 +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
1149 +dependencies = [
1150 + "num-conv",
1151 + "time-core",
1152 +]
1153 +
1154 +[[package]]
1155 +name = "tinystr"
1156 +version = "0.8.3"
1157 +source = "registry+https://github.com/rust-lang/crates.io-index"
1158 +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1159 +dependencies = [
1160 + "displaydoc",
1161 + "zerovec",
1162 +]
1163 +
1164 +[[package]]
1165 +name = "tinyvec"
1166 +version = "1.12.0"
1167 +source = "registry+https://github.com/rust-lang/crates.io-index"
1168 +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
1169 +dependencies = [
1170 + "tinyvec_macros",
1171 +]
1172 +
1173 +[[package]]
1174 +name = "tinyvec_macros"
1175 +version = "0.1.1"
1176 +source = "registry+https://github.com/rust-lang/crates.io-index"
1177 +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1178 +
1179 +[[package]]
1180 +name = "tokio"
1181 +version = "1.52.3"
1182 +source = "registry+https://github.com/rust-lang/crates.io-index"
1183 +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
1184 +dependencies = [
1185 + "bytes",
1186 + "libc",
1187 + "mio",
1188 + "pin-project-lite",
1189 + "socket2",
1190 + "tokio-macros",
1191 + "windows-sys 0.61.2",
1192 +]
1193 +
1194 +[[package]]
1195 +name = "tokio-macros"
1196 +version = "2.7.0"
1197 +source = "registry+https://github.com/rust-lang/crates.io-index"
1198 +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
1199 +dependencies = [
1200 + "proc-macro2",
1201 + "quote",
1202 + "syn",
1203 +]
1204 +
1205 +[[package]]
1206 +name = "tokio-rustls"
1207 +version = "0.26.4"
1208 +source = "registry+https://github.com/rust-lang/crates.io-index"
1209 +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
1210 +dependencies = [
1211 + "rustls",
1212 + "tokio",
1213 +]
1214 +
1215 +[[package]]
1216 +name = "tower"
1217 +version = "0.5.3"
1218 +source = "registry+https://github.com/rust-lang/crates.io-index"
1219 +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
1220 +dependencies = [
1221 + "futures-core",
1222 + "futures-util",
1223 + "pin-project-lite",
1224 + "sync_wrapper",
1225 + "tokio",
1226 + "tower-layer",
1227 + "tower-service",
1228 +]
1229 +
1230 +[[package]]
1231 +name = "tower-http"
1232 +version = "0.6.11"
1233 +source = "registry+https://github.com/rust-lang/crates.io-index"
1234 +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
1235 +dependencies = [
1236 + "bitflags",
1237 + "bytes",
1238 + "futures-util",
1239 + "http",
1240 + "http-body",
1241 + "pin-project-lite",
1242 + "tower",
1243 + "tower-layer",
1244 + "tower-service",
1245 + "url",
1246 +]
1247 +
1248 +[[package]]
1249 +name = "tower-layer"
1250 +version = "0.3.3"
1251 +source = "registry+https://github.com/rust-lang/crates.io-index"
1252 +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1253 +
1254 +[[package]]
1255 +name = "tower-service"
1256 +version = "0.3.3"
1257 +source = "registry+https://github.com/rust-lang/crates.io-index"
1258 +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1259 +
1260 +[[package]]
1261 +name = "tracing"
1262 +version = "0.1.44"
1263 +source = "registry+https://github.com/rust-lang/crates.io-index"
1264 +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1265 +dependencies = [
1266 + "pin-project-lite",
1267 + "tracing-core",
1268 +]
1269 +
1270 +[[package]]
1271 +name = "tracing-core"
1272 +version = "0.1.36"
1273 +source = "registry+https://github.com/rust-lang/crates.io-index"
1274 +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1275 +dependencies = [
1276 + "once_cell",
1277 +]
1278 +
1279 +[[package]]
1280 +name = "try-lock"
1281 +version = "0.2.5"
1282 +source = "registry+https://github.com/rust-lang/crates.io-index"
1283 +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1284 +
1285 +[[package]]
1286 +name = "unicode-ident"
1287 +version = "1.0.24"
1288 +source = "registry+https://github.com/rust-lang/crates.io-index"
1289 +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1290 +
1291 +[[package]]
1292 +name = "untrusted"
1293 +version = "0.9.0"
1294 +source = "registry+https://github.com/rust-lang/crates.io-index"
1295 +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1296 +
1297 +[[package]]
1298 +name = "url"
1299 +version = "2.5.8"
1300 +source = "registry+https://github.com/rust-lang/crates.io-index"
1301 +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1302 +dependencies = [
1303 + "form_urlencoded",
1304 + "idna",
1305 + "percent-encoding",
1306 + "serde",
1307 +]
1308 +
1309 +[[package]]
1310 +name = "utf8_iter"
1311 +version = "1.0.4"
1312 +source = "registry+https://github.com/rust-lang/crates.io-index"
1313 +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1314 +
1315 +[[package]]
1316 +name = "utf8parse"
1317 +version = "0.2.2"
1318 +source = "registry+https://github.com/rust-lang/crates.io-index"
1319 +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1320 +
1321 +[[package]]
1322 +name = "want"
1323 +version = "0.3.1"
1324 +source = "registry+https://github.com/rust-lang/crates.io-index"
1325 +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
1326 +dependencies = [
1327 + "try-lock",
1328 +]
1329 +
1330 +[[package]]
1331 +name = "wasi"
1332 +version = "0.11.1+wasi-snapshot-preview1"
1333 +source = "registry+https://github.com/rust-lang/crates.io-index"
1334 +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1335 +
1336 +[[package]]
1337 +name = "wasm-bindgen"
1338 +version = "0.2.126"
1339 +source = "registry+https://github.com/rust-lang/crates.io-index"
1340 +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
1341 +dependencies = [
1342 + "cfg-if",
1343 + "once_cell",
1344 + "rustversion",
1345 + "wasm-bindgen-macro",
1346 + "wasm-bindgen-shared",
1347 +]
1348 +
1349 +[[package]]
1350 +name = "wasm-bindgen-futures"
1351 +version = "0.4.76"
1352 +source = "registry+https://github.com/rust-lang/crates.io-index"
1353 +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
1354 +dependencies = [
1355 + "js-sys",
1356 + "wasm-bindgen",
1357 +]
1358 +
1359 +[[package]]
1360 +name = "wasm-bindgen-macro"
1361 +version = "0.2.126"
1362 +source = "registry+https://github.com/rust-lang/crates.io-index"
1363 +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
1364 +dependencies = [
1365 + "quote",
1366 + "wasm-bindgen-macro-support",
1367 +]
1368 +
1369 +[[package]]
1370 +name = "wasm-bindgen-macro-support"
1371 +version = "0.2.126"
1372 +source = "registry+https://github.com/rust-lang/crates.io-index"
1373 +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
1374 +dependencies = [
1375 + "bumpalo",
1376 + "proc-macro2",
1377 + "quote",
1378 + "syn",
1379 + "wasm-bindgen-shared",
1380 +]
1381 +
1382 +[[package]]
1383 +name = "wasm-bindgen-shared"
1384 +version = "0.2.126"
1385 +source = "registry+https://github.com/rust-lang/crates.io-index"
1386 +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
1387 +dependencies = [
1388 + "unicode-ident",
1389 +]
1390 +
1391 +[[package]]
1392 +name = "web-sys"
1393 +version = "0.3.103"
1394 +source = "registry+https://github.com/rust-lang/crates.io-index"
1395 +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
1396 +dependencies = [
1397 + "js-sys",
1398 + "wasm-bindgen",
1399 +]
1400 +
1401 +[[package]]
1402 +name = "web-time"
1403 +version = "1.1.0"
1404 +source = "registry+https://github.com/rust-lang/crates.io-index"
1405 +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
1406 +dependencies = [
1407 + "js-sys",
1408 + "wasm-bindgen",
1409 +]
1410 +
1411 +[[package]]
1412 +name = "windows-link"
1413 +version = "0.2.1"
1414 +source = "registry+https://github.com/rust-lang/crates.io-index"
1415 +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1416 +
1417 +[[package]]
1418 +name = "windows-sys"
1419 +version = "0.52.0"
1420 +source = "registry+https://github.com/rust-lang/crates.io-index"
1421 +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1422 +dependencies = [
1423 + "windows-targets",
1424 +]
1425 +
1426 +[[package]]
1427 +name = "windows-sys"
1428 +version = "0.61.2"
1429 +source = "registry+https://github.com/rust-lang/crates.io-index"
1430 +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1431 +dependencies = [
1432 + "windows-link",
1433 +]
1434 +
1435 +[[package]]
1436 +name = "windows-targets"
1437 +version = "0.52.6"
1438 +source = "registry+https://github.com/rust-lang/crates.io-index"
1439 +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1440 +dependencies = [
1441 + "windows_aarch64_gnullvm",
1442 + "windows_aarch64_msvc",
1443 + "windows_i686_gnu",
1444 + "windows_i686_gnullvm",
1445 + "windows_i686_msvc",
1446 + "windows_x86_64_gnu",
1447 + "windows_x86_64_gnullvm",
1448 + "windows_x86_64_msvc",
1449 +]
1450 +
1451 +[[package]]
1452 +name = "windows_aarch64_gnullvm"
1453 +version = "0.52.6"
1454 +source = "registry+https://github.com/rust-lang/crates.io-index"
1455 +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1456 +
1457 +[[package]]
1458 +name = "windows_aarch64_msvc"
1459 +version = "0.52.6"
1460 +source = "registry+https://github.com/rust-lang/crates.io-index"
1461 +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1462 +
1463 +[[package]]
1464 +name = "windows_i686_gnu"
1465 +version = "0.52.6"
1466 +source = "registry+https://github.com/rust-lang/crates.io-index"
1467 +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1468 +
1469 +[[package]]
1470 +name = "windows_i686_gnullvm"
1471 +version = "0.52.6"
1472 +source = "registry+https://github.com/rust-lang/crates.io-index"
1473 +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1474 +
1475 +[[package]]
1476 +name = "windows_i686_msvc"
1477 +version = "0.52.6"
1478 +source = "registry+https://github.com/rust-lang/crates.io-index"
1479 +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1480 +
1481 +[[package]]
1482 +name = "windows_x86_64_gnu"
1483 +version = "0.52.6"
1484 +source = "registry+https://github.com/rust-lang/crates.io-index"
1485 +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1486 +
1487 +[[package]]
1488 +name = "windows_x86_64_gnullvm"
1489 +version = "0.52.6"
1490 +source = "registry+https://github.com/rust-lang/crates.io-index"
1491 +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1492 +
1493 +[[package]]
1494 +name = "windows_x86_64_msvc"
1495 +version = "0.52.6"
1496 +source = "registry+https://github.com/rust-lang/crates.io-index"
1497 +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1498 +
1499 +[[package]]
1500 +name = "writeable"
1501 +version = "0.6.3"
1502 +source = "registry+https://github.com/rust-lang/crates.io-index"
1503 +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
1504 +
1505 +[[package]]
1506 +name = "yoke"
1507 +version = "0.8.3"
1508 +source = "registry+https://github.com/rust-lang/crates.io-index"
1509 +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1510 +dependencies = [
1511 + "stable_deref_trait",
1512 + "yoke-derive",
1513 + "zerofrom",
1514 +]
1515 +
1516 +[[package]]
1517 +name = "yoke-derive"
1518 +version = "0.8.2"
1519 +source = "registry+https://github.com/rust-lang/crates.io-index"
1520 +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
1521 +dependencies = [
1522 + "proc-macro2",
1523 + "quote",
1524 + "syn",
1525 + "synstructure",
1526 +]
1527 +
1528 +[[package]]
1529 +name = "zerofrom"
1530 +version = "0.1.8"
1531 +source = "registry+https://github.com/rust-lang/crates.io-index"
1532 +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1533 +dependencies = [
1534 + "zerofrom-derive",
1535 +]
1536 +
1537 +[[package]]
1538 +name = "zerofrom-derive"
1539 +version = "0.1.7"
1540 +source = "registry+https://github.com/rust-lang/crates.io-index"
1541 +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
1542 +dependencies = [
1543 + "proc-macro2",
1544 + "quote",
1545 + "syn",
1546 + "synstructure",
1547 +]
1548 +
1549 +[[package]]
1550 +name = "zeroize"
1551 +version = "1.9.0"
1552 +source = "registry+https://github.com/rust-lang/crates.io-index"
1553 +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
1554 +
1555 +[[package]]
1556 +name = "zerotrie"
1557 +version = "0.2.4"
1558 +source = "registry+https://github.com/rust-lang/crates.io-index"
1559 +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
1560 +dependencies = [
1561 + "displaydoc",
1562 + "yoke",
1563 + "zerofrom",
1564 +]
1565 +
1566 +[[package]]
1567 +name = "zerovec"
1568 +version = "0.11.6"
1569 +source = "registry+https://github.com/rust-lang/crates.io-index"
1570 +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
1571 +dependencies = [
1572 + "yoke",
1573 + "zerofrom",
1574 + "zerovec-derive",
1575 +]
1576 +
1577 +[[package]]
1578 +name = "zerovec-derive"
1579 +version = "0.11.3"
1580 +source = "registry+https://github.com/rust-lang/crates.io-index"
1581 +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
1582 +dependencies = [
1583 + "proc-macro2",
1584 + "quote",
1585 + "syn",
1586 +]
1587 +
1588 +[[package]]
1589 +name = "zmij"
1590 +version = "1.0.23"
1591 +source = "registry+https://github.com/rust-lang/crates.io-index"
1592 +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml new
+21
@@ -0,0 +1,21 @@
1 +[workspace]
2 +resolver = "2"
3 +members = ["crates/ascapi", "crates/cli"]
4 +
5 +[workspace.package]
6 +version = "0.1.0"
7 +edition = "2021"
8 +authors = ["Seto Elkahfi <seto@smbcloud.xyz>"]
9 +license = "Apache-2.0"
10 +repository = "https://github.com/smbcloudXYZ/smbcloud-ascapi"
11 +
12 +[workspace.dependencies]
13 +smbcloud-ascapi = { version = "0.1.0", path = "crates/ascapi" }
14 +anyhow = "1"
15 +clap = "4"
16 +jsonwebtoken = "9"
17 +reqwest = { version = "0.12", default-features = false }
18 +serde = "1"
19 +serde_json = "1"
20 +thiserror = "2.0"
21 +tokio = "1"
README.md new
+3
@@ -0,0 +1,3 @@
1 +# smbcloud-ascapi
2 +
3 +Ascapi bolo bolo bolo.
crates/ascapi/Cargo.toml new
+23
@@ -0,0 +1,23 @@
1 +[package]
2 +name = "smbcloud-ascapi"
3 +version.workspace = true
4 +edition.workspace = true
5 +authors.workspace = true
6 +license.workspace = true
7 +repository.workspace = true
8 +description = "Embeddable client for the App Store Connect API's App Metadata resources (apps, app infos, app store versions, bundle IDs, and their localizations)."
9 +readme = "README.md"
10 +
11 +[lib]
12 +path = "src/ascapi.rs"
13 +
14 +[dependencies]
15 +jsonwebtoken = { workspace = true }
16 +md5 = "0.7"
17 +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
18 +serde = { workspace = true, features = ["derive"] }
19 +serde_json = { workspace = true }
20 +thiserror = { workspace = true }
21 +
22 +[dev-dependencies]
23 +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
crates/ascapi/src/app.rs new
+74
@@ -0,0 +1,74 @@
1 +//! `GET /v1/apps`, `GET`/`PATCH /v1/apps/{id}`. Note there is no
2 +//! `POST /v1/apps` — App Store Connect has no "create an app" endpoint.
3 +//! An app record comes into existence the first time you register its
4 +//! bundle ID (see [`crate::bundle_id`]) and give it a first
5 +//! [`crate::app_store_version`]; the App Store Connect UI (or, per
6 +//! platform, a build upload) is what actually provisions the `App` row.
7 +//! This module only reads and updates one that already exists.
8 +
9 +use crate::client::Client;
10 +use crate::error::Result;
11 +use crate::jsonapi::{Document, ListDocument, Resource, UpdateBody, UpdateData};
12 +use reqwest::Method;
13 +use serde::{Deserialize, Serialize};
14 +
15 +pub const RESOURCE_TYPE: &str = "apps";
16 +
17 +#[derive(Debug, Clone, Serialize, Deserialize)]
18 +#[serde(rename_all = "camelCase")]
19 +pub struct AppAttributes {
20 + pub name: Option<String>,
21 + pub bundle_id: Option<String>,
22 + pub sku: Option<String>,
23 + pub primary_locale: Option<String>,
24 + pub is_or_ever_was_made_for_kids: Option<bool>,
25 + pub content_rights_declaration: Option<String>,
26 +}
27 +
28 +pub type App = Resource<AppAttributes>;
29 +
30 +#[derive(Debug, Clone, Default, Serialize)]
31 +#[serde(rename_all = "camelCase")]
32 +pub struct AppUpdateAttributes {
33 + #[serde(skip_serializing_if = "Option::is_none")]
34 + pub primary_locale: Option<String>,
35 + #[serde(skip_serializing_if = "Option::is_none")]
36 + pub content_rights_declaration: Option<String>,
37 +}
38 +
39 +impl Client {
40 + /// `GET /v1/apps`, optionally narrowed with `filter[bundleId]` — the
41 + /// usual way to resolve an app's ASC id from the bundle identifier
42 + /// already baked into an Xcode project.
43 + pub async fn list_apps(&self, filter_bundle_id: Option<&str>) -> Result<Vec<App>> {
44 + let mut query = Vec::new();
45 + if let Some(bundle_id) = filter_bundle_id {
46 + query.push(("filter[bundleId]", bundle_id));
47 + }
48 + let doc: ListDocument<AppAttributes> = self
49 + .request(Method::GET, "/v1/apps", &query, None::<&()>)
50 + .await?;
51 + Ok(doc.data)
52 + }
53 +
54 + pub async fn get_app(&self, app_id: &str) -> Result<App> {
55 + let path = format!("/v1/apps/{app_id}");
56 + let doc: Document<AppAttributes> =
57 + self.request(Method::GET, &path, &[], None::<&()>).await?;
58 + Ok(doc.data)
59 + }
60 +
61 + pub async fn update_app(&self, app_id: &str, attributes: AppUpdateAttributes) -> Result<App> {
62 + let path = format!("/v1/apps/{app_id}");
63 + let body = UpdateBody {
64 + data: UpdateData {
65 + resource_type: RESOURCE_TYPE,
66 + id: app_id.to_string(),
67 + attributes,
68 + },
69 + };
70 + let doc: Document<AppAttributes> =
71 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
72 + Ok(doc.data)
73 + }
74 +}
crates/ascapi/src/app_info.rs new
+44
@@ -0,0 +1,44 @@
1 +//! An `AppInfo` is the platform-independent, category/age-rating side of an
2 +//! app's metadata; its localized name/subtitle live on
3 +//! [`crate::app_info_localization`]. App Store Connect creates and manages
4 +//! `AppInfo` rows itself (there is no create/delete endpoint) — this module
5 +//! only reads them and updates category/age-rating relationships.
6 +
7 +use crate::client::Client;
8 +use crate::error::Result;
9 +use crate::jsonapi::{Document, ListDocument, Resource};
10 +use reqwest::Method;
11 +use serde::{Deserialize, Serialize};
12 +
13 +pub const RESOURCE_TYPE: &str = "appInfos";
14 +
15 +#[derive(Debug, Clone, Serialize, Deserialize)]
16 +#[serde(rename_all = "camelCase")]
17 +pub struct AppInfoAttributes {
18 + pub app_store_state: Option<String>,
19 + pub app_store_age_rating: Option<String>,
20 + pub brazil_age_rating: Option<String>,
21 + pub kids_age_band: Option<String>,
22 +}
23 +
24 +pub type AppInfo = Resource<AppInfoAttributes>;
25 +
26 +impl Client {
27 + /// `GET /v1/apps/{app_id}/appInfos`. An app usually has one current
28 + /// `AppInfo` (plus, briefly, a second pending one while an edit awaits
29 + /// review) — this is the parent resource `AppInfoLocalization`s hang
30 + /// off of, and the id you need for `create_app_info_localization`.
31 + pub async fn list_app_infos(&self, app_id: &str) -> Result<Vec<AppInfo>> {
32 + let path = format!("/v1/apps/{app_id}/appInfos");
33 + let doc: ListDocument<AppInfoAttributes> =
34 + self.request(Method::GET, &path, &[], None::<&()>).await?;
35 + Ok(doc.data)
36 + }
37 +
38 + pub async fn get_app_info(&self, id: &str) -> Result<AppInfo> {
39 + let path = format!("/v1/appInfos/{id}");
40 + let doc: Document<AppInfoAttributes> =
41 + self.request(Method::GET, &path, &[], None::<&()>).await?;
42 + Ok(doc.data)
43 + }
44 +}
crates/ascapi/src/app_info_localization.rs new
+114
@@ -0,0 +1,114 @@
1 +use crate::client::Client;
2 +use crate::error::Result;
3 +use crate::jsonapi::{
4 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
5 + UpdateData,
6 +};
7 +use reqwest::Method;
8 +use serde::{Deserialize, Serialize};
9 +
10 +pub const RESOURCE_TYPE: &str = "appInfoLocalizations";
11 +
12 +#[derive(Debug, Clone, Serialize, Deserialize)]
13 +#[serde(rename_all = "camelCase")]
14 +pub struct AppInfoLocalizationAttributes {
15 + pub locale: String,
16 + pub name: Option<String>,
17 + pub subtitle: Option<String>,
18 + pub privacy_policy_url: Option<String>,
19 + pub privacy_choices_url: Option<String>,
20 + pub privacy_policy_text: Option<String>,
21 +}
22 +
23 +pub type AppInfoLocalization = Resource<AppInfoLocalizationAttributes>;
24 +
25 +#[derive(Debug, Clone, Serialize)]
26 +#[serde(rename_all = "camelCase")]
27 +pub struct AppInfoLocalizationCreateAttributes {
28 + pub locale: String,
29 + pub name: String,
30 + #[serde(skip_serializing_if = "Option::is_none")]
31 + pub subtitle: Option<String>,
32 + #[serde(skip_serializing_if = "Option::is_none")]
33 + pub privacy_policy_url: Option<String>,
34 +}
35 +
36 +#[derive(Debug, Clone, Default, Serialize)]
37 +#[serde(rename_all = "camelCase")]
38 +pub struct AppInfoLocalizationUpdateAttributes {
39 + #[serde(skip_serializing_if = "Option::is_none")]
40 + pub name: Option<String>,
41 + #[serde(skip_serializing_if = "Option::is_none")]
42 + pub subtitle: Option<String>,
43 + #[serde(skip_serializing_if = "Option::is_none")]
44 + pub privacy_policy_url: Option<String>,
45 +}
46 +
47 +#[derive(Debug, Clone, Serialize)]
48 +#[serde(rename_all = "camelCase")]
49 +pub struct AppInfoLocalizationRelationships {
50 + pub app_info: ToOne,
51 +}
52 +
53 +impl Client {
54 + pub async fn list_app_info_localizations(
55 + &self,
56 + app_info_id: &str,
57 + ) -> Result<Vec<AppInfoLocalization>> {
58 + let path = format!("/v1/appInfos/{app_info_id}/appInfoLocalizations");
59 + let doc: ListDocument<AppInfoLocalizationAttributes> =
60 + self.request(Method::GET, &path, &[], None::<&()>).await?;
61 + Ok(doc.data)
62 + }
63 +
64 + /// `POST /v1/appInfoLocalizations` — adds a locale's name/subtitle to an
65 + /// `AppInfo`.
66 + pub async fn create_app_info_localization(
67 + &self,
68 + app_info_id: &str,
69 + attributes: AppInfoLocalizationCreateAttributes,
70 + ) -> Result<AppInfoLocalization> {
71 + let body = CreateBody {
72 + data: CreateData {
73 + resource_type: RESOURCE_TYPE,
74 + attributes,
75 + relationships: Some(AppInfoLocalizationRelationships {
76 + app_info: ToOne {
77 + data: ResourceId {
78 + resource_type: crate::app_info::RESOURCE_TYPE,
79 + id: app_info_id.to_string(),
80 + },
81 + },
82 + }),
83 + },
84 + };
85 + let doc: Document<AppInfoLocalizationAttributes> = self
86 + .request(Method::POST, "/v1/appInfoLocalizations", &[], Some(&body))
87 + .await?;
88 + Ok(doc.data)
89 + }
90 +
91 + pub async fn update_app_info_localization(
92 + &self,
93 + id: &str,
94 + attributes: AppInfoLocalizationUpdateAttributes,
95 + ) -> Result<AppInfoLocalization> {
96 + let path = format!("/v1/appInfoLocalizations/{id}");
97 + let body = UpdateBody {
98 + data: UpdateData {
99 + resource_type: RESOURCE_TYPE,
100 + id: id.to_string(),
101 + attributes,
102 + },
103 + };
104 + let doc: Document<AppInfoLocalizationAttributes> =
105 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
106 + Ok(doc.data)
107 + }
108 +
109 + pub async fn delete_app_info_localization(&self, id: &str) -> Result<()> {
110 + let path = format!("/v1/appInfoLocalizations/{id}");
111 + self.request_no_content::<()>(Method::DELETE, &path, &[], None)
112 + .await
113 + }
114 +}
crates/ascapi/src/app_screenshot.rs new
+233
@@ -0,0 +1,233 @@
1 +//! An `AppScreenshot` is one image binary within an `AppScreenshotSet`.
2 +//! Uploading one is a three-step JSON:API + raw-HTTP dance (see
3 +//! <https://developer.apple.com/documentation/appstoreconnectapi/uploading-assets-to-app-store-connect>):
4 +//!
5 +//! 1. `POST /v1/appScreenshots` reserves the asset and returns pre-signed
6 +//! `uploadOperations` (one or more chunked PUT requests).
7 +//! 2. The image bytes are PUT to each operation's URL with its exact
8 +//! `requestHeaders` — these URLs are *not* App Store Connect API
9 +//! endpoints, so no bearer token is sent.
10 +//! 3. `PATCH /v1/appScreenshots/{id}` with `uploaded: true` and an MD5
11 +//! checksum of the file commits the upload.
12 +//!
13 +//! [`Client::upload_app_screenshot`] does all three steps in one call.
14 +
15 +use crate::client::Client;
16 +use crate::error::Result;
17 +use crate::jsonapi::{
18 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
19 + UpdateData,
20 +};
21 +use reqwest::Method;
22 +use serde::{Deserialize, Deserializer, Serialize};
23 +
24 +pub const RESOURCE_TYPE: &str = "appScreenshots";
25 +
26 +/// App Store Connect sends `uploadOperations: null` (not an omitted field)
27 +/// once a screenshot has been committed — plain `#[serde(default)]` only
28 +/// covers a *missing* field, not an explicit `null`, so `Vec<T>` fails to
29 +/// deserialize without this.
30 +fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
31 +where
32 + D: Deserializer<'de>,
33 + T: Default + Deserialize<'de>,
34 +{
35 + Ok(Option::deserialize(deserializer)?.unwrap_or_default())
36 +}
37 +
38 +#[derive(Debug, Clone, Serialize, Deserialize)]
39 +#[serde(rename_all = "camelCase")]
40 +pub struct RequestHeader {
41 + pub name: String,
42 + pub value: String,
43 +}
44 +
45 +#[derive(Debug, Clone, Serialize, Deserialize)]
46 +#[serde(rename_all = "camelCase")]
47 +pub struct UploadOperation {
48 + pub method: String,
49 + pub url: String,
50 + #[serde(default)]
51 + pub length: u64,
52 + #[serde(default)]
53 + pub offset: u64,
54 + #[serde(default, deserialize_with = "null_as_default")]
55 + pub request_headers: Vec<RequestHeader>,
56 +}
57 +
58 +#[derive(Debug, Clone, Serialize, Deserialize)]
59 +#[serde(rename_all = "camelCase")]
60 +pub struct AssetDeliveryState {
61 + pub state: String,
62 + #[serde(default, deserialize_with = "null_as_default")]
63 + pub errors: Vec<serde_json::Value>,
64 + #[serde(default, deserialize_with = "null_as_default")]
65 + pub warnings: Vec<serde_json::Value>,
66 +}
67 +
68 +#[derive(Debug, Clone, Serialize, Deserialize)]
69 +#[serde(rename_all = "camelCase")]
70 +pub struct AppScreenshotAttributes {
71 + pub file_name: String,
72 + pub file_size: u64,
73 + #[serde(default)]
74 + pub source_file_checksum: Option<String>,
75 + #[serde(default)]
76 + pub image_asset: Option<serde_json::Value>,
77 + #[serde(default)]
78 + pub asset_delivery_state: Option<AssetDeliveryState>,
79 + #[serde(default, deserialize_with = "null_as_default")]
80 + pub upload_operations: Vec<UploadOperation>,
81 + #[serde(default)]
82 + pub uploaded: Option<bool>,
83 +}
84 +
85 +pub type AppScreenshot = Resource<AppScreenshotAttributes>;
86 +
87 +#[derive(Debug, Clone, Serialize)]
88 +#[serde(rename_all = "camelCase")]
89 +pub struct AppScreenshotCreateAttributes {
90 + pub file_name: String,
91 + pub file_size: u64,
92 +}
93 +
94 +#[derive(Debug, Clone, Serialize)]
95 +#[serde(rename_all = "camelCase")]
96 +pub struct AppScreenshotSetRelationship {
97 + pub app_screenshot_set: ToOne,
98 +}
99 +
100 +#[derive(Debug, Clone, Serialize)]
101 +#[serde(rename_all = "camelCase")]
102 +pub struct AppScreenshotCommitAttributes {
103 + pub uploaded: bool,
104 + #[serde(skip_serializing_if = "Option::is_none")]
105 + pub source_file_checksum: Option<String>,
106 +}
107 +
108 +impl Client {
109 + /// `GET /v1/appScreenshotSets/{id}/appScreenshots`.
110 + pub async fn list_app_screenshots(
111 + &self,
112 + app_screenshot_set_id: &str,
113 + ) -> Result<Vec<AppScreenshot>> {
114 + let path = format!("/v1/appScreenshotSets/{app_screenshot_set_id}/appScreenshots");
115 + let doc: ListDocument<AppScreenshotAttributes> =
116 + self.request(Method::GET, &path, &[], None::<&()>).await?;
117 + Ok(doc.data)
118 + }
119 +
120 + /// `POST /v1/appScreenshots` — reserves the asset and returns
121 + /// pre-signed `uploadOperations`. Prefer [`Client::upload_app_screenshot`]
122 + /// unless you need to drive the upload/commit steps yourself.
123 + pub async fn create_app_screenshot(
124 + &self,
125 + app_screenshot_set_id: &str,
126 + attributes: AppScreenshotCreateAttributes,
127 + ) -> Result<AppScreenshot> {
128 + let body = CreateBody {
129 + data: CreateData {
130 + resource_type: RESOURCE_TYPE,
131 + attributes,
132 + relationships: Some(AppScreenshotSetRelationship {
133 + app_screenshot_set: ToOne {
134 + data: ResourceId {
135 + resource_type: crate::app_screenshot_set::RESOURCE_TYPE,
136 + id: app_screenshot_set_id.to_string(),
137 + },
138 + },
139 + }),
140 + },
141 + };
142 + let doc: Document<AppScreenshotAttributes> = self
143 + .request(Method::POST, "/v1/appScreenshots", &[], Some(&body))
144 + .await?;
145 + Ok(doc.data)
146 + }
147 +
148 + /// PUTs `bytes` to every one of `screenshot`'s reserved
149 + /// `uploadOperations`, slicing the buffer per operation's `offset` /
150 + /// `length`. Does not commit the upload — call
151 + /// [`Client::commit_app_screenshot`] (or use
152 + /// [`Client::upload_app_screenshot`]) after this succeeds.
153 + pub async fn upload_app_screenshot_bytes(
154 + &self,
155 + screenshot: &AppScreenshot,
156 + bytes: &[u8],
157 + ) -> Result<()> {
158 + for op in &screenshot.attributes.upload_operations {
159 + let start = op.offset as usize;
160 + let end = start + op.length as usize;
161 + let chunk = bytes
162 + .get(start..end)
163 + .map(|slice| slice.to_vec())
164 + .unwrap_or_default();
165 + let headers: Vec<(String, String)> = op
166 + .request_headers
167 + .iter()
168 + .map(|h| (h.name.clone(), h.value.clone()))
169 + .collect();
170 + let method: Method = op.method.parse().unwrap_or(Method::PUT);
171 + self.upload_bytes(method, &op.url, &headers, chunk).await?;
172 + }
173 + Ok(())
174 + }
175 +
176 + /// `PATCH /v1/appScreenshots/{id}` with `uploaded: true` — tells App
177 + /// Store Connect the binary is fully transferred so it can start
178 + /// processing/validating the asset.
179 + pub async fn commit_app_screenshot(
180 + &self,
181 + id: &str,
182 + source_file_checksum: String,
183 + ) -> Result<AppScreenshot> {
184 + let path = format!("/v1/appScreenshots/{id}");
185 + let body = UpdateBody {
186 + data: UpdateData {
187 + resource_type: RESOURCE_TYPE,
188 + id: id.to_string(),
189 + attributes: AppScreenshotCommitAttributes {
190 + uploaded: true,
191 + source_file_checksum: Some(source_file_checksum),
192 + },
193 + },
194 + };
195 + let doc: Document<AppScreenshotAttributes> =
196 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
197 + Ok(doc.data)
198 + }
199 +
200 + /// `DELETE /v1/appScreenshots/{id}`.
201 + pub async fn delete_app_screenshot(&self, id: &str) -> Result<()> {
202 + let path = format!("/v1/appScreenshots/{id}");
203 + self.request_no_content::<()>(Method::DELETE, &path, &[], None)
204 + .await
205 + }
206 +
207 + /// Reserve, upload, and commit an image in one call: `bytes` becomes an
208 + /// `AppScreenshot` under `app_screenshot_set_id`, named `file_name`.
209 + /// This is the entry point most callers want; the lower-level
210 + /// `create_app_screenshot` / `upload_app_screenshot_bytes` /
211 + /// `commit_app_screenshot` are exposed for callers that need to
212 + /// checkpoint between steps (e.g. a CLI resuming a failed upload).
213 + pub async fn upload_app_screenshot(
214 + &self,
215 + app_screenshot_set_id: &str,
216 + file_name: String,
217 + bytes: Vec<u8>,
218 + ) -> Result<AppScreenshot> {
219 + let checksum = format!("{:x}", md5::compute(&bytes));
220 + let screenshot = self
221 + .create_app_screenshot(
222 + app_screenshot_set_id,
223 + AppScreenshotCreateAttributes {
224 + file_name,
225 + file_size: bytes.len() as u64,
226 + },
227 + )
228 + .await?;
229 + self.upload_app_screenshot_bytes(&screenshot, &bytes)
230 + .await?;
231 + self.commit_app_screenshot(&screenshot.id, checksum).await
232 + }
233 +}
crates/ascapi/src/app_screenshot_set.rs new
+133
@@ -0,0 +1,133 @@
1 +//! An `AppScreenshotSet` groups the screenshots for one device/display
2 +//! class (e.g. Apple Vision Pro, iPhone 6.7") within one localization of an
3 +//! `AppStoreVersion`. Screenshots themselves are a separate resource
4 +//! ([`crate::app_screenshot`]) that belongs to a set.
5 +
6 +use crate::client::Client;
7 +use crate::error::Result;
8 +use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne};
9 +use reqwest::Method;
10 +use serde::{Deserialize, Serialize};
11 +
12 +pub const RESOURCE_TYPE: &str = "appScreenshotSets";
13 +
14 +/// The device/display class a screenshot set targets. Apple documents the
15 +/// full set at
16 +/// <https://developer.apple.com/documentation/appstoreconnectapi/screenshotdisplaytype>;
17 +/// this covers the App Store (non-iMessage) display classes in current use.
18 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19 +pub enum ScreenshotDisplayType {
20 + #[serde(rename = "APP_APPLE_VISION_PRO")]
21 + AppleVisionPro,
22 + #[serde(rename = "APP_IPHONE_67")]
23 + Iphone67,
24 + #[serde(rename = "APP_IPHONE_65")]
25 + Iphone65,
26 + #[serde(rename = "APP_IPHONE_61")]
27 + Iphone61,
28 + #[serde(rename = "APP_IPHONE_58")]
29 + Iphone58,
30 + #[serde(rename = "APP_IPHONE_55")]
31 + Iphone55,
32 + #[serde(rename = "APP_IPHONE_47")]
33 + Iphone47,
34 + #[serde(rename = "APP_IPHONE_40")]
35 + Iphone40,
36 + #[serde(rename = "APP_IPHONE_35")]
37 + Iphone35,
38 + #[serde(rename = "APP_IPAD_PRO_3GEN_129")]
39 + IpadPro3Gen129,
40 + #[serde(rename = "APP_IPAD_PRO_3GEN_11")]
41 + IpadPro3Gen11,
42 + #[serde(rename = "APP_IPAD_PRO_129")]
43 + IpadPro129,
44 + #[serde(rename = "APP_IPAD_105")]
45 + Ipad105,
46 + #[serde(rename = "APP_IPAD_97")]
47 + Ipad97,
48 + #[serde(rename = "APP_DESKTOP")]
49 + Desktop,
50 + #[serde(rename = "APP_APPLE_TV")]
51 + AppleTv,
52 + #[serde(rename = "APP_WATCH_ULTRA")]
53 + WatchUltra,
54 + #[serde(rename = "APP_WATCH_SERIES_10")]
55 + WatchSeries10,
56 + #[serde(rename = "APP_WATCH_SERIES_7")]
57 + WatchSeries7,
58 + #[serde(rename = "APP_WATCH_SERIES_4")]
59 + WatchSeries4,
60 + #[serde(rename = "APP_WATCH_SERIES_3")]
61 + WatchSeries3,
62 +}
63 +
64 +#[derive(Debug, Clone, Serialize, Deserialize)]
65 +#[serde(rename_all = "camelCase")]
66 +pub struct AppScreenshotSetAttributes {
67 + pub screenshot_display_type: ScreenshotDisplayType,
68 +}
69 +
70 +pub type AppScreenshotSet = Resource<AppScreenshotSetAttributes>;
71 +
72 +#[derive(Debug, Clone, Serialize)]
73 +#[serde(rename_all = "camelCase")]
74 +pub struct AppScreenshotSetCreateAttributes {
75 + pub screenshot_display_type: ScreenshotDisplayType,
76 +}
77 +
78 +#[derive(Debug, Clone, Serialize)]
79 +#[serde(rename_all = "camelCase")]
80 +pub struct AppScreenshotSetRelationships {
81 + pub app_store_version_localization: ToOne,
82 +}
83 +
84 +impl Client {
85 + /// `GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets`.
86 + pub async fn list_app_screenshot_sets(
87 + &self,
88 + app_store_version_localization_id: &str,
89 + ) -> Result<Vec<AppScreenshotSet>> {
90 + let path = format!(
91 + "/v1/appStoreVersionLocalizations/{app_store_version_localization_id}/appScreenshotSets"
92 + );
93 + let doc: ListDocument<AppScreenshotSetAttributes> =
94 + self.request(Method::GET, &path, &[], None::<&()>).await?;
95 + Ok(doc.data)
96 + }
97 +
98 + /// `POST /v1/appScreenshotSets` — adds a device/display class's
99 + /// screenshot bucket to a localization. Screenshots are then added to
100 + /// the returned set with [`Client::create_app_screenshot`] /
101 + /// [`Client::upload_app_screenshot`].
102 + pub async fn create_app_screenshot_set(
103 + &self,
104 + app_store_version_localization_id: &str,
105 + attributes: AppScreenshotSetCreateAttributes,
106 + ) -> Result<AppScreenshotSet> {
107 + let body = CreateBody {
108 + data: CreateData {
109 + resource_type: RESOURCE_TYPE,
110 + attributes,
111 + relationships: Some(AppScreenshotSetRelationships {
112 + app_store_version_localization: ToOne {
113 + data: ResourceId {
114 + resource_type: crate::app_store_version_localization::RESOURCE_TYPE,
115 + id: app_store_version_localization_id.to_string(),
116 + },
117 + },
118 + }),
119 + },
120 + };
121 + let doc: Document<AppScreenshotSetAttributes> = self
122 + .request(Method::POST, "/v1/appScreenshotSets", &[], Some(&body))
123 + .await?;
124 + Ok(doc.data)
125 + }
126 +
127 + /// `DELETE /v1/appScreenshotSets/{id}`.
128 + pub async fn delete_app_screenshot_set(&self, id: &str) -> Result<()> {
129 + let path = format!("/v1/appScreenshotSets/{id}");
130 + self.request_no_content::<()>(Method::DELETE, &path, &[], None)
131 + .await
132 + }
133 +}
crates/ascapi/src/app_store_version.rs new
+166
@@ -0,0 +1,166 @@
1 +//! An `AppStoreVersion` is App Store Connect's per-platform record for an
2 +//! app: `platform` is one of `IOS` / `MAC_OS` / `TV_OS` / `VISION_OS`.
3 +//! Since there is no direct "create app for platform X" endpoint (see
4 +//! [`crate::app`]), creating an `AppStoreVersion` for a platform an app
5 +//! doesn't have one for yet — e.g. adding visionOS to an app that so far
6 +//! only ships on iOS/macOS — *is* how you add that platform.
7 +
8 +use crate::client::Client;
9 +use crate::error::Result;
10 +use crate::jsonapi::{
11 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne,
12 + UpdateRelationshipsBody, UpdateRelationshipsData,
13 +};
14 +use reqwest::Method;
15 +use serde::{Deserialize, Serialize};
16 +
17 +pub const RESOURCE_TYPE: &str = "appStoreVersions";
18 +
19 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20 +pub enum Platform {
21 + #[serde(rename = "IOS")]
22 + Ios,
23 + #[serde(rename = "MAC_OS")]
24 + MacOs,
25 + #[serde(rename = "TV_OS")]
26 + TvOs,
27 + #[serde(rename = "VISION_OS")]
28 + VisionOs,
29 +}
30 +
31 +impl Platform {
32 + fn as_query_value(self) -> &'static str {
33 + match self {
34 + Platform::Ios => "IOS",
35 + Platform::MacOs => "MAC_OS",
36 + Platform::TvOs => "TV_OS",
37 + Platform::VisionOs => "VISION_OS",
38 + }
39 + }
40 +}
41 +
42 +#[derive(Debug, Clone, Serialize, Deserialize)]
43 +#[serde(rename_all = "camelCase")]
44 +pub struct AppStoreVersionAttributes {
45 + pub platform: Platform,
46 + pub version_string: Option<String>,
47 + pub app_version_state: Option<String>,
48 + pub copyright: Option<String>,
49 + pub created_date: Option<String>,
50 + pub downloadable: Option<bool>,
51 +}
52 +
53 +pub type AppStoreVersion = Resource<AppStoreVersionAttributes>;
54 +
55 +#[derive(Debug, Clone, Serialize)]
56 +#[serde(rename_all = "camelCase")]
57 +pub struct AppStoreVersionCreateAttributes {
58 + pub platform: Platform,
59 + pub version_string: String,
60 + #[serde(skip_serializing_if = "Option::is_none")]
61 + pub copyright: Option<String>,
62 +}
63 +
64 +#[derive(Debug, Clone, Serialize)]
65 +#[serde(rename_all = "camelCase")]
66 +pub struct AppStoreVersionRelationships {
67 + pub app: ToOne,
68 +}
69 +
70 +impl Client {
71 + /// `GET /v1/apps/{app_id}/appStoreVersions`, optionally narrowed to one
72 + /// platform.
73 + pub async fn list_app_store_versions(
74 + &self,
75 + app_id: &str,
76 + filter_platform: Option<Platform>,
77 + ) -> Result<Vec<AppStoreVersion>> {
78 + let path = format!("/v1/apps/{app_id}/appStoreVersions");
79 + let mut query = Vec::new();
80 + if let Some(platform) = filter_platform {
81 + query.push(("filter[platform]", platform.as_query_value()));
82 + }
83 + let doc: ListDocument<AppStoreVersionAttributes> = self
84 + .request(Method::GET, &path, &query, None::<&()>)
85 + .await?;
86 + Ok(doc.data)
87 + }
88 +
89 + pub async fn get_app_store_version(&self, id: &str) -> Result<AppStoreVersion> {
90 + let path = format!("/v1/appStoreVersions/{id}");
91 + let doc: Document<AppStoreVersionAttributes> =
92 + self.request(Method::GET, &path, &[], None::<&()>).await?;
93 + Ok(doc.data)
94 + }
95 +
96 + /// `POST /v1/appStoreVersions`.
97 + pub async fn create_app_store_version(
98 + &self,
99 + app_id: &str,
100 + attributes: AppStoreVersionCreateAttributes,
101 + ) -> Result<AppStoreVersion> {
102 + let body = CreateBody {
103 + data: CreateData {
104 + resource_type: RESOURCE_TYPE,
105 + attributes,
106 + relationships: Some(AppStoreVersionRelationships {
107 + app: ToOne {
108 + data: ResourceId {
109 + resource_type: crate::app::RESOURCE_TYPE,
110 + id: app_id.to_string(),
111 + },
112 + },
113 + }),
114 + },
115 + };
116 + let doc: Document<AppStoreVersionAttributes> = self
117 + .request(Method::POST, "/v1/appStoreVersions", &[], Some(&body))
118 + .await?;
119 + Ok(doc.data)
120 + }
121 +
122 + /// `DELETE /v1/appStoreVersions/{id}` — only allowed while the version
123 + /// hasn't been submitted for review.
124 + pub async fn delete_app_store_version(&self, id: &str) -> Result<()> {
125 + let path = format!("/v1/appStoreVersions/{id}");
126 + self.request_no_content::<()>(Method::DELETE, &path, &[], None)
127 + .await
128 + }
129 +
130 + /// `PATCH /v1/appStoreVersions/{id}` with a `build` relationship —
131 + /// attaches (or replaces) the `Build` this version will submit. Needed
132 + /// after a re-upload fixes an `INVALID_BINARY` version: the fixed
133 + /// build must be attached here before the version can be resubmitted
134 + /// for review (resubmission itself is outside this crate's scope — see
135 + /// [`crate::build`]'s module doc).
136 + pub async fn set_app_store_version_build(
137 + &self,
138 + id: &str,
139 + build_id: &str,
140 + ) -> Result<AppStoreVersion> {
141 + let path = format!("/v1/appStoreVersions/{id}");
142 + let body = UpdateRelationshipsBody {
143 + data: UpdateRelationshipsData {
144 + resource_type: RESOURCE_TYPE,
145 + id: id.to_string(),
146 + relationships: AppStoreVersionBuildRelationship {
147 + build: ToOne {
148 + data: ResourceId {
149 + resource_type: crate::build::RESOURCE_TYPE,
150 + id: build_id.to_string(),
151 + },
152 + },
153 + },
154 + },
155 + };
156 + let doc: Document<AppStoreVersionAttributes> =
157 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
158 + Ok(doc.data)
159 + }
160 +}
161 +
162 +#[derive(Debug, Clone, Serialize)]
163 +#[serde(rename_all = "camelCase")]
164 +pub struct AppStoreVersionBuildRelationship {
165 + pub build: ToOne,
166 +}
crates/ascapi/src/app_store_version_localization.rs new
+124
@@ -0,0 +1,124 @@
1 +use crate::client::Client;
2 +use crate::error::Result;
3 +use crate::jsonapi::{
4 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne, UpdateBody,
5 + UpdateData,
6 +};
7 +use reqwest::Method;
8 +use serde::{Deserialize, Serialize};
9 +
10 +pub const RESOURCE_TYPE: &str = "appStoreVersionLocalizations";
11 +
12 +#[derive(Debug, Clone, Serialize, Deserialize)]
13 +#[serde(rename_all = "camelCase")]
14 +pub struct AppStoreVersionLocalizationAttributes {
15 + pub locale: String,
16 + pub description: Option<String>,
17 + pub keywords: Option<String>,
18 + pub marketing_url: Option<String>,
19 + pub promotional_text: Option<String>,
20 + pub support_url: Option<String>,
21 + pub whats_new: Option<String>,
22 +}
23 +
24 +pub type AppStoreVersionLocalization = Resource<AppStoreVersionLocalizationAttributes>;
25 +
26 +#[derive(Debug, Clone, Default, Serialize)]
27 +#[serde(rename_all = "camelCase")]
28 +pub struct AppStoreVersionLocalizationFields {
29 + #[serde(skip_serializing_if = "Option::is_none")]
30 + pub description: Option<String>,
31 + #[serde(skip_serializing_if = "Option::is_none")]
32 + pub keywords: Option<String>,
33 + #[serde(skip_serializing_if = "Option::is_none")]
34 + pub marketing_url: Option<String>,
35 + #[serde(skip_serializing_if = "Option::is_none")]
36 + pub promotional_text: Option<String>,
37 + #[serde(skip_serializing_if = "Option::is_none")]
38 + pub support_url: Option<String>,
39 + #[serde(skip_serializing_if = "Option::is_none")]
40 + pub whats_new: Option<String>,
41 +}
42 +
43 +#[derive(Debug, Clone, Serialize)]
44 +#[serde(rename_all = "camelCase")]
45 +pub struct AppStoreVersionLocalizationCreateAttributes {
46 + pub locale: String,
47 + #[serde(flatten)]
48 + pub fields: AppStoreVersionLocalizationFields,
49 +}
50 +
51 +#[derive(Debug, Clone, Serialize)]
52 +#[serde(rename_all = "camelCase")]
53 +pub struct AppStoreVersionLocalizationRelationships {
54 + pub app_store_version: ToOne,
55 +}
56 +
57 +impl Client {
58 + pub async fn list_app_store_version_localizations(
59 + &self,
60 + app_store_version_id: &str,
61 + ) -> Result<Vec<AppStoreVersionLocalization>> {
62 + let path =
63 + format!("/v1/appStoreVersions/{app_store_version_id}/appStoreVersionLocalizations");
64 + let doc: ListDocument<AppStoreVersionLocalizationAttributes> =
65 + self.request(Method::GET, &path, &[], None::<&()>).await?;
66 + Ok(doc.data)
67 + }
68 +
69 + /// `POST /v1/appStoreVersionLocalizations` — adds a locale's
70 + /// description/keywords/etc to an `AppStoreVersion`.
71 + pub async fn create_app_store_version_localization(
72 + &self,
73 + app_store_version_id: &str,
74 + attributes: AppStoreVersionLocalizationCreateAttributes,
75 + ) -> Result<AppStoreVersionLocalization> {
76 + let body = CreateBody {
77 + data: CreateData {
78 + resource_type: RESOURCE_TYPE,
79 + attributes,
80 + relationships: Some(AppStoreVersionLocalizationRelationships {
81 + app_store_version: ToOne {
82 + data: ResourceId {
83 + resource_type: crate::app_store_version::RESOURCE_TYPE,
84 + id: app_store_version_id.to_string(),
85 + },
86 + },
87 + }),
88 + },
89 + };
90 + let doc: Document<AppStoreVersionLocalizationAttributes> = self
91 + .request(
92 + Method::POST,
93 + "/v1/appStoreVersionLocalizations",
94 + &[],
95 + Some(&body),
96 + )
97 + .await?;
98 + Ok(doc.data)
99 + }
100 +
101 + pub async fn update_app_store_version_localization(
102 + &self,
103 + id: &str,
104 + fields: AppStoreVersionLocalizationFields,
105 + ) -> Result<AppStoreVersionLocalization> {
106 + let path = format!("/v1/appStoreVersionLocalizations/{id}");
107 + let body = UpdateBody {
108 + data: UpdateData {
109 + resource_type: RESOURCE_TYPE,
110 + id: id.to_string(),
111 + attributes: fields,
112 + },
113 + };
114 + let doc: Document<AppStoreVersionLocalizationAttributes> =
115 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
116 + Ok(doc.data)
117 + }
118 +
119 + pub async fn delete_app_store_version_localization(&self, id: &str) -> Result<()> {
120 + let path = format!("/v1/appStoreVersionLocalizations/{id}");
121 + self.request_no_content::<()>(Method::DELETE, &path, &[], None)
122 + .await
123 + }
124 +}
crates/ascapi/src/ascapi.rs new
+56
@@ -0,0 +1,56 @@
1 +//! Embeddable Rust client (and CLI, in the sibling `smbcloud-ascapi-cli`
2 +//! crate / `ascapi` binary) for the App Store Connect API's
3 +//! [App Metadata](https://developer.apple.com/documentation/appstoreconnectapi/app-metadata)
4 +//! resources: apps, app infos and their localizations, app store versions
5 +//! and their localizations, and bundle IDs.
6 +//!
7 +//! ```no_run
8 +//! use smbcloud_ascapi::{ApiKey, Client};
9 +//! use smbcloud_ascapi::app_store_version::{AppStoreVersionCreateAttributes, Platform};
10 +//!
11 +//! # async fn example() -> smbcloud_ascapi::Result<()> {
12 +//! let api_key = ApiKey::from_p8_file(
13 +//! "L84N624YQH",
14 +//! "b4e8d369-8b7d-4538-8435-643b73237575",
15 +//! "/Users/me/private_keys/AuthKey_L84N624YQH.p8",
16 +//! )?;
17 +//! let client = Client::new(api_key);
18 +//!
19 +//! let apps = client.list_apps(Some("ai.siti.Siti")).await?;
20 +//! let app = &apps[0];
21 +//!
22 +//! client
23 +//! .create_app_store_version(
24 +//! &app.id,
25 +//! AppStoreVersionCreateAttributes {
26 +//! platform: Platform::VisionOs,
27 +//! version_string: "1.0.0".to_string(),
28 +//! copyright: None,
29 +//! },
30 +//! )
31 +//! .await?;
32 +//! # Ok(())
33 +//! # }
34 +//! ```
35 +//!
36 +//! Not covered (yet): app preview (video) binary uploads, age rating
37 +//! declarations, in-app purchases, TestFlight, and the rest of the wider
38 +//! App Store Connect API surface outside "App Metadata".
39 +
40 +pub mod app;
41 +pub mod app_info;
42 +pub mod app_info_localization;
43 +pub mod app_screenshot;
44 +pub mod app_screenshot_set;
45 +pub mod app_store_version;
46 +pub mod app_store_version_localization;
47 +pub mod auth;
48 +pub mod build;
49 +pub mod bundle_id;
50 +pub mod client;
51 +pub mod error;
52 +pub mod jsonapi;
53 +
54 +pub use auth::ApiKey;
55 +pub use client::Client;
56 +pub use error::{Error, Result};
crates/ascapi/src/auth.rs new
+99
@@ -0,0 +1,99 @@
1 +use crate::error::{Error, Result};
2 +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
3 +use serde::Serialize;
4 +use std::path::Path;
5 +use std::time::{SystemTime, UNIX_EPOCH};
6 +
7 +const AUDIENCE: &str = "appstoreconnect-v1";
8 +// Apple rejects tokens whose `exp` is more than 20 minutes past `iat`. See
9 +// https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
10 +const MAX_TOKEN_LIFETIME_SECS: u64 = 20 * 60;
11 +
12 +#[derive(Debug, Clone, Serialize)]
13 +struct Claims {
14 + iss: String,
15 + iat: u64,
16 + exp: u64,
17 + aud: String,
18 +}
19 +
20 +/// An App Store Connect API key: the (key ID, issuer ID, private key)
21 +/// triple from App Store Connect → Users and Access → Integrations → App
22 +/// Store Connect API. Signs the short-lived ES256 JWTs the API requires as
23 +/// a bearer token on every request.
24 +#[derive(Clone)]
25 +pub struct ApiKey {
26 + key_id: String,
27 + issuer_id: String,
28 + encoding_key: EncodingKey,
29 + lifetime_secs: u64,
30 +}
31 +
32 +impl ApiKey {
33 + /// Load a key from a PEM-encoded `.p8` private key file — the file App
34 + /// Store Connect lets you download once, at key-creation time.
35 + pub fn from_p8_file(
36 + key_id: impl Into<String>,
37 + issuer_id: impl Into<String>,
38 + p8_path: impl AsRef<Path>,
39 + ) -> Result<Self> {
40 + let path = p8_path.as_ref();
41 + let pem = std::fs::read_to_string(path).map_err(|source| Error::PrivateKeyRead {
42 + path: path.display().to_string(),
43 + source,
44 + })?;
45 + Self::from_p8_pem(key_id, issuer_id, &pem)
46 + }
47 +
48 + /// Load a key from an already-in-memory PEM string.
49 + pub fn from_p8_pem(
50 + key_id: impl Into<String>,
51 + issuer_id: impl Into<String>,
52 + pem: &str,
53 + ) -> Result<Self> {
54 + let encoding_key = EncodingKey::from_ec_pem(pem.as_bytes())?;
55 + Ok(Self {
56 + key_id: key_id.into(),
57 + issuer_id: issuer_id.into(),
58 + encoding_key,
59 + lifetime_secs: MAX_TOKEN_LIFETIME_SECS,
60 + })
61 + }
62 +
63 + /// Override the token lifetime in seconds. Values above Apple's 20
64 + /// minute ceiling are clamped down to it.
65 + pub fn with_lifetime_secs(mut self, secs: u64) -> Self {
66 + self.lifetime_secs = secs.min(MAX_TOKEN_LIFETIME_SECS);
67 + self
68 + }
69 +
70 + pub fn key_id(&self) -> &str {
71 + &self.key_id
72 + }
73 +
74 + pub fn lifetime_secs(&self) -> u64 {
75 + self.lifetime_secs
76 + }
77 +
78 + /// Mint a fresh bearer token. `Client` caches and reuses the result of
79 + /// this until shortly before it expires rather than calling it per
80 + /// request.
81 + pub fn token(&self) -> Result<String> {
82 + let now = SystemTime::now()
83 + .duration_since(UNIX_EPOCH)
84 + .unwrap_or_default() // a clock before 1970 isn't a real failure mode worth propagating
85 + .as_secs();
86 +
87 + let claims = Claims {
88 + iss: self.issuer_id.clone(),
89 + iat: now,
90 + exp: now + self.lifetime_secs,
91 + aud: AUDIENCE.to_string(),
92 + };
93 +
94 + let mut header = Header::new(Algorithm::ES256);
95 + header.kid = Some(self.key_id.clone());
96 +
97 + Ok(encode(&header, &claims, &self.encoding_key)?)
98 + }
99 +}
crates/ascapi/src/build.rs new
+44
@@ -0,0 +1,44 @@
1 +//! Read-only lookup for `Build` — the processed artifact behind an uploaded
2 +//! `.ipa`/`.pkg`. Uploading itself is out of this crate's scope (done via
3 +//! `xcrun altool`/Xcode, see the CLI's sibling build scripts); this module
4 +//! exists so a caller can check whether App Store Connect finished
5 +//! processing a build as `VALID` or `INVALID` after upload — the signal that
6 +//! actually confirms a binary-rejection fix worked, independent of an
7 +//! `AppStoreVersion`'s `appVersionState` (which only changes once a build is
8 +//! attached to the version and resubmitted — outside this crate's scope).
9 +
10 +use crate::client::Client;
11 +use crate::error::Result;
12 +use crate::jsonapi::{ListDocument, Resource};
13 +use reqwest::Method;
14 +use serde::{Deserialize, Serialize};
15 +
16 +pub const RESOURCE_TYPE: &str = "builds";
17 +
18 +#[derive(Debug, Clone, Serialize, Deserialize)]
19 +#[serde(rename_all = "camelCase")]
20 +pub struct BuildAttributes {
21 + pub version: Option<String>,
22 + pub uploaded_date: Option<String>,
23 + pub expired: Option<bool>,
24 + pub processing_state: Option<String>,
25 + pub min_os_version: Option<String>,
26 +}
27 +
28 +pub type Build = Resource<BuildAttributes>;
29 +
30 +impl Client {
31 + /// `GET /v1/apps/{app_id}/builds`, sorted newest-first by
32 + /// `uploadedDate` (client-side — this endpoint's `sort` query parameter
33 + /// is rejected by the API, unlike most other list endpoints), so the
34 + /// build most recently uploaded (e.g. by a fixplist re-upload) is
35 + /// `list_builds(app_id).await?.first()`.
36 + pub async fn list_builds(&self, app_id: &str) -> Result<Vec<Build>> {
37 + let path = format!("/v1/apps/{app_id}/builds");
38 + let doc: ListDocument<BuildAttributes> =
39 + self.request(Method::GET, &path, &[], None::<&()>).await?;
40 + let mut builds = doc.data;
41 + builds.sort_by(|a, b| b.attributes.uploaded_date.cmp(&a.attributes.uploaded_date));
42 + Ok(builds)
43 + }
44 +}
crates/ascapi/src/bundle_id.rs new
+76
@@ -0,0 +1,76 @@
1 +use crate::client::Client;
2 +use crate::error::Result;
3 +use crate::jsonapi::{CreateBody, CreateData, Document, ListDocument, Resource};
4 +use reqwest::Method;
5 +use serde::{Deserialize, Serialize};
6 +
7 +pub const RESOURCE_TYPE: &str = "bundleIds";
8 +
9 +/// Note this is a *different* enum from
10 +/// [`crate::app_store_version::Platform`] — App Store Connect uses a
11 +/// coarser set of platforms when registering a bundle ID than when
12 +/// declaring an App Store Version's platform. A visionOS app typically
13 +/// registers its bundle ID as `Ios` (visionOS shares the iOS identifier
14 +/// namespace) rather than a dedicated visionOS value, because there isn't
15 +/// one here.
16 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17 +pub enum BundleIdPlatform {
18 + #[serde(rename = "IOS")]
19 + Ios,
20 + #[serde(rename = "MAC_OS")]
21 + MacOs,
22 + #[serde(rename = "UNIVERSAL")]
23 + Universal,
24 +}
25 +
26 +#[derive(Debug, Clone, Serialize, Deserialize)]
27 +#[serde(rename_all = "camelCase")]
28 +pub struct BundleIdAttributes {
29 + pub name: Option<String>,
30 + pub identifier: Option<String>,
31 + pub platform: Option<BundleIdPlatform>,
32 + pub seed_id: Option<String>,
33 +}
34 +
35 +pub type BundleId = Resource<BundleIdAttributes>;
36 +
37 +#[derive(Debug, Clone, Serialize)]
38 +#[serde(rename_all = "camelCase")]
39 +pub struct BundleIdCreateAttributes {
40 + pub identifier: String,
41 + pub name: String,
42 + pub platform: BundleIdPlatform,
43 +}
44 +
45 +impl Client {
46 + /// `GET /v1/bundleIds`, optionally filtered to an exact identifier —
47 + /// check whether a bundle ID is already registered before trying to
48 + /// create it, or before creating an App Store Version under an app
49 + /// that uses it.
50 + pub async fn list_bundle_ids(&self, filter_identifier: Option<&str>) -> Result<Vec<BundleId>> {
51 + let mut query = Vec::new();
52 + if let Some(identifier) = filter_identifier {
53 + query.push(("filter[identifier]", identifier));
54 + }
55 + let doc: ListDocument<BundleIdAttributes> = self
56 + .request(Method::GET, "/v1/bundleIds", &query, None::<&()>)
57 + .await?;
58 + Ok(doc.data)
59 + }
60 +
61 + /// `POST /v1/bundleIds` — registers a new bundle ID with the developer
62 + /// account (Certificates, Identifiers & Profiles).
63 + pub async fn create_bundle_id(&self, attributes: BundleIdCreateAttributes) -> Result<BundleId> {
64 + let body = CreateBody {
65 + data: CreateData {
66 + resource_type: RESOURCE_TYPE,
67 + attributes,
68 + relationships: None::<()>,
69 + },
70 + };
71 + let doc: Document<BundleIdAttributes> = self
72 + .request(Method::POST, "/v1/bundleIds", &[], Some(&body))
73 + .await?;
74 + Ok(doc.data)
75 + }
76 +}
crates/ascapi/src/client.rs new
+167
@@ -0,0 +1,167 @@
1 +use crate::auth::ApiKey;
2 +use crate::error::{Error, Result};
3 +use crate::jsonapi::ErrorDocument;
4 +use reqwest::{Method, StatusCode};
5 +use serde::de::DeserializeOwned;
6 +use serde::Serialize;
7 +use std::sync::Mutex;
8 +use std::time::{Duration, Instant};
9 +
10 +const BASE_URL: &str = "https://api.appstoreconnect.apple.com";
11 +// Refresh a bit before the token's real expiry so an in-flight request never
12 +// races a signature Apple has already started rejecting.
13 +const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60);
14 +
15 +struct CachedToken {
16 + value: String,
17 + minted_at: Instant,
18 + lifetime: Duration,
19 +}
20 +
21 +/// Thin async HTTP client for the App Store Connect API. Handles JWT minting
22 +/// (cached and refreshed automatically) and JSON:API request/response
23 +/// plumbing. Resource-specific calls (apps, app store versions, bundle IDs,
24 +/// ...) are implemented as additional `impl Client` blocks alongside each
25 +/// resource's types, in their own modules.
26 +pub struct Client {
27 + http: reqwest::Client,
28 + api_key: ApiKey,
29 + base_url: String,
30 + token: Mutex<Option<CachedToken>>,
31 +}
32 +
33 +impl Client {
34 + pub fn new(api_key: ApiKey) -> Self {
35 + Self {
36 + http: reqwest::Client::new(),
37 + api_key,
38 + base_url: BASE_URL.to_string(),
39 + token: Mutex::new(None),
40 + }
41 + }
42 +
43 + /// Override the API host — only meaningful for pointing the client at a
44 + /// mock server in tests.
45 + pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
46 + self.base_url = base_url.into();
47 + self
48 + }
49 +
50 + fn bearer_token(&self) -> Result<String> {
51 + let mut guard = self
52 + .token
53 + .lock()
54 + .unwrap_or_else(|poisoned| poisoned.into_inner());
55 +
56 + if let Some(cached) = guard.as_ref() {
57 + if cached.minted_at.elapsed() + TOKEN_REFRESH_MARGIN < cached.lifetime {
58 + return Ok(cached.value.clone());
59 + }
60 + }
61 +
62 + let value = self.api_key.token()?;
63 + *guard = Some(CachedToken {
64 + value: value.clone(),
65 + minted_at: Instant::now(),
66 + lifetime: Duration::from_secs(self.api_key.lifetime_secs()),
67 + });
68 + Ok(value)
69 + }
70 +
71 + /// Send a request and decode a JSON:API response body into `T`. Use
72 + /// `request_no_content` instead for calls (typically `DELETE`) that
73 + /// return an empty `204` body.
74 + pub(crate) async fn request<B: Serialize, T: DeserializeOwned>(
75 + &self,
76 + method: Method,
77 + path: &str,
78 + query: &[(&str, &str)],
79 + body: Option<&B>,
80 + ) -> Result<T> {
81 + let (status, bytes) = self.send(method, path, query, body).await?;
82 + if !status.is_success() {
83 + return Err(api_error(status, &bytes));
84 + }
85 + Ok(serde_json::from_slice(&bytes)?)
86 + }
87 +
88 + /// Send a request that returns no body on success (typically `DELETE`).
89 + pub(crate) async fn request_no_content<B: Serialize>(
90 + &self,
91 + method: Method,
92 + path: &str,
93 + query: &[(&str, &str)],
94 + body: Option<&B>,
95 + ) -> Result<()> {
96 + let (status, bytes) = self.send(method, path, query, body).await?;
97 + if !status.is_success() {
98 + return Err(api_error(status, &bytes));
99 + }
100 + Ok(())
101 + }
102 +
103 + async fn send<B: Serialize>(
104 + &self,
105 + method: Method,
106 + path: &str,
107 + query: &[(&str, &str)],
108 + body: Option<&B>,
109 + ) -> Result<(StatusCode, Vec<u8>)> {
110 + let url = format!("{}{}", self.base_url, path);
111 + let token = self.bearer_token()?;
112 + let mut req = self.http.request(method, &url).bearer_auth(token);
113 + if !query.is_empty() {
114 + req = req.query(query);
115 + }
116 + if let Some(b) = body {
117 + req = req.json(b);
118 + }
119 +
120 + let response = req.send().await?;
121 + let status = response.status();
122 + let bytes = response.bytes().await?.to_vec();
123 + Ok((status, bytes))
124 + }
125 +
126 + /// PUTs a raw byte body to a pre-signed asset-upload URL (as returned by
127 + /// e.g. `appScreenshots.uploadOperations`). Unlike [`Client::request`],
128 + /// this does **not** prefix `base_url` or attach the ASC bearer token —
129 + /// upload URLs are pre-signed and carry their own auth in
130 + /// `request_headers`; sending our JWT alongside would be wrong for a
131 + /// host that isn't `api.appstoreconnect.apple.com`.
132 + pub(crate) async fn upload_bytes(
133 + &self,
134 + method: Method,
135 + url: &str,
136 + headers: &[(String, String)],
137 + body: Vec<u8>,
138 + ) -> Result<()> {
139 + let mut req = self.http.request(method, url);
140 + for (name, value) in headers {
141 + req = req.header(name.as_str(), value.as_str());
142 + }
143 + let response = req.body(body).send().await?;
144 + let status = response.status();
145 + if !status.is_success() {
146 + let bytes = response.bytes().await?.to_vec();
147 + return Err(api_error(status, &bytes));
148 + }
149 + Ok(())
150 + }
151 +}
152 +
153 +fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
154 + let detail = match serde_json::from_slice::<ErrorDocument>(bytes) {
155 + Ok(doc) if !doc.errors.is_empty() => doc
156 + .errors
157 + .iter()
158 + .map(|e| format!("{} ({}): {}", e.title, e.code, e.detail))
159 + .collect::<Vec<_>>()
160 + .join("; "),
161 + _ => String::from_utf8_lossy(bytes).into_owned(),
162 + };
163 + Error::Api {
164 + status: status.as_u16(),
165 + detail,
166 + }
167 +}
crates/ascapi/src/error.rs new
+25
@@ -0,0 +1,25 @@
1 +use thiserror::Error;
2 +
3 +#[derive(Debug, Error)]
4 +pub enum Error {
5 + #[error("failed to read private key at {path}: {source}")]
6 + PrivateKeyRead {
7 + path: String,
8 + #[source]
9 + source: std::io::Error,
10 + },
11 +
12 + #[error("failed to sign JWT: {0}")]
13 + Jwt(#[from] jsonwebtoken::errors::Error),
14 +
15 + #[error("http request failed: {0}")]
16 + Http(#[from] reqwest::Error),
17 +
18 + #[error("failed to decode JSON response: {0}")]
19 + Json(#[from] serde_json::Error),
20 +
21 + #[error("App Store Connect API returned {status}: {detail}")]
22 + Api { status: u16, detail: String },
23 +}
24 +
25 +pub type Result<T> = std::result::Result<T, Error>;
crates/ascapi/src/jsonapi.rs new
+107
@@ -0,0 +1,107 @@
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 resource-collection JSON:API document — the body of e.g. `GET
15 +/// /v1/apps`.
16 +#[derive(Debug, Clone, Deserialize)]
17 +pub struct ListDocument<A> {
18 + pub data: Vec<Resource<A>>,
19 +}
20 +
21 +/// A single JSON:API resource object: its id, type, and typed attributes.
22 +/// Relationships are intentionally left untyped (`serde_json::Value`) —
23 +/// this crate builds relationship payloads explicitly on requests rather
24 +/// than typing every relationship shape App Store Connect can return.
25 +#[derive(Debug, Clone, Serialize, Deserialize)]
26 +pub struct Resource<A> {
27 + pub id: String,
28 + #[serde(rename = "type")]
29 + pub resource_type: String,
30 + pub attributes: A,
31 + #[serde(default, skip_serializing_if = "Option::is_none")]
32 + pub relationships: Option<serde_json::Value>,
33 +}
34 +
35 +/// A bare `{ type, id }` resource identifier, used to point a create
36 +/// request's relationships at an existing resource.
37 +#[derive(Debug, Clone, Serialize, Deserialize)]
38 +pub struct ResourceId {
39 + #[serde(rename = "type")]
40 + pub resource_type: &'static str,
41 + pub id: String,
42 +}
43 +
44 +/// A to-one relationship payload, e.g. `relationships.app` on a
45 +/// `POST /v1/appStoreVersions` body.
46 +#[derive(Debug, Clone, Serialize)]
47 +pub struct ToOne {
48 + pub data: ResourceId,
49 +}
50 +
51 +#[derive(Debug, Clone, Serialize)]
52 +pub struct CreateBody<A, R> {
53 + pub data: CreateData<A, R>,
54 +}
55 +
56 +#[derive(Debug, Clone, Serialize)]
57 +pub struct CreateData<A, R> {
58 + #[serde(rename = "type")]
59 + pub resource_type: &'static str,
60 + pub attributes: A,
61 + #[serde(skip_serializing_if = "Option::is_none")]
62 + pub relationships: Option<R>,
63 +}
64 +
65 +#[derive(Debug, Clone, Serialize)]
66 +pub struct UpdateBody<A> {
67 + pub data: UpdateData<A>,
68 +}
69 +
70 +/// A `PATCH` body that updates only relationships (no attributes) — e.g.
71 +/// attaching a `Build` to an `AppStoreVersion`.
72 +#[derive(Debug, Clone, Serialize)]
73 +pub struct UpdateRelationshipsBody<R> {
74 + pub data: UpdateRelationshipsData<R>,
75 +}
76 +
77 +#[derive(Debug, Clone, Serialize)]
78 +pub struct UpdateRelationshipsData<R> {
79 + #[serde(rename = "type")]
80 + pub resource_type: &'static str,
81 + pub id: String,
82 + pub relationships: R,
83 +}
84 +
85 +#[derive(Debug, Clone, Serialize)]
86 +pub struct UpdateData<A> {
87 + #[serde(rename = "type")]
88 + pub resource_type: &'static str,
89 + pub id: String,
90 + pub attributes: A,
91 +}
92 +
93 +/// The `errors` envelope App Store Connect returns on non-2xx responses.
94 +#[derive(Debug, Clone, Deserialize, Default)]
95 +pub struct ErrorDocument {
96 + #[serde(default)]
97 + pub errors: Vec<ApiError>,
98 +}
99 +
100 +#[derive(Debug, Clone, Deserialize)]
101 +pub struct ApiError {
102 + pub status: String,
103 + pub code: String,
104 + pub title: String,
105 + #[serde(default)]
106 + pub detail: String,
107 +}
crates/cli/Cargo.toml new
+20
@@ -0,0 +1,20 @@
1 +[package]
2 +name = "smbcloud-ascapi-cli"
3 +version.workspace = true
4 +edition.workspace = true
5 +authors.workspace = true
6 +license.workspace = true
7 +repository.workspace = true
8 +description = "CLI for adding/updating App Store Connect app metadata, built on smbcloud-ascapi."
9 +
10 +[[bin]]
11 +name = "ascapi"
12 +path = "src/main.rs"
13 +
14 +[dependencies]
15 +anyhow = { workspace = true }
16 +clap = { workspace = true, features = ["derive", "env"] }
17 +serde = { workspace = true }
18 +serde_json = { workspace = true }
19 +smbcloud-ascapi = { workspace = true }
20 +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
crates/cli/src/main.rs new
+729
@@ -0,0 +1,729 @@
1 +use anyhow::{Context, Result};
2 +use clap::{Parser, Subcommand, ValueEnum};
3 +use smbcloud_ascapi::app::AppUpdateAttributes;
4 +use smbcloud_ascapi::app_info_localization::{
5 + AppInfoLocalizationCreateAttributes, AppInfoLocalizationUpdateAttributes,
6 +};
7 +use smbcloud_ascapi::app_screenshot_set::{
8 + AppScreenshotSetCreateAttributes, ScreenshotDisplayType,
9 +};
10 +use smbcloud_ascapi::app_store_version::{AppStoreVersionCreateAttributes, Platform};
11 +use smbcloud_ascapi::app_store_version_localization::{
12 + AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13 +};
14 +use smbcloud_ascapi::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
15 +use smbcloud_ascapi::{ApiKey, Client};
16 +use std::path::PathBuf;
17 +
18 +/// Add/update App Store Connect app metadata (apps, app infos, app store
19 +/// versions, and their localizations) from the terminal or a script.
20 +#[derive(Parser)]
21 +#[command(name = "ascapi", version, about)]
22 +struct Cli {
23 + /// App Store Connect API key ID (Users and Access → Integrations → App
24 + /// Store Connect API).
25 + #[arg(long, env = "ASC_API_KEY")]
26 + key_id: String,
27 +
28 + /// App Store Connect API issuer ID (same page as the key).
29 + #[arg(long, env = "ASC_ISSUER_ID")]
30 + issuer_id: String,
31 +
32 + /// Path to the key's .p8 private key file. Defaults to
33 + /// ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8, matching
34 + /// Xcode's own convention.
35 + #[arg(long, env = "ASC_PRIVATE_KEY_PATH", global = true)]
36 + private_key_path: Option<PathBuf>,
37 +
38 + /// Print the request body that would be sent instead of sending it.
39 + /// Only affects commands that create or modify data.
40 + #[arg(long, global = true)]
41 + dry_run: bool,
42 +
43 + #[command(subcommand)]
44 + command: Command,
45 +}
46 +
47 +#[derive(Subcommand)]
48 +enum Command {
49 + /// Look up apps by bundle ID, or a specific app by its ASC app ID.
50 + Apps {
51 + #[command(subcommand)]
52 + command: AppsCommand,
53 + },
54 + /// Register or look up bundle identifiers.
55 + BundleIds {
56 + #[command(subcommand)]
57 + command: BundleIdsCommand,
58 + },
59 + /// Per-platform App Store Version records for an app — create one to
60 + /// add a new platform (e.g. visionOS) to an existing app.
61 + AppStoreVersions {
62 + #[command(subcommand)]
63 + command: AppStoreVersionsCommand,
64 + },
65 + /// Localized name/subtitle for an app's AppInfo.
66 + AppInfoLocalizations {
67 + #[command(subcommand)]
68 + command: AppInfoLocalizationsCommand,
69 + },
70 + /// Localized description/keywords/etc for an App Store Version.
71 + AppStoreVersionLocalizations {
72 + #[command(subcommand)]
73 + command: AppStoreVersionLocalizationsCommand,
74 + },
75 + /// Per-device-class screenshot buckets on an App Store Version
76 + /// Localization — create one, then add images to it with
77 + /// `app-screenshots upload`.
78 + AppScreenshotSets {
79 + #[command(subcommand)]
80 + command: AppScreenshotSetsCommand,
81 + },
82 + /// Screenshot image binaries within an AppScreenshotSet.
83 + AppScreenshots {
84 + #[command(subcommand)]
85 + command: AppScreenshotsCommand,
86 + },
87 +}
88 +
89 +#[derive(Subcommand)]
90 +enum AppsCommand {
91 + /// `GET /v1/apps`.
92 + List {
93 + #[arg(long)]
94 + bundle_id: Option<String>,
95 + },
96 + /// `GET /v1/apps/{id}`.
97 + Get { app_id: String },
98 + /// `PATCH /v1/apps/{id}`.
99 + Update {
100 + app_id: String,
101 + #[arg(long)]
102 + primary_locale: Option<String>,
103 + },
104 + /// `GET /v1/apps/{id}/appInfos`.
105 + Infos { app_id: String },
106 + /// `GET /v1/apps/{id}/builds` — newest first, so the
107 + /// most recently uploaded build (e.g. after a fixplist re-upload) is
108 + /// first. Check `processingState` (VALID/INVALID/PROCESSING/FAILED).
109 + Builds { app_id: String },
110 +}
111 +
112 +#[derive(Subcommand)]
113 +enum BundleIdsCommand {
114 + /// `GET /v1/bundleIds`.
115 + List {
116 + #[arg(long)]
117 + identifier: Option<String>,
118 + },
119 + /// `POST /v1/bundleIds`.
120 + Create {
121 + #[arg(long)]
122 + identifier: String,
123 + #[arg(long)]
124 + name: String,
125 + #[arg(long, value_enum, default_value_t = CliBundleIdPlatform::Ios)]
126 + platform: CliBundleIdPlatform,
127 + },
128 +}
129 +
130 +#[derive(Subcommand)]
131 +enum AppStoreVersionsCommand {
132 + /// `GET /v1/apps/{app_id}/appStoreVersions`.
133 + List {
134 + app_id: String,
135 + #[arg(long, value_enum)]
136 + platform: Option<CliPlatform>,
137 + },
138 + /// `GET /v1/appStoreVersions/{id}`.
139 + Get { id: String },
140 + /// `POST /v1/appStoreVersions`.
141 + Create {
142 + app_id: String,
143 + #[arg(long, value_enum)]
144 + platform: CliPlatform,
145 + #[arg(long)]
146 + version_string: String,
147 + #[arg(long)]
148 + copyright: Option<String>,
149 + },
150 + /// `DELETE /v1/appStoreVersions/{id}`.
151 + Delete { id: String },
152 + /// `PATCH /v1/appStoreVersions/{id}` — attach a `Build` (see
153 + /// `apps builds`) to this version, e.g. after fixing an
154 + /// `INVALID_BINARY` version with a re-uploaded build.
155 + SetBuild {
156 + id: String,
157 + #[arg(long)]
158 + build_id: String,
159 + },
160 +}
161 +
162 +#[derive(Subcommand)]
163 +enum AppInfoLocalizationsCommand {
164 + List {
165 + app_info_id: String,
166 + },
167 + Create {
168 + app_info_id: String,
169 + #[arg(long)]
170 + locale: String,
171 + #[arg(long)]
172 + name: String,
173 + #[arg(long)]
174 + subtitle: Option<String>,
175 + },
176 + Update {
177 + id: String,
178 + #[arg(long)]
179 + name: Option<String>,
180 + #[arg(long)]
181 + subtitle: Option<String>,
182 + },
183 + Delete {
184 + id: String,
185 + },
186 +}
187 +
188 +#[derive(Subcommand)]
189 +enum AppStoreVersionLocalizationsCommand {
190 + List {
191 + app_store_version_id: String,
192 + },
193 + Create {
194 + app_store_version_id: String,
195 + #[arg(long)]
196 + locale: String,
197 + #[arg(long)]
198 + description: Option<String>,
199 + #[arg(long)]
200 + keywords: Option<String>,
201 + #[arg(long)]
202 + whats_new: Option<String>,
203 + #[arg(long)]
204 + promotional_text: Option<String>,
205 + #[arg(long)]
206 + marketing_url: Option<String>,
207 + #[arg(long)]
208 + support_url: Option<String>,
209 + },
210 + Update {
211 + id: String,
212 + #[arg(long)]
213 + description: Option<String>,
214 + #[arg(long)]
215 + keywords: Option<String>,
216 + #[arg(long)]
217 + whats_new: Option<String>,
218 + #[arg(long)]
219 + promotional_text: Option<String>,
220 + #[arg(long)]
221 + marketing_url: Option<String>,
222 + #[arg(long)]
223 + support_url: Option<String>,
224 + },
225 + Delete {
226 + id: String,
227 + },
228 +}
229 +
230 +#[derive(Subcommand)]
231 +enum AppScreenshotSetsCommand {
232 + /// `GET /v1/appStoreVersionLocalizations/{id}/appScreenshotSets`.
233 + List {
234 + app_store_version_localization_id: String,
235 + },
236 + /// `POST /v1/appScreenshotSets`.
237 + Create {
238 + app_store_version_localization_id: String,
239 + #[arg(long, value_enum)]
240 + display_type: CliScreenshotDisplayType,
241 + },
242 + /// `DELETE /v1/appScreenshotSets/{id}`.
243 + Delete { id: String },
244 +}
245 +
246 +#[derive(Subcommand)]
247 +enum AppScreenshotsCommand {
248 + /// `GET /v1/appScreenshotSets/{id}/appScreenshots`.
249 + List { app_screenshot_set_id: String },
250 + /// Reserve, PUT, and commit an image file in one call — the full
251 + /// `POST /v1/appScreenshots` → upload → `PATCH …uploaded:true` flow.
252 + Upload {
253 + app_screenshot_set_id: String,
254 + #[arg(long)]
255 + file: PathBuf,
256 + },
257 + /// `DELETE /v1/appScreenshots/{id}`.
258 + Delete { id: String },
259 +}
260 +
261 +#[derive(Debug, Clone, Copy, ValueEnum)]
262 +enum CliScreenshotDisplayType {
263 + AppleVisionPro,
264 + Iphone67,
265 + Iphone65,
266 + Iphone61,
267 + Iphone58,
268 + Iphone55,
269 + Iphone47,
270 + Iphone40,
271 + Iphone35,
272 + IpadPro3Gen129,
273 + IpadPro3Gen11,
274 + IpadPro129,
275 + Ipad105,
276 + Ipad97,
277 + Desktop,
278 + AppleTv,
279 + WatchUltra,
280 + WatchSeries10,
281 + WatchSeries7,
282 + WatchSeries4,
283 + WatchSeries3,
284 +}
285 +
286 +impl From<CliScreenshotDisplayType> for ScreenshotDisplayType {
287 + fn from(value: CliScreenshotDisplayType) -> Self {
288 + match value {
289 + CliScreenshotDisplayType::AppleVisionPro => ScreenshotDisplayType::AppleVisionPro,
290 + CliScreenshotDisplayType::Iphone67 => ScreenshotDisplayType::Iphone67,
291 + CliScreenshotDisplayType::Iphone65 => ScreenshotDisplayType::Iphone65,
292 + CliScreenshotDisplayType::Iphone61 => ScreenshotDisplayType::Iphone61,
293 + CliScreenshotDisplayType::Iphone58 => ScreenshotDisplayType::Iphone58,
294 + CliScreenshotDisplayType::Iphone55 => ScreenshotDisplayType::Iphone55,
295 + CliScreenshotDisplayType::Iphone47 => ScreenshotDisplayType::Iphone47,
296 + CliScreenshotDisplayType::Iphone40 => ScreenshotDisplayType::Iphone40,
297 + CliScreenshotDisplayType::Iphone35 => ScreenshotDisplayType::Iphone35,
298 + CliScreenshotDisplayType::IpadPro3Gen129 => ScreenshotDisplayType::IpadPro3Gen129,
299 + CliScreenshotDisplayType::IpadPro3Gen11 => ScreenshotDisplayType::IpadPro3Gen11,
300 + CliScreenshotDisplayType::IpadPro129 => ScreenshotDisplayType::IpadPro129,
301 + CliScreenshotDisplayType::Ipad105 => ScreenshotDisplayType::Ipad105,
302 + CliScreenshotDisplayType::Ipad97 => ScreenshotDisplayType::Ipad97,
303 + CliScreenshotDisplayType::Desktop => ScreenshotDisplayType::Desktop,
304 + CliScreenshotDisplayType::AppleTv => ScreenshotDisplayType::AppleTv,
305 + CliScreenshotDisplayType::WatchUltra => ScreenshotDisplayType::WatchUltra,
306 + CliScreenshotDisplayType::WatchSeries10 => ScreenshotDisplayType::WatchSeries10,
307 + CliScreenshotDisplayType::WatchSeries7 => ScreenshotDisplayType::WatchSeries7,
308 + CliScreenshotDisplayType::WatchSeries4 => ScreenshotDisplayType::WatchSeries4,
309 + CliScreenshotDisplayType::WatchSeries3 => ScreenshotDisplayType::WatchSeries3,
310 + }
311 + }
312 +}
313 +
314 +#[derive(Debug, Clone, Copy, ValueEnum)]
315 +enum CliPlatform {
316 + Ios,
317 + MacOs,
318 + TvOs,
319 + VisionOs,
320 +}
321 +
322 +impl From<CliPlatform> for Platform {
323 + fn from(value: CliPlatform) -> Self {
324 + match value {
325 + CliPlatform::Ios => Platform::Ios,
326 + CliPlatform::MacOs => Platform::MacOs,
327 + CliPlatform::TvOs => Platform::TvOs,
328 + CliPlatform::VisionOs => Platform::VisionOs,
329 + }
330 + }
331 +}
332 +
333 +#[derive(Debug, Clone, Copy, ValueEnum)]
334 +enum CliBundleIdPlatform {
335 + Ios,
336 + MacOs,
337 + Universal,
338 +}
339 +
340 +impl From<CliBundleIdPlatform> for BundleIdPlatform {
341 + fn from(value: CliBundleIdPlatform) -> Self {
342 + match value {
343 + CliBundleIdPlatform::Ios => BundleIdPlatform::Ios,
344 + CliBundleIdPlatform::MacOs => BundleIdPlatform::MacOs,
345 + CliBundleIdPlatform::Universal => BundleIdPlatform::Universal,
346 + }
347 + }
348 +}
349 +
350 +fn print_json(value: &impl serde::Serialize) -> Result<()> {
351 + println!("{}", serde_json::to_string_pretty(value)?);
352 + Ok(())
353 +}
354 +
355 +/// Print `value` and return without sending anything, when `--dry-run` is
356 +/// set. Every mutating command checks this before touching the network.
357 +fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
358 + if dry_run {
359 + println!("# dry run — request body that would be sent:");
360 + print_json(value)?;
361 + }
362 + Ok(dry_run)
363 +}
364 +
365 +#[tokio::main]
366 +async fn main() -> Result<()> {
367 + let cli = Cli::parse();
368 +
369 + let private_key_path = cli.private_key_path.clone().unwrap_or_else(|| {
370 + let mut path = dirs_home();
371 + path.push(".appstoreconnect");
372 + path.push("private_keys");
373 + path.push(format!("AuthKey_{}.p8", cli.key_id));
374 + path
375 + });
376 +
377 + let api_key = ApiKey::from_p8_file(&cli.key_id, &cli.issuer_id, &private_key_path)
378 + .with_context(|| format!("loading App Store Connect API key from {private_key_path:?}"))?;
379 + let client = Client::new(api_key);
380 +
381 + match cli.command {
382 + Command::Apps { command } => run_apps(&client, command, cli.dry_run).await,
383 + Command::BundleIds { command } => run_bundle_ids(&client, command, cli.dry_run).await,
384 + Command::AppStoreVersions { command } => {
385 + run_app_store_versions(&client, command, cli.dry_run).await
386 + }
387 + Command::AppInfoLocalizations { command } => {
388 + run_app_info_localizations(&client, command, cli.dry_run).await
389 + }
390 + Command::AppStoreVersionLocalizations { command } => {
391 + run_app_store_version_localizations(&client, command, cli.dry_run).await
392 + }
393 + Command::AppScreenshotSets { command } => {
394 + run_app_screenshot_sets(&client, command, cli.dry_run).await
395 + }
396 + Command::AppScreenshots { command } => {
397 + run_app_screenshots(&client, command, cli.dry_run).await
398 + }
399 + }
400 +}
401 +
402 +fn dirs_home() -> PathBuf {
403 + // Avoids pulling in the `dirs` crate for a single lookup; $HOME is set
404 + // on every platform this CLI targets (macOS/Linux CI runners).
405 + std::env::var_os("HOME")
406 + .map(PathBuf::from)
407 + .unwrap_or_else(|| PathBuf::from("."))
408 +}
409 +
410 +async fn run_apps(client: &Client, command: AppsCommand, dry_run: bool) -> Result<()> {
411 + match command {
412 + AppsCommand::List { bundle_id } => {
413 + let apps = client.list_apps(bundle_id.as_deref()).await?;
414 + print_json(&apps)
415 + }
416 + AppsCommand::Get { app_id } => print_json(&client.get_app(&app_id).await?),
417 + AppsCommand::Update {
418 + app_id,
419 + primary_locale,
420 + } => {
421 + let attributes = AppUpdateAttributes {
422 + primary_locale,
423 + content_rights_declaration: None,
424 + };
425 + if dry_run_guard(dry_run, &attributes)? {
426 + return Ok(());
427 + }
428 + print_json(&client.update_app(&app_id, attributes).await?)
429 + }
430 + AppsCommand::Infos { app_id } => print_json(&client.list_app_infos(&app_id).await?),
431 + AppsCommand::Builds { app_id } => print_json(&client.list_builds(&app_id).await?),
432 + }
433 +}
434 +
435 +async fn run_bundle_ids(client: &Client, command: BundleIdsCommand, dry_run: bool) -> Result<()> {
436 + match command {
437 + BundleIdsCommand::List { identifier } => {
438 + print_json(&client.list_bundle_ids(identifier.as_deref()).await?)
439 + }
440 + BundleIdsCommand::Create {
441 + identifier,
442 + name,
443 + platform,
444 + } => {
445 + let attributes = BundleIdCreateAttributes {
446 + identifier,
447 + name,
448 + platform: platform.into(),
449 + };
450 + if dry_run_guard(dry_run, &attributes)? {
451 + return Ok(());
452 + }
453 + print_json(&client.create_bundle_id(attributes).await?)
454 + }
455 + }
456 +}
457 +
458 +async fn run_app_store_versions(
459 + client: &Client,
460 + command: AppStoreVersionsCommand,
461 + dry_run: bool,
462 +) -> Result<()> {
463 + match command {
464 + AppStoreVersionsCommand::List { app_id, platform } => {
465 + let versions = client
466 + .list_app_store_versions(&app_id, platform.map(Into::into))
467 + .await?;
468 + print_json(&versions)
469 + }
470 + AppStoreVersionsCommand::Get { id } => {
471 + print_json(&client.get_app_store_version(&id).await?)
472 + }
473 + AppStoreVersionsCommand::Create {
474 + app_id,
475 + platform,
476 + version_string,
477 + copyright,
478 + } => {
479 + let attributes = AppStoreVersionCreateAttributes {
480 + platform: platform.into(),
481 + version_string,
482 + copyright,
483 + };
484 + if dry_run_guard(dry_run, &attributes)? {
485 + return Ok(());
486 + }
487 + print_json(&client.create_app_store_version(&app_id, attributes).await?)
488 + }
489 + AppStoreVersionsCommand::Delete { id } => {
490 + if dry_run {
491 + println!("# dry run — would DELETE /v1/appStoreVersions/{id}");
492 + return Ok(());
493 + }
494 + client.delete_app_store_version(&id).await?;
495 + println!("deleted {id}");
496 + Ok(())
497 + }
498 + AppStoreVersionsCommand::SetBuild { id, build_id } => {
499 + if dry_run {
500 + println!(
501 + "# dry run — would PATCH /v1/appStoreVersions/{id} relationships.build → {build_id}"
502 + );
503 + return Ok(());
504 + }
505 + print_json(&client.set_app_store_version_build(&id, &build_id).await?)
506 + }
507 + }
508 +}
509 +
510 +async fn run_app_info_localizations(
511 + client: &Client,
512 + command: AppInfoLocalizationsCommand,
513 + dry_run: bool,
514 +) -> Result<()> {
515 + match command {
516 + AppInfoLocalizationsCommand::List { app_info_id } => {
517 + print_json(&client.list_app_info_localizations(&app_info_id).await?)
518 + }
519 + AppInfoLocalizationsCommand::Create {
520 + app_info_id,
521 + locale,
522 + name,
523 + subtitle,
524 + } => {
525 + let attributes = AppInfoLocalizationCreateAttributes {
526 + locale,
527 + name,
528 + subtitle,
529 + privacy_policy_url: None,
530 + };
531 + if dry_run_guard(dry_run, &attributes)? {
532 + return Ok(());
533 + }
534 + print_json(
535 + &client
536 + .create_app_info_localization(&app_info_id, attributes)
537 + .await?,
538 + )
539 + }
540 + AppInfoLocalizationsCommand::Update { id, name, subtitle } => {
541 + let attributes = AppInfoLocalizationUpdateAttributes {
542 + name,
543 + subtitle,
544 + privacy_policy_url: None,
545 + };
546 + if dry_run_guard(dry_run, &attributes)? {
547 + return Ok(());
548 + }
549 + print_json(&client.update_app_info_localization(&id, attributes).await?)
550 + }
551 + AppInfoLocalizationsCommand::Delete { id } => {
552 + if dry_run {
553 + println!("# dry run — would DELETE /v1/appInfoLocalizations/{id}");
554 + return Ok(());
555 + }
556 + client.delete_app_info_localization(&id).await?;
557 + println!("deleted {id}");
558 + Ok(())
559 + }
560 + }
561 +}
562 +
563 +async fn run_app_store_version_localizations(
564 + client: &Client,
565 + command: AppStoreVersionLocalizationsCommand,
566 + dry_run: bool,
567 +) -> Result<()> {
568 + match command {
569 + AppStoreVersionLocalizationsCommand::List {
570 + app_store_version_id,
571 + } => print_json(
572 + &client
573 + .list_app_store_version_localizations(&app_store_version_id)
574 + .await?,
575 + ),
576 + AppStoreVersionLocalizationsCommand::Create {
577 + app_store_version_id,
578 + locale,
579 + description,
580 + keywords,
581 + whats_new,
582 + promotional_text,
583 + marketing_url,
584 + support_url,
585 + } => {
586 + let attributes = AppStoreVersionLocalizationCreateAttributes {
587 + locale,
588 + fields: AppStoreVersionLocalizationFields {
589 + description,
590 + keywords,
591 + whats_new,
592 + promotional_text,
593 + marketing_url,
594 + support_url,
595 + },
596 + };
597 + if dry_run_guard(dry_run, &attributes)? {
598 + return Ok(());
599 + }
600 + print_json(
601 + &client
602 + .create_app_store_version_localization(&app_store_version_id, attributes)
603 + .await?,
604 + )
605 + }
606 + AppStoreVersionLocalizationsCommand::Update {
607 + id,
608 + description,
609 + keywords,
610 + whats_new,
611 + promotional_text,
612 + marketing_url,
613 + support_url,
614 + } => {
615 + let fields = AppStoreVersionLocalizationFields {
616 + description,
617 + keywords,
618 + whats_new,
619 + promotional_text,
620 + marketing_url,
621 + support_url,
622 + };
623 + if dry_run_guard(dry_run, &fields)? {
624 + return Ok(());
625 + }
626 + print_json(
627 + &client
628 + .update_app_store_version_localization(&id, fields)
629 + .await?,
630 + )
631 + }
632 + AppStoreVersionLocalizationsCommand::Delete { id } => {
633 + if dry_run {
634 + println!("# dry run — would DELETE /v1/appStoreVersionLocalizations/{id}");
635 + return Ok(());
636 + }
637 + client.delete_app_store_version_localization(&id).await?;
638 + println!("deleted {id}");
639 + Ok(())
640 + }
641 + }
642 +}
643 +
644 +async fn run_app_screenshot_sets(
645 + client: &Client,
646 + command: AppScreenshotSetsCommand,
647 + dry_run: bool,
648 +) -> Result<()> {
649 + match command {
650 + AppScreenshotSetsCommand::List {
651 + app_store_version_localization_id,
652 + } => print_json(
653 + &client
654 + .list_app_screenshot_sets(&app_store_version_localization_id)
655 + .await?,
656 + ),
657 + AppScreenshotSetsCommand::Create {
658 + app_store_version_localization_id,
659 + display_type,
660 + } => {
661 + let attributes = AppScreenshotSetCreateAttributes {
662 + screenshot_display_type: display_type.into(),
663 + };
664 + if dry_run_guard(dry_run, &attributes)? {
665 + return Ok(());
666 + }
667 + print_json(
668 + &client
669 + .create_app_screenshot_set(&app_store_version_localization_id, attributes)
670 + .await?,
671 + )
672 + }
673 + AppScreenshotSetsCommand::Delete { id } => {
674 + if dry_run {
675 + println!("# dry run — would DELETE /v1/appScreenshotSets/{id}");
676 + return Ok(());
677 + }
678 + client.delete_app_screenshot_set(&id).await?;
679 + println!("deleted {id}");
680 + Ok(())
681 + }
682 + }
683 +}
684 +
685 +async fn run_app_screenshots(
686 + client: &Client,
687 + command: AppScreenshotsCommand,
688 + dry_run: bool,
689 +) -> Result<()> {
690 + match command {
691 + AppScreenshotsCommand::List {
692 + app_screenshot_set_id,
693 + } => print_json(&client.list_app_screenshots(&app_screenshot_set_id).await?),
694 + AppScreenshotsCommand::Upload {
695 + app_screenshot_set_id,
696 + file,
697 + } => {
698 + let file_name = file
699 + .file_name()
700 + .map(|n| n.to_string_lossy().into_owned())
701 + .with_context(|| format!("{file:?} has no file name"))?;
702 + let bytes = std::fs::read(&file)
703 + .with_context(|| format!("reading screenshot file {file:?}"))?;
704 +
705 + if dry_run {
706 + println!(
707 + "# dry run — would upload {file_name} ({} bytes) to appScreenshotSet {app_screenshot_set_id}",
708 + bytes.len()
709 + );
710 + return Ok(());
711 + }
712 +
713 + print_json(
714 + &client
715 + .upload_app_screenshot(&app_screenshot_set_id, file_name, bytes)
716 + .await?,
717 + )
718 + }
719 + AppScreenshotsCommand::Delete { id } => {
720 + if dry_run {
721 + println!("# dry run — would DELETE /v1/appScreenshots/{id}");
722 + return Ok(());
723 + }
724 + client.delete_app_screenshot(&id).await?;
725 + println!("deleted {id}");
726 + Ok(())
727 + }
728 + }
729 +}