1use reqwest::Method;
6use serde::{Deserialize, Serialize};
7use time::Date;
8use uuid::Uuid;
9use url::Url;
10use strum_macros::Display;
11use CardCatalogResource::Autocomplete;
12use CardPageResource::Search;
13use CardResource::*;
14use CardCollectionResource::*;
15use crate::HttpResource;
16use crate::resources::card_symbols::ColorSymbol;
17use crate::resources::catalog::Catalog;
18use crate::resources::ResourceKind;
19
20pub enum CardResource<'a> {
26 ById(&'a str),
30
31 ByArenaId(&'a str),
35
36 ByCardmarketId(&'a str),
40
41 ByCode(&'a str, &'a str),
45
46 ByMtgoId(&'a str),
50
51 ByMultiverseId(&'a str),
55
56 ByTcgplayerId(&'a str),
60
61 NamedExact(&'a str),
65
66 NamedFuzzy(&'a str),
71
72 Random(Option<&'a str>),
78}
79
80pub enum CardPageResource {
82 Search(SearchQueryParams)
121}
122
123pub enum CardCatalogResource<'a> {
125 Autocomplete(&'a str),
133}
134
135pub enum CardCollectionResource {
137 WithIdentifiers(CardIdentifiers),
158}
159
160impl<'a> HttpResource<Card> for CardResource<'a> {
161 fn path(&self) -> String {
162 format!("cards/{}", match self {
163 ById(id) => id.to_string(),
164
165 ByArenaId(id) => format!(
166 "arena/{id}"
167 ),
168 ByCardmarketId(id) => format!(
169 "cardmarket/{id}"
170 ),
171 ByCode(code, number) => format!(
172 "{code}/{number}"
173 ),
174 ByMtgoId(id) => format!(
175 "mtgo/{id}"
176 ),
177 ByMultiverseId(id) => format!(
178 "multiverse/{id}"
179 ),
180 ByTcgplayerId(id) => format!(
181 "tcgplayer/{id}"
182 ),
183 NamedExact(name) => format!(
184 "named?exact={name}"
185 ),
186 NamedFuzzy(name) => format!(
187 "named?fuzzy={name}"
188 ),
189 Random(query) => format!(
190 "random{}", query
191 .map(|q| format!("?q={q}"))
192 .unwrap_or("".into())
193 ),
194 })
195 }
196}
197
198impl HttpResource<CardPage> for CardPageResource {
199 fn path(&self) -> String {
200 format!("cards/search{}", match self {
201 Search(params) => params.as_query_str()
202 })
203 }
204}
205
206impl<'a> HttpResource<Catalog> for CardCatalogResource<'a> {
207 fn path(&self) -> String {
208 format!("cards/{}", match self {
209 Autocomplete(q) => format!("autocomplete?q={q}")
210 })
211 }
212}
213
214impl HttpResource<CardCollection> for CardCollectionResource {
215 fn method(&self) -> Method {
216 match self {
217 WithIdentifiers(_) => Method::POST,
218 }
219 }
220
221 fn path(&self) -> String {
222 match self {
223 WithIdentifiers(_) => "cards/collection".into()
224 }
225 }
226
227 fn json(&self) -> Option<String> {
228 match self {
229 WithIdentifiers(r) => serde_json::to_string(r).ok(),
230 }
231 }
232}
233
234#[derive(Debug, Serialize, Deserialize, PartialEq)]
240pub struct Card {
241 pub all_parts: Option<Vec<RelatedCard>>,
242 pub arena_id: Option<i32>,
243 pub artist: Option<String>,
244 pub artist_ids: Vec<Uuid>,
245 pub booster: bool,
246 pub border_color: String,
247 pub card_back_id: Option<Uuid>,
248 pub card_faces: Option<Vec<CardFace>>,
249 pub cardmarket_id: Option<i32>,
250 pub cmc: f64,
251 pub collector_number: String,
252 pub color_identity: Vec<ColorSymbol>,
253 pub color_indicator: Option<Vec<ColorSymbol>>,
254 pub colors: Option<Vec<ColorSymbol>>,
255 pub content_warning: Option<bool>,
256 pub digital: bool,
257 pub edhrec_rank: Option<i64>,
258 pub finishes: Vec<CardFinish>,
259 pub flavor_name: Option<String>,
260 pub flavor_text: Option<String>,
261 pub foil: bool,
262 pub frame: String,
263 pub full_art: bool,
264 pub games: Vec<GameKind>,
265 pub hand_modifier: Option<String>,
266 pub highres_image: bool,
267 pub id: Uuid,
268 pub illustration_id: Option<Uuid>,
269 pub image_status: ImageStatus,
270 pub image_uris: Option<ImageUris>,
271 pub keywords: Vec<String>,
272 #[serde(rename = "object")]
273 pub kind: ResourceKind,
274 pub lang: String,
275 pub layout: Layout,
276 pub legalities: Legalities,
277 pub life_modifier: Option<String>,
278 pub loyalty: Option<String>,
279 pub mana_cost: Option<String>,
280 pub mtgo_id: Option<i32>,
281 pub mtgo_foil_id: Option<i32>,
282 pub multiverse_ids: Option<Vec<i32>>,
283 pub name: String,
284 pub nonfoil: bool,
285 pub oracle_id: Uuid,
286 pub oracle_text: Option<String>,
287 pub oversized: bool,
288 pub penny_rank: Option<i64>,
289 pub power: Option<String>,
290 pub prices: Prices,
291 pub printed_name: Option<String>,
292 pub printed_text: Option<String>,
293 pub printed_type_line: Option<String>,
294 pub prints_search_uri: Url,
295 pub produced_mana: Option<Vec<ColorSymbol>>,
296 pub promo: bool,
297 pub promo_types: Option<Vec<String>>,
298 pub purchase_uris: Option<PurchaseUris>,
299 pub rarity: Rarity,
300 pub related_uris: Option<RelatedUris>,
301 pub released_at: Date,
302 pub reprint: bool,
303 pub reserved: bool,
304 pub rulings_uri: Url,
305 pub scryfall_set_uri: Url,
306 pub scryfall_uri: Url,
307 pub security_stamp: Option<String>,
308 pub set: String,
309 pub set_id: String,
310 pub set_name: String,
311 pub set_search_uri: Url,
312 pub set_type: String,
313 pub set_uri: Url,
314 pub story_spotlight: bool,
315 pub tcgplayer_id: Option<i32>,
316 pub tcgplayer_etched_id: Option<i32>,
317 pub textless: bool,
318 pub toughness: Option<String>,
319 pub type_line: String,
320 pub uri: Url,
321 pub variation: bool,
322 pub variation_of: Option<Uuid>,
323}
324
325#[derive(Debug, Serialize, Deserialize, PartialEq)]
326pub struct RelatedCard {
327 pub component: String,
328 pub id: Uuid,
329 #[serde(rename = "object")]
330 pub kind: ResourceKind,
331 pub name: String,
332 pub type_line: String,
333 pub uri: Url,
334}
335
336#[derive(Debug, Serialize, Deserialize, PartialEq)]
337#[serde(rename_all = "lowercase")]
338pub enum CardFinish {
339 Etched,
340 Foil,
341 Glossy,
342 NonFoil,
343}
344
345#[derive(Debug, Serialize, Deserialize, PartialEq)]
346#[serde(rename_all = "lowercase")]
347pub enum GameKind {
348 Arena,
349 Mtgo,
350 Paper,
351}
352
353#[derive(Debug, Serialize, Deserialize, PartialEq)]
354#[serde(rename_all = "snake_case")]
355pub enum ImageStatus {
356 HighresScan,
357 Lowres,
358 Missing,
359 Placeholder,
360}
361
362#[derive(Debug, Serialize, Deserialize, PartialEq)]
363#[serde(rename_all = "snake_case")]
364pub enum Layout {
365 Adventure,
366 ArtSeries,
367 Augment,
368 Class,
369 DoubleFacedToken,
370 Emblem,
371 Flip,
372 Host,
373 Leveler,
374 Meld,
375 ModalDfc,
376 Normal,
377 Planar,
378 ReversibleCard,
379 Saga,
380 Scheme,
381 Split,
382 Token,
383 Transform,
384 Vanguard,
385}
386
387#[derive(Debug, Serialize, Deserialize, PartialEq)]
388#[serde(rename_all = "lowercase")]
389pub enum Rarity {
390 Bonus,
391 Common,
392 Mythic,
393 Rare,
394 Special,
395 Uncommon,
396}
397
398#[derive(Debug, Serialize, Deserialize, PartialEq)]
399pub struct CardPage {
400 pub data: Vec<Card>,
401 pub has_more: bool,
402 #[serde(rename = "object")]
403 pub kind: ResourceKind,
404 pub next_page: Option<Url>,
405 pub total_cards: i64,
406}
407
408#[derive(Debug, Serialize, Deserialize, PartialEq)]
410pub struct CardFace {
411 pub artist: Option<String>,
412 pub artist_id: Option<Uuid>,
413 pub cmc: Option<f64>,
414 pub color_indicator: Option<Vec<ColorSymbol>>,
415 pub colors: Option<Vec<ColorSymbol>>,
416 pub flavor_name: Option<String>,
417 pub flavor_text: Option<String>,
418 pub illustration_id: Option<Uuid>,
419 pub image_uris: Option<ImageUris>,
420 #[serde(rename = "object")]
421 pub kind: ResourceKind,
422 pub layout: Option<Layout>,
423 pub loyalty: Option<String>,
424 pub mana_cost: String,
425 pub name: String,
426 pub oracle_id: Option<Uuid>,
427 pub oracle_text: Option<String>,
428 pub power: Option<String>,
429 pub printed_name: Option<String>,
430 pub printed_text: Option<String>,
431 pub printed_type_line: Option<String>,
432 pub toughness: Option<String>,
433 pub type_line: Option<String>,
434 pub watermark: Option<String>,
435}
436
437#[derive(Debug, Serialize, Deserialize, PartialEq)]
439pub struct ImageUris {
440 pub art_crop: Url,
441 pub border_crop: Url,
442 pub large: Url,
443 pub normal: Url,
444 pub png: Url,
445 pub small: Url,
446}
447
448#[derive(Debug, Serialize, Deserialize, PartialEq)]
450pub struct Legalities {
451 pub alchemy: Legality,
452 pub brawl: Legality,
453 pub commander: Legality,
454 pub duel: Legality,
455 pub explorer: Legality,
456 pub future: Legality,
457 pub gladiator: Legality,
458 pub historic: Legality,
459 pub historicbrawl: Legality,
460 pub legacy: Legality,
461 pub modern: Legality,
462 pub oldschool: Legality,
463 pub pauper: Legality,
464 pub paupercommander: Legality,
465 pub penny: Legality,
466 pub pioneer: Legality,
467 pub premodern: Legality,
468 pub standard: Legality,
469 pub vintage: Legality,
470}
471
472#[derive(Debug, Serialize, Deserialize, PartialEq)]
474pub struct Prices {
475 pub eur: Option<String>,
476 pub eur_foil: Option<String>,
477 pub tix: Option<String>,
478 pub usd: Option<String>,
479 pub usd_etched: Option<String>,
480 pub usd_foil: Option<String>,
481}
482
483#[derive(Debug, Serialize, Deserialize, PartialEq)]
485pub struct PurchaseUris {
486 pub cardhoarder: Url,
487 pub cardmarket: Url,
488 pub tcgplayer: Url,
489}
490
491#[derive(Debug, Serialize, Deserialize, PartialEq)]
493pub struct RelatedUris {
494 pub edhrec: Option<Url>,
495 pub gatherer: Option<Url>,
496 pub tcgplayer_infinite_articles: Option<Url>,
497 pub tcgplayer_infinite_decks: Option<Url>,
498}
499
500#[derive(Serialize, Deserialize, Debug, PartialEq)]
502pub enum Legality {
503 #[serde(rename = "banned")]
504 Banned,
505
506 #[serde(rename = "legal")]
507 Legal,
508
509 #[serde(rename = "not_legal")]
510 NotLegal,
511
512 #[serde(rename = "restricted")]
513 Restricted,
514}
515
516pub struct SearchQueryParams {
517 pub dir: Option<OrderDirection>,
518 pub include_extras: Option<bool>,
519 pub include_multilingual: Option<bool>,
520 pub include_variations: Option<bool>,
521 pub order: Option<OrderField>,
522 pub page: Option<u32>,
523 pub q: String,
524 pub unique: Option<UniqueMode>,
525}
526
527impl SearchQueryParams {
528 pub fn as_query_str(&self) -> String {
529 let mut query = format!("?q={}", self.q);
530
531 query.push_str(self.unique.as_ref()
532 .map_or("".into(),
533 |mode| format!("&unique={}", mode)
534 ).as_str()
535 );
536
537 query.push_str(self.order.as_ref()
538 .map_or("".into(),
539 |field| format!("&order={}", field)
540 ).as_str()
541 );
542
543 query.push_str(self.dir.as_ref()
544 .map_or("".into(),
545 |dir| format!("&dir={}", dir)
546 ).as_str()
547 );
548
549 query.push_str(self.include_extras.as_ref()
550 .map_or("".into(),
551 |b| format!("&include_extras={}", b)
552 ).as_str()
553 );
554
555 query.push_str(self.include_multilingual.as_ref()
556 .map_or("".into(),
557 |b| format!("&include_multilingual={}", b),
558 ).as_str()
559 );
560
561 query.push_str(self.include_variations.as_ref()
562 .map_or("".into(),
563 |b| format!("&include_variations={}", b),
564 ).as_str()
565 );
566
567 query.push_str(self.page.as_ref()
568 .map_or("".into(),
569 |p| format!("&page={}", p),
570 ).as_str()
571 );
572
573 query
574 }
575
576 pub fn with_q(q: &str) -> Self {
577 SearchQueryParams {
578 q: q.into(),
579 unique: None,
580 order: None,
581 dir: None,
582 include_extras: None,
583 include_multilingual: None,
584 include_variations: None,
585 page: None,
586 }
587 }
588}
589
590#[derive(Default, Display)]
591#[strum(serialize_all = "snake_case")]
592pub enum UniqueMode {
593 Art,
594 #[default]
595 Cards,
596 Prints,
597}
598
599#[derive(Default, Display)]
600#[strum(serialize_all = "snake_case")]
601pub enum OrderField {
602 Artist,
603 Cmc,
604 Color,
605 Edhrec,
606 Eur,
607 #[default]
608 Name,
609 Penny,
610 Power,
611 Rarity,
612 Released,
613 Review,
614 Set,
615 Tix,
616 Toughness,
617 Usd,
618}
619
620#[derive(Default, Display)]
621#[strum(serialize_all = "snake_case")]
622pub enum OrderDirection {
623 #[default]
624 Auto,
625 Asc,
626 Desc,
627}
628
629#[derive(Debug, Serialize, Deserialize, PartialEq)]
630pub struct CardCollection {
631 #[serde(rename = "object")]
632 pub kind: ResourceKind,
633
634 pub not_found: Vec<CardIdentifier>,
635
636 #[serde(rename = "data")]
637 pub cards: Vec<Card>,
638}
639
640#[derive(Debug, Serialize, PartialEq)]
641pub struct CardIdentifiers {
642 pub identifiers: Vec<CardIdentifier>
643}
644
645#[derive(Debug, Display, Serialize, Deserialize, PartialEq)]
646#[serde(untagged)]
647pub enum CardIdentifier {
648 IllustrationId {
649 #[serde(rename="illustration_id")]
650 val: String,
651 },
652 MtgoId {
653 #[serde(rename="mtgo_id")]
654 val: String,
655 },
656 MutliverseId {
657 #[serde(rename="multiverse_id")]
658 val: u32,
659 },
660 Name {
661 #[serde(rename="name")]
662 val: String,
663 },
664 OracleId {
665 #[serde(rename="oracle_id")]
666 val: String,
667 },
668 ScryfallId {
669 #[serde(rename="id")]
670 val: String,
671 },
672 SetAndName {
673 set: String,
674 name: String,
675 },
676 SetAndNumber {
677 set: String,
678
679 #[serde(rename="collector_number")]
680 number: String,
681 },
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use rstest::rstest;
688
689 #[rstest]
690 #[case::by_id(CardResource::ById("123"), "cards/123")]
691 #[case::by_arena_id(CardResource::ByArenaId("123"), "cards/arena/123")]
692 #[case::by_cardmarket_id(CardResource::ByCardmarketId("123"), "cards/cardmarket/123")]
693 #[case::by_code(CardResource::ByCode("123", "456"), "cards/123/456")]
694 #[case::by_mtgo_id(CardResource::ByMtgoId("123"), "cards/mtgo/123")]
695 #[case::by_multiverse_id(CardResource::ByMultiverseId("123"), "cards/multiverse/123")]
696 #[case::by_tcgplayer_id(CardResource::ByTcgplayerId("123"), "cards/tcgplayer/123")]
697 #[case::named_exact(CardResource::NamedExact("name"), "cards/named?exact=name")]
698 #[case::named_fuzzy(CardResource::NamedFuzzy("name"), "cards/named?fuzzy=name")]
699 #[case::random(CardResource::Random(None), "cards/random")]
700 #[case::random(CardResource::Random(Some("name")), "cards/random?q=name")]
701 fn card_resource_should_return_path_and_method(
702 #[case] resource: CardResource,
703 #[case] expected: &str
704 ) {
705 assert_eq!(expected, resource.path());
706 assert_eq!(Method::GET, resource.method());
707 }
708
709 #[rstest]
710 #[case::search(CardPageResource::Search(SearchQueryParams::with_q("test")), "cards/search?q=test")]
711 fn card_page_resource_should_return_path_and_method(
712 #[case] resource: CardPageResource,
713 #[case] expected: &str
714 ) {
715 assert_eq!(expected, resource.path());
716 assert_eq!(Method::GET, resource.method());
717 }
718
719 #[rstest]
720 #[case::autocomplete(CardCatalogResource::Autocomplete("test"), "cards/autocomplete?q=test")]
721 fn card_catalog_resource_should_return_path_and_method(
722 #[case] resource: CardCatalogResource,
723 #[case] expected: &str
724 ) {
725 assert_eq!(expected, resource.path());
726 assert_eq!(Method::GET, resource.method());
727 }
728
729 #[rstest]
730 fn card_collection_resource_should_return_path_method_and_json_body() {
731 let resource = CardCollectionResource::WithIdentifiers(
732 CardIdentifiers {
733 identifiers: vec![
734 CardIdentifier::ScryfallId { val: "123".into() }
735 ]
736 }
737 );
738
739 assert_eq!("cards/collection", resource.path());
740 assert_eq!(Method::POST, resource.method());
741 assert_eq!(String::from("{\"identifiers\":[{\"id\":\"123\"}]}"), resource.json().unwrap());
742 }
743
744 #[rstest]
745 #[case::all_off("q", None, None, None, None, None, None, None, "?q=q")]
746 #[case::all_on("q",
747 Some(UniqueMode::default()),
748 Some(OrderField::default()),
749 Some(OrderDirection::default()),
750 Some(true), Some(true), Some(true), Some(1),
751 "?q=q&unique=cards&order=name&dir=auto&include_extras=true&include_multilingual=true&include_variations=true&page=1"
752 )]
753 fn search_query_params_should_parse_as_query_string(
754 #[case] q: String,
755 #[case] unique: Option<UniqueMode>,
756 #[case] order: Option<OrderField>,
757 #[case] dir: Option<OrderDirection>,
758 #[case] include_extras: Option<bool>,
759 #[case] include_multilingual: Option<bool>,
760 #[case] include_variations: Option<bool>,
761 #[case] page: Option<u32>,
762 #[case] expected: String
763 ) {
764 let params = SearchQueryParams {
765 q,
766 unique,
767 order,
768 dir,
769 include_extras,
770 include_multilingual,
771 include_variations,
772 page,
773 };
774
775 assert_eq!(expected, params.as_query_str())
776 }
777}