1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use reqwest::multipart::{Form, Part};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::{Result, WepubError, http::build_client};
9
10use super::auth::generate_jwt;
11
12const DEFAULT_ROOT_URL: &str = "https://addons.mozilla.org/";
13const UPLOAD_FILE_NAME: &str = "addon.zip";
14const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1);
15const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
16
17#[derive(Debug, Clone, Default)]
19pub struct FirefoxPublishOptions {
20 pub channel: Channel,
22 pub compatibility: Option<Compatibility>,
25 pub release_notes: HashMap<String, String>,
27 pub approval_notes: Option<String>,
30 pub source: Option<Vec<u8>>,
33 pub poll: FirefoxPollConfig,
36}
37
38#[derive(Debug, Clone)]
43pub struct FirefoxPollConfig {
44 pub interval: Duration,
46 pub timeout: Duration,
49}
50
51impl Default for FirefoxPollConfig {
52 fn default() -> Self {
53 Self {
54 interval: DEFAULT_POLL_INTERVAL,
55 timeout: DEFAULT_POLL_TIMEOUT,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, Default)]
62pub enum Channel {
63 #[default]
66 Listed,
67 Unlisted,
69}
70
71impl Channel {
72 fn as_str(self) -> &'static str {
73 match self {
74 Channel::Listed => "listed",
75 Channel::Unlisted => "unlisted",
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
87pub enum Compatibility {
88 Apps(Vec<Application>),
90 Detailed(HashMap<Application, VersionRange>),
93}
94
95impl Serialize for Compatibility {
96 fn serialize<S: serde::Serializer>(
97 &self,
98 serializer: S,
99 ) -> std::result::Result<S::Ok, S::Error> {
100 match self {
101 Self::Apps(apps) => apps.serialize(serializer),
102 Self::Detailed(map) => map.serialize(serializer),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum Application {
110 Firefox,
112 Android,
114}
115
116impl Application {
117 fn as_str(self) -> &'static str {
118 match self {
119 Application::Firefox => "firefox",
120 Application::Android => "android",
121 }
122 }
123}
124
125impl Serialize for Application {
126 fn serialize<S: serde::Serializer>(
127 &self,
128 serializer: S,
129 ) -> std::result::Result<S::Ok, S::Error> {
130 serializer.serialize_str(self.as_str())
131 }
132}
133
134#[derive(Debug, Clone, Default, Serialize)]
140pub struct VersionRange {
141 #[serde(skip_serializing_if = "Option::is_none")]
144 pub min: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
148 pub max: Option<String>,
149}
150
151#[derive(Debug, Clone, Deserialize)]
155pub(crate) struct VersionResponse {
156 pub(crate) id: u64,
157}
158
159pub struct FirefoxStore {
166 addon_id: String,
167 issuer: String,
168 secret: String,
169 root_url: Url,
170 client: reqwest::Client,
171}
172
173impl FirefoxStore {
174 pub fn from_jwt_credentials(
185 addon_id: String,
186 jwt_issuer: String,
187 jwt_secret: String,
188 ) -> Result<Self> {
189 Ok(Self {
190 addon_id,
191 issuer: jwt_issuer,
192 secret: jwt_secret,
193 root_url: Url::parse(DEFAULT_ROOT_URL).expect("DEFAULT_ROOT_URL is a valid URL"),
194 client: build_client()?,
195 })
196 }
197
198 pub fn with_root_url(mut self, root_url: &str) -> Result<Self> {
210 let parsed = Url::parse(root_url)
211 .map_err(|e| WepubError::InvalidUrl(format!("{root_url:?}: {e}")))?;
212 self.root_url = ensure_trailing_slash(parsed);
213 Ok(self)
214 }
215
216 pub async fn publish(&self, zip: Vec<u8>, options: FirefoxPublishOptions) -> Result<()> {
251 let upload = self.upload(zip, options.channel).await?;
252 let validated = self
253 .wait_until_validated(&upload.uuid, &options.poll)
254 .await?;
255
256 let version = self
257 .create_version(
258 &validated.uuid,
259 options.compatibility.as_ref(),
260 &options.release_notes,
261 options.approval_notes.as_deref(),
262 )
263 .await?;
264
265 if let Some(source) = options.source {
266 self.patch_version_source(version.id, source).await?;
267 }
268
269 tracing::info!(version_id = version.id, "published version");
270 Ok(())
271 }
272
273 pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
274 self.root_url
275 .join(path)
276 .map_err(|e| WepubError::Internal(format!("invalid endpoint path {path:?}: {e}")))
277 }
278
279 pub(crate) async fn upload(&self, zip: Vec<u8>, channel: Channel) -> Result<UploadResponse> {
280 let url = self.endpoint("api/v5/addons/upload/")?;
281 let auth = self.auth_header()?;
282
283 let len = zip.len() as u64;
284 let part = Part::stream_with_length(reqwest::Body::from(zip), len)
285 .file_name(UPLOAD_FILE_NAME)
286 .mime_str("application/zip")
287 .map_err(|e| WepubError::Internal(format!("invalid MIME literal: {e}")))?;
288 let form = Form::new()
289 .part("upload", part)
290 .text("channel", channel.as_str());
291
292 tracing::info!(addon_id = %self.addon_id, channel = channel.as_str(), "uploading add-on to AMO");
293
294 let resp = self
295 .client
296 .post(url)
297 .header(reqwest::header::AUTHORIZATION, auth)
298 .multipart(form)
299 .send()
300 .await?;
301
302 decode_response(resp).await
303 }
304
305 pub(crate) async fn wait_until_validated(
306 &self,
307 uuid: &str,
308 config: &FirefoxPollConfig,
309 ) -> Result<UploadResponse> {
310 let url = self.endpoint(&format!("api/v5/addons/upload/{uuid}/"))?;
311 let started = Instant::now();
312
313 loop {
314 let auth = self.auth_header()?;
315 let resp = self
316 .client
317 .get(url.clone())
318 .header(reqwest::header::AUTHORIZATION, auth)
319 .send()
320 .await?;
321 let upload: UploadResponse = decode_response(resp).await?;
322
323 tracing::info!(
324 uuid = uuid,
325 processed = upload.processed,
326 valid = upload.valid,
327 "polling AMO upload status"
328 );
329
330 if upload.processed {
331 if upload.valid {
332 return Ok(upload);
333 }
334 let body = upload.validation.as_ref().map_or_else(
335 || "validation failed (no detail provided)".to_string(),
336 |v| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()),
337 );
338 return Err(WepubError::Validation {
339 uuid: uuid.to_string(),
340 body,
341 });
342 }
343
344 if started.elapsed() >= config.timeout {
345 return Err(WepubError::Validation {
346 uuid: uuid.to_string(),
347 body: format!("validation timed out after {:?}", config.timeout),
348 });
349 }
350
351 tokio::time::sleep(config.interval).await;
352 }
353 }
354
355 pub(crate) async fn create_version(
356 &self,
357 upload_uuid: &str,
358 compatibility: Option<&Compatibility>,
359 release_notes: &HashMap<String, String>,
360 approval_notes: Option<&str>,
361 ) -> Result<VersionResponse> {
362 let url = self.endpoint(&format!("api/v5/addons/addon/{}/versions/", self.addon_id))?;
363 let auth = self.auth_header()?;
364
365 let body = VersionCreateBody {
366 upload: upload_uuid,
367 compatibility,
368 release_notes,
369 approval_notes,
370 };
371
372 tracing::info!(
373 addon_id = %self.addon_id,
374 uuid = upload_uuid,
375 "creating AMO version"
376 );
377
378 let resp = self
379 .client
380 .post(url)
381 .header(reqwest::header::AUTHORIZATION, auth)
382 .json(&body)
383 .send()
384 .await?;
385
386 decode_response(resp).await
387 }
388
389 pub(crate) async fn patch_version_source(
390 &self,
391 version_id: u64,
392 source: Vec<u8>,
393 ) -> Result<VersionResponse> {
394 let url = self.endpoint(&format!(
395 "api/v5/addons/addon/{}/versions/{version_id}/",
396 self.addon_id
397 ))?;
398 let auth = self.auth_header()?;
399
400 let len = source.len() as u64;
401 let part = Part::stream_with_length(reqwest::Body::from(source), len)
402 .file_name("source.zip")
403 .mime_str("application/zip")
404 .map_err(|e| WepubError::Internal(format!("invalid MIME literal: {e}")))?;
405 let form = Form::new().part("source", part);
406
407 tracing::info!(
408 addon_id = %self.addon_id,
409 version_id,
410 "uploading version source to AMO"
411 );
412
413 let resp = self
414 .client
415 .patch(url)
416 .header(reqwest::header::AUTHORIZATION, auth)
417 .multipart(form)
418 .send()
419 .await?;
420
421 decode_response(resp).await
422 }
423
424 fn auth_header(&self) -> Result<String> {
425 let token = generate_jwt(&self.issuer, &self.secret)?;
426 Ok(format!("JWT {token}"))
427 }
428}
429
430#[derive(Debug, Clone, Deserialize)]
431pub(crate) struct UploadResponse {
432 pub uuid: String,
433 pub processed: bool,
434 pub valid: bool,
435 #[serde(default)]
436 pub validation: Option<serde_json::Value>,
437}
438
439#[derive(Serialize)]
440struct VersionCreateBody<'a> {
441 upload: &'a str,
442 #[serde(skip_serializing_if = "Option::is_none")]
443 compatibility: Option<&'a Compatibility>,
444 #[serde(skip_serializing_if = "HashMap::is_empty")]
445 release_notes: &'a HashMap<String, String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 approval_notes: Option<&'a str>,
448}
449
450async fn decode_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
451 let status = resp.status();
452 let body = resp.text().await?;
453 tracing::debug!(
454 status = status.as_u16(),
455 body = %body,
456 "received AMO response",
457 );
458 if !status.is_success() {
459 return Err(WepubError::Api {
460 status: status.as_u16(),
461 body,
462 });
463 }
464 serde_json::from_str(&body).map_err(WepubError::from)
465}
466
467fn ensure_trailing_slash(mut url: Url) -> Url {
468 if !url.path().ends_with('/') {
469 let new_path = format!("{}/", url.path());
470 url.set_path(&new_path);
471 }
472 url
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use serde_json::json;
479 use wiremock::matchers::{header_exists, method, path};
480 use wiremock::{Mock, MockServer, ResponseTemplate};
481
482 #[tokio::test]
483 async fn upload_posts_multipart_and_parses_response() {
484 let server = MockServer::start().await;
485 Mock::given(method("POST"))
486 .and(path("/api/v5/addons/upload/"))
487 .and(header_exists("authorization"))
488 .respond_with(
489 ResponseTemplate::new(201).set_body_json(upload_json("abc-123", false, false)),
490 )
491 .expect(1)
492 .mount(&server)
493 .await;
494
495 let store = store_for(&server);
496 let resp = store
497 .upload(b"fake-zip".to_vec(), Channel::Listed)
498 .await
499 .unwrap();
500
501 assert_eq!(resp.uuid, "abc-123");
502 assert!(!resp.processed);
503 }
504
505 #[tokio::test]
506 async fn wait_until_validated_returns_when_processed_and_valid() {
507 let server = MockServer::start().await;
508
509 Mock::given(method("GET"))
510 .and(path("/api/v5/addons/upload/uuid-1/"))
511 .respond_with(
512 ResponseTemplate::new(200).set_body_json(upload_json("uuid-1", false, false)),
513 )
514 .up_to_n_times(1)
515 .mount(&server)
516 .await;
517
518 Mock::given(method("GET"))
519 .and(path("/api/v5/addons/upload/uuid-1/"))
520 .respond_with(
521 ResponseTemplate::new(200).set_body_json(upload_json("uuid-1", true, true)),
522 )
523 .mount(&server)
524 .await;
525
526 let store = store_for(&server);
527 let resp = store
528 .wait_until_validated("uuid-1", &fast_poll())
529 .await
530 .unwrap();
531
532 assert_eq!(resp.uuid, "uuid-1");
533 assert!(resp.processed);
534 assert!(resp.valid);
535 }
536
537 #[tokio::test]
538 async fn wait_until_validated_errors_on_invalid_validation() {
539 let server = MockServer::start().await;
540 let body = json!({
541 "uuid": "uuid-2",
542 "channel": "listed",
543 "processed": true,
544 "submitted": false,
545 "url": "https://example.com/upload/uuid-2/",
546 "valid": false,
547 "validation": { "messages": [{ "type": "error", "message": "manifest broken" }] },
548 "version": null,
549 });
550 Mock::given(method("GET"))
551 .and(path("/api/v5/addons/upload/uuid-2/"))
552 .respond_with(ResponseTemplate::new(200).set_body_json(body))
553 .mount(&server)
554 .await;
555
556 let store = store_for(&server);
557 let err = store
558 .wait_until_validated("uuid-2", &fast_poll())
559 .await
560 .unwrap_err();
561
562 match err {
563 WepubError::Validation { uuid, body } => {
564 assert_eq!(uuid, "uuid-2");
565 assert!(body.contains("manifest broken"));
566 }
567 other => panic!("expected WepubError::Validation, got {other:?}"),
568 }
569 }
570
571 #[tokio::test]
572 async fn wait_until_validated_times_out_when_processing_never_completes() {
573 let server = MockServer::start().await;
574 Mock::given(method("GET"))
575 .and(path("/api/v5/addons/upload/uuid-3/"))
576 .respond_with(
577 ResponseTemplate::new(200).set_body_json(upload_json("uuid-3", false, false)),
578 )
579 .mount(&server)
580 .await;
581
582 let store = store_for(&server);
583 let err = store
584 .wait_until_validated("uuid-3", &fast_poll())
585 .await
586 .unwrap_err();
587
588 match err {
589 WepubError::Validation { uuid, body } => {
590 assert_eq!(uuid, "uuid-3");
591 assert!(body.contains("timed out"));
592 }
593 other => panic!("expected WepubError::Validation, got {other:?}"),
594 }
595 }
596
597 #[tokio::test]
598 async fn create_version_posts_json_and_parses_id() {
599 let server = MockServer::start().await;
600 Mock::given(method("POST"))
601 .and(path("/api/v5/addons/addon/test-addon/versions/"))
602 .and(header_exists("authorization"))
603 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 4242 })))
604 .expect(1)
605 .mount(&server)
606 .await;
607
608 let store = store_for(&server);
609 let resp = store
610 .create_version("uuid-x", None, &HashMap::new(), None)
611 .await
612 .unwrap();
613
614 assert_eq!(resp.id, 4242);
615 }
616
617 #[tokio::test]
618 async fn patch_version_source_sends_multipart_patch() {
619 let server = MockServer::start().await;
620 Mock::given(method("PATCH"))
621 .and(path("/api/v5/addons/addon/test-addon/versions/4242/"))
622 .and(header_exists("authorization"))
623 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4242 })))
624 .expect(1)
625 .mount(&server)
626 .await;
627
628 let store = store_for(&server);
629 let resp = store
630 .patch_version_source(4242, b"source-zip".to_vec())
631 .await
632 .unwrap();
633
634 assert_eq!(resp.id, 4242);
635 }
636
637 #[tokio::test]
638 async fn publish_runs_full_flow_when_source_is_provided() {
639 let server = MockServer::start().await;
640
641 Mock::given(method("POST"))
642 .and(path("/api/v5/addons/upload/"))
643 .respond_with(
644 ResponseTemplate::new(201).set_body_json(upload_json("uuid-pub", false, false)),
645 )
646 .expect(1)
647 .mount(&server)
648 .await;
649
650 Mock::given(method("GET"))
651 .and(path("/api/v5/addons/upload/uuid-pub/"))
652 .respond_with(
653 ResponseTemplate::new(200).set_body_json(upload_json("uuid-pub", true, true)),
654 )
655 .mount(&server)
656 .await;
657
658 Mock::given(method("POST"))
659 .and(path("/api/v5/addons/addon/test-addon/versions/"))
660 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 7777 })))
661 .expect(1)
662 .mount(&server)
663 .await;
664
665 Mock::given(method("PATCH"))
666 .and(path("/api/v5/addons/addon/test-addon/versions/7777/"))
667 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 7777 })))
668 .expect(1)
669 .mount(&server)
670 .await;
671
672 let store = store_for(&server);
673 let options = FirefoxPublishOptions {
674 source: Some(b"source-zip".to_vec()),
675 poll: fast_poll(),
676 ..FirefoxPublishOptions::default()
677 };
678 store.publish(b"zip".to_vec(), options).await.unwrap();
679 }
680
681 #[tokio::test]
682 async fn publish_skips_source_patch_when_no_source_provided() {
683 let server = MockServer::start().await;
684
685 Mock::given(method("POST"))
686 .and(path("/api/v5/addons/upload/"))
687 .respond_with(
688 ResponseTemplate::new(201).set_body_json(upload_json("uuid-ns", true, true)),
689 )
690 .expect(1)
691 .mount(&server)
692 .await;
693
694 Mock::given(method("GET"))
695 .and(path("/api/v5/addons/upload/uuid-ns/"))
696 .respond_with(
697 ResponseTemplate::new(200).set_body_json(upload_json("uuid-ns", true, true)),
698 )
699 .mount(&server)
700 .await;
701
702 Mock::given(method("POST"))
703 .and(path("/api/v5/addons/addon/test-addon/versions/"))
704 .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 9999 })))
705 .expect(1)
706 .mount(&server)
707 .await;
708
709 Mock::given(method("PATCH"))
710 .and(path("/api/v5/addons/addon/test-addon/versions/9999/"))
711 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 9999 })))
712 .expect(0)
713 .mount(&server)
714 .await;
715
716 let store = store_for(&server);
717 let options = FirefoxPublishOptions {
718 poll: fast_poll(),
719 ..FirefoxPublishOptions::default()
720 };
721 store.publish(b"zip".to_vec(), options).await.unwrap();
722 }
723
724 #[tokio::test]
725 async fn publish_propagates_upload_api_error() {
726 let server = MockServer::start().await;
727 Mock::given(method("POST"))
728 .and(path("/api/v5/addons/upload/"))
729 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
730 .expect(1)
731 .mount(&server)
732 .await;
733
734 let store = store_for(&server);
735 let options = FirefoxPublishOptions {
736 poll: fast_poll(),
737 ..FirefoxPublishOptions::default()
738 };
739 let err = store.publish(b"zip".to_vec(), options).await.unwrap_err();
740
741 match err {
742 WepubError::Api { status, body } => {
743 assert_eq!(status, 401);
744 assert_eq!(body, "unauthorized");
745 }
746 other => panic!("expected WepubError::Api, got {other:?}"),
747 }
748 }
749
750 #[test]
753 fn channel_serialises_as_amo_expects() {
754 assert_eq!(Channel::Listed.as_str(), "listed");
755 assert_eq!(Channel::Unlisted.as_str(), "unlisted");
756 }
757
758 #[test]
759 fn version_create_body_minimal_only_has_upload() {
760 let json = body_to_json("uuid-123", None, &HashMap::new(), None);
761 assert_eq!(json, serde_json::json!({ "upload": "uuid-123" }));
762 }
763
764 #[test]
765 fn version_create_body_with_apps_shorthand() {
766 let compat = Compatibility::Apps(vec![Application::Firefox, Application::Android]);
767 let json = body_to_json("uuid-123", Some(&compat), &HashMap::new(), None);
768 assert_eq!(
769 json,
770 serde_json::json!({
771 "upload": "uuid-123",
772 "compatibility": ["firefox", "android"],
773 })
774 );
775 }
776
777 #[test]
778 fn version_create_body_with_detailed_compatibility_omits_empty_min_max() {
779 let mut map = HashMap::new();
780 map.insert(
781 Application::Firefox,
782 VersionRange {
783 min: Some("58.0".into()),
784 max: Some("120.0".into()),
785 },
786 );
787 map.insert(
788 Application::Android,
789 VersionRange {
790 min: Some("58.0".into()),
791 max: None,
792 },
793 );
794 let compat = Compatibility::Detailed(map);
795 let json = body_to_json("uuid-123", Some(&compat), &HashMap::new(), None);
796
797 assert_eq!(json["upload"], "uuid-123");
798 assert_eq!(
799 json["compatibility"]["firefox"],
800 serde_json::json!({ "min": "58.0", "max": "120.0" })
801 );
802 assert_eq!(
803 json["compatibility"]["android"],
804 serde_json::json!({ "min": "58.0" })
805 );
806 }
807
808 #[test]
809 fn version_create_body_includes_release_notes_and_approval_notes() {
810 let mut notes = HashMap::new();
811 notes.insert("en-US".into(), "Hello".into());
812 notes.insert("ja".into(), "こんにちは".into());
813
814 let json = body_to_json("uuid-123", None, ¬es, Some("for reviewers"));
815
816 assert_eq!(json["upload"], "uuid-123");
817 assert_eq!(json["release_notes"]["en-US"], "Hello");
818 assert_eq!(json["release_notes"]["ja"], "こんにちは");
819 assert_eq!(json["approval_notes"], "for reviewers");
820 }
821
822 #[test]
823 fn endpoint_joins_relative_path() {
824 let store = FirefoxStore::from_jwt_credentials(
825 "test-addon".into(),
826 "issuer".into(),
827 "secret".into(),
828 )
829 .unwrap();
830 let url = store.endpoint("api/v5/addons/upload/").unwrap();
831 assert_eq!(
832 url.as_str(),
833 "https://addons.mozilla.org/api/v5/addons/upload/"
834 );
835 }
836
837 #[test]
838 fn with_root_url_overrides_default() {
839 let store = FirefoxStore::from_jwt_credentials(
840 "test-addon".into(),
841 "issuer".into(),
842 "secret".into(),
843 )
844 .unwrap()
845 .with_root_url("http://127.0.0.1:8000/")
846 .unwrap();
847 let url = store.endpoint("api/v5/addons/upload/").unwrap();
848 assert_eq!(url.as_str(), "http://127.0.0.1:8000/api/v5/addons/upload/");
849 }
850
851 #[test]
852 fn with_root_url_rejects_garbage() {
853 let store = FirefoxStore::from_jwt_credentials(
854 "test-addon".into(),
855 "issuer".into(),
856 "secret".into(),
857 )
858 .unwrap();
859 let Err(err) = store.with_root_url("not a url") else {
860 panic!("expected with_root_url to reject");
861 };
862 assert!(matches!(err, WepubError::InvalidUrl(_)), "got {err:?}");
863 }
864
865 #[test]
866 fn ensure_trailing_slash_appends_when_missing() {
867 let url = Url::parse("https://example.com/api/v5").unwrap();
868 let result = ensure_trailing_slash(url);
869 assert_eq!(result.as_str(), "https://example.com/api/v5/");
870 }
871
872 #[test]
873 fn ensure_trailing_slash_is_idempotent() {
874 let url = Url::parse("https://example.com/api/v5/").unwrap();
875 let result = ensure_trailing_slash(url);
876 assert_eq!(result.as_str(), "https://example.com/api/v5/");
877 }
878
879 fn store_for(server: &MockServer) -> FirefoxStore {
880 FirefoxStore::from_jwt_credentials("test-addon".into(), "issuer".into(), "secret".into())
881 .unwrap()
882 .with_root_url(&server.uri())
883 .unwrap()
884 }
885
886 fn fast_poll() -> FirefoxPollConfig {
887 FirefoxPollConfig {
888 interval: Duration::from_millis(10),
889 timeout: Duration::from_millis(200),
890 }
891 }
892
893 fn upload_json(uuid: &str, processed: bool, valid: bool) -> serde_json::Value {
894 json!({
895 "uuid": uuid,
896 "channel": "listed",
897 "processed": processed,
898 "submitted": false,
899 "url": format!("https://example.com/upload/{uuid}/"),
900 "valid": valid,
901 "validation": null,
902 "version": "1.0.0",
903 })
904 }
905
906 fn body_to_json(
907 upload: &str,
908 compatibility: Option<&Compatibility>,
909 release_notes: &HashMap<String, String>,
910 approval_notes: Option<&str>,
911 ) -> serde_json::Value {
912 serde_json::to_value(VersionCreateBody {
913 upload,
914 compatibility,
915 release_notes,
916 approval_notes,
917 })
918 .unwrap()
919 }
920}