1use serde::{Deserialize, Serialize};
46use std::collections::BTreeMap;
47
48use crate::{Client, Fetched, NetError, Source, Transport};
49
50pub const BASE_URL: &str = "https://motor.fusionspace.co/api/v1";
52
53pub const SCHEMA_VERSION: u32 = 1;
56
57pub const TTL_S: u64 = 3_600;
59
60pub const ATTRIBUTION: &str = "Motor stock data from motor.fusionspace.co, licensed CC BY 4.0 \
64 (https://creativecommons.org/licenses/by/4.0/); aggregated from \
65 public vendor listings and ThrustCurve.org; provided as is, with \
66 no warranty: check stock and price on the vendor's own page \
67 before relying on them";
68
69pub const MANUFACTURERS: &[(&str, &str)] = &[
72 ("AeroTech", "aerotech"),
73 ("Cesaroni Technology", "cesaroni"),
74 ("Loki Research", "loki"),
75];
76
77#[non_exhaustive]
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Endpoint {
81 Meta,
83 Motors,
85 InStock,
87 Vendors,
89 Motor(MotorName),
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct MotorName {
97 manufacturer_slug: &'static str,
98 designation: String,
99}
100
101impl MotorName {
102 #[must_use]
104 pub fn manufacturer_slug(&self) -> &'static str {
105 self.manufacturer_slug
106 }
107
108 #[must_use]
110 pub fn designation(&self) -> &str {
111 &self.designation
112 }
113}
114
115impl Endpoint {
116 pub fn motor(manufacturer: &str, designation: &str) -> Result<Self, MotorFinderError> {
123 let manufacturer_slug = manufacturer_slug(manufacturer)?;
124 let allowed = |c: char| c.is_ascii_alphanumeric() || "-_./".contains(c);
125 if designation.is_empty()
126 || designation.chars().all(|c| c == '.')
127 || !designation.chars().all(allowed)
128 {
129 return Err(MotorFinderError::Request {
130 what: "designation",
131 value: designation.to_owned(),
132 });
133 }
134 Ok(Self::Motor(MotorName {
135 manufacturer_slug,
136 designation: designation.to_owned(),
137 }))
138 }
139
140 #[must_use]
143 pub fn url(&self) -> String {
144 match self {
145 Self::Meta => format!("{BASE_URL}/meta.json"),
146 Self::Motors => format!("{BASE_URL}/motors.json"),
147 Self::InStock => format!("{BASE_URL}/in-stock.json"),
148 Self::Vendors => format!("{BASE_URL}/vendors.json"),
149 Self::Motor(name) => format!(
150 "{BASE_URL}/motors/{}/{}.json",
151 name.manufacturer_slug,
152 name.designation.replace('/', "~")
153 ),
154 }
155 }
156
157 #[must_use]
160 pub fn source() -> Source {
161 Source {
162 name: "motor.fusionspace.co".to_owned(),
163 attribution: ATTRIBUTION.to_owned(),
164 ttl_s: TTL_S,
165 }
166 }
167}
168
169#[non_exhaustive]
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct Meta {
173 pub schema_version: u32,
175 pub generated_at: String,
177 pub counts: Counts,
179 pub manufacturers: Vec<String>,
181 pub endpoints: BTreeMap<String, String>,
183 pub docs: Option<String>,
185 pub license: Option<String>,
187 pub notes: Option<String>,
189}
190
191#[non_exhaustive]
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194pub struct Counts {
195 pub motors: u32,
197 pub in_stock: u32,
199 pub vendors: u32,
201}
202
203#[non_exhaustive]
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct MotorList {
207 pub schema_version: u32,
209 pub generated_at: String,
211 pub count: u32,
213 pub motors: Vec<Motor>,
215}
216
217#[non_exhaustive]
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct MotorPage {
221 pub schema_version: u32,
223 pub generated_at: String,
225 pub motor: Motor,
227}
228
229#[non_exhaustive]
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct Motor {
233 pub id: u64,
235 pub path: String,
237 pub manufacturer: String,
239 pub designation: String,
241 pub common_name: Option<String>,
243 pub impulse_class: String,
245 pub diameter_mm: f64,
247 pub total_impulse_ns: Option<f64>,
249 pub avg_thrust_n: Option<f64>,
251 pub burn_time_s: Option<f64>,
253 pub propellant: Option<String>,
255 pub sparky: bool,
257 pub motor_type: Option<MotorType>,
259 pub case_info: Option<String>,
261 pub hazmat: Hazmat,
263 pub delays: Option<String>,
265 pub delay_adjustable: bool,
267 pub discontinued: bool,
269 pub in_stock: bool,
271 pub vendor_count: u32,
273 pub in_stock_vendor_count: u32,
275 pub listing_count: u32,
277 pub cheapest_in_stock: Option<Offer>,
279 pub listings: Vec<Listing>,
281}
282
283#[non_exhaustive]
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286pub enum MotorType {
287 #[serde(rename = "reload")]
289 Reload,
290 #[serde(rename = "SU")]
292 SingleUse,
293 #[serde(rename = "hybrid")]
295 Hybrid,
296}
297
298#[non_exhaustive]
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305pub enum Hazmat {
306 #[serde(rename = "required")]
308 Required,
309 #[serde(rename = "varies")]
311 Varies,
312 #[serde(rename = "none")]
314 NotRequired,
315}
316
317#[non_exhaustive]
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Offer {
322 pub price_cents: Option<u64>,
324 pub unit_price_cents: Option<u64>,
326 pub currency: String,
328 pub vendor: String,
330 pub vendor_slug: String,
332 pub url: String,
334 pub pack_size: u32,
336}
337
338#[non_exhaustive]
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct Listing {
342 pub vendor: String,
344 pub vendor_slug: String,
346 pub url: String,
348 pub status: ListingStatus,
350 pub price_cents: Option<u64>,
352 pub unit_price_cents: Option<u64>,
354 pub currency: String,
356 pub pack_size: u32,
358 pub stock_count: Option<i64>,
360 pub lead_time: Option<String>,
362 pub last_seen: String,
364}
365
366#[non_exhaustive]
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum ListingStatus {
372 InStock,
374 OutOfStock,
376 SpecialOrder,
378 #[serde(other)]
380 Unknown,
381}
382
383#[non_exhaustive]
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct VendorList {
387 pub schema_version: u32,
389 pub generated_at: String,
391 pub count: u32,
393 pub vendors: Vec<Vendor>,
395}
396
397#[non_exhaustive]
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Vendor {
401 pub slug: String,
403 pub name: String,
405 pub motor_count: u32,
407 pub in_stock_count: u32,
409}
410
411pub fn parse_meta(body: &[u8]) -> Result<Meta, MotorFinderError> {
418 let meta: Meta = json(body)?;
419 header(meta.schema_version, &meta.generated_at)?;
420 Ok(meta)
421}
422
423pub fn parse_motors(body: &[u8]) -> Result<MotorList, MotorFinderError> {
432 let list: MotorList = json(body)?;
433 header(list.schema_version, &list.generated_at)?;
434 let found = list.motors.len();
435 if usize::try_from(list.count).ok() != Some(found) {
436 return Err(MotorFinderError::Count {
437 expected: list.count,
438 found,
439 });
440 }
441 for (i, motor) in list.motors.iter().enumerate() {
442 check_motor(motor, &format!("motors[{i}]"))?;
443 }
444 Ok(list)
445}
446
447pub fn parse_in_stock(body: &[u8]) -> Result<MotorList, MotorFinderError> {
452 let list = parse_motors(body)?;
453 if let Some(i) = list.motors.iter().position(|m| !m.in_stock) {
454 return Err(field(format!("motors[{i}].in_stock"), "false"));
455 }
456 Ok(list)
457}
458
459pub fn parse_vendors(body: &[u8]) -> Result<VendorList, MotorFinderError> {
464 let list: VendorList = json(body)?;
465 header(list.schema_version, &list.generated_at)?;
466 let found = list.vendors.len();
467 if usize::try_from(list.count).ok() != Some(found) {
468 return Err(MotorFinderError::Count {
469 expected: list.count,
470 found,
471 });
472 }
473 Ok(list)
474}
475
476pub fn parse_motor(body: &[u8]) -> Result<MotorPage, MotorFinderError> {
482 let page: MotorPage = json(body)?;
483 header(page.schema_version, &page.generated_at)?;
484 check_motor(&page.motor, "motor")?;
485 Ok(page)
486}
487
488pub fn fetch_meta<T: Transport>(
500 client: &Client<T>,
501 now_s: u64,
502) -> Result<(Meta, Fetched), MotorFinderError> {
503 fetch(client, &Endpoint::Meta, now_s, parse_meta)
504}
505
506pub fn fetch_motors<T: Transport>(
511 client: &Client<T>,
512 now_s: u64,
513) -> Result<(MotorList, Fetched), MotorFinderError> {
514 fetch(client, &Endpoint::Motors, now_s, parse_motors)
515}
516
517pub fn fetch_in_stock<T: Transport>(
522 client: &Client<T>,
523 now_s: u64,
524) -> Result<(MotorList, Fetched), MotorFinderError> {
525 fetch(client, &Endpoint::InStock, now_s, parse_in_stock)
526}
527
528pub fn fetch_vendors<T: Transport>(
533 client: &Client<T>,
534 now_s: u64,
535) -> Result<(VendorList, Fetched), MotorFinderError> {
536 fetch(client, &Endpoint::Vendors, now_s, parse_vendors)
537}
538
539pub fn fetch_motor<T: Transport>(
547 client: &Client<T>,
548 manufacturer: &str,
549 designation: &str,
550 now_s: u64,
551) -> Result<(MotorPage, Fetched), MotorFinderError> {
552 let endpoint = Endpoint::motor(manufacturer, designation)?;
553 let asked_slug = manufacturer_slug(manufacturer)?;
554 let read = |body: &[u8]| {
555 let page = parse_motor(body)?;
556 let slug = MANUFACTURERS
557 .iter()
558 .find(|(name, _)| *name == page.motor.manufacturer)
559 .map(|&(_, slug)| slug);
560 if slug != Some(asked_slug) || page.motor.designation != designation {
561 return Err(MotorFinderError::OtherMotor {
562 asked: format!("{asked_slug}/{designation}"),
563 found: format!("{}/{}", page.motor.manufacturer, page.motor.designation),
564 });
565 }
566 Ok(page)
567 };
568 fetch(client, &endpoint, now_s, read)
569}
570
571fn manufacturer_slug(manufacturer: &str) -> Result<&'static str, MotorFinderError> {
573 MANUFACTURERS
574 .iter()
575 .find(|(name, slug)| {
576 manufacturer.eq_ignore_ascii_case(name) || manufacturer.eq_ignore_ascii_case(slug)
577 })
578 .map(|&(_, slug)| slug)
579 .ok_or_else(|| MotorFinderError::Request {
580 what: "manufacturer",
581 value: manufacturer.to_owned(),
582 })
583}
584
585fn fetch<T: Transport, R>(
587 client: &Client<T>,
588 endpoint: &Endpoint,
589 now_s: u64,
590 read: impl Fn(&[u8]) -> Result<R, MotorFinderError>,
591) -> Result<(R, Fetched), MotorFinderError> {
592 let check = |body: &[u8]| read(body).map(drop).map_err(|e| e.to_string());
593 let fetched = client.fetch_checked(&Endpoint::source(), &endpoint.url(), now_s, check)?;
594 let value = read(&fetched.body)?;
595 Ok((value, fetched))
596}
597
598#[must_use]
602pub fn unix_s(timestamp: &str) -> Option<i64> {
603 let rest = timestamp
604 .strip_suffix('Z')
605 .or_else(|| timestamp.strip_suffix("+00:00"))?;
606 let (date, time) = rest.split_once('T')?;
607 let time = time.split_once('.').map_or(time, |(whole, fraction)| {
608 if !fraction.is_empty() && fraction.bytes().all(|b| b.is_ascii_digit()) {
609 whole
610 } else {
611 ""
612 }
613 });
614 let number = |text: &str, digits: usize| -> Option<i64> {
615 (text.len() == digits && text.bytes().all(|b| b.is_ascii_digit()))
616 .then(|| text.parse().ok())
617 .flatten()
618 };
619 let mut parts = date.split('-');
620 let (year, month, day) = (
621 number(parts.next()?, 4)?,
622 number(parts.next()?, 2)?,
623 number(parts.next()?, 2)?,
624 );
625 let mut parts = time.split(':');
626 let (hour, minute, second) = (
627 number(parts.next()?, 2)?,
628 number(parts.next()?, 2)?,
629 number(parts.next()?, 2)?,
630 );
631 if parts.next().is_some() || date.split('-').count() != 3 {
632 return None;
633 }
634 if hour > 23 || minute > 59 || second > 59 {
635 return None;
636 }
637 let start = crate::civil::unix_day_start(year, month, day)?;
638 Some(start + hour * 3_600 + minute * 60 + second)
639}
640
641fn json<'a, D: Deserialize<'a>>(body: &'a [u8]) -> Result<D, MotorFinderError> {
643 serde_json::from_slice(body).map_err(|e| MotorFinderError::Json(e.to_string()))
644}
645
646fn header(schema_version: u32, generated_at: &str) -> Result<(), MotorFinderError> {
648 if schema_version != SCHEMA_VERSION {
649 return Err(MotorFinderError::Schema {
650 found: schema_version,
651 });
652 }
653 if unix_s(generated_at).is_none() {
654 return Err(field("generated_at".to_owned(), generated_at));
655 }
656 Ok(())
657}
658
659fn field(field: String, value: impl ToString) -> MotorFinderError {
661 MotorFinderError::Field {
662 field,
663 value: value.to_string(),
664 }
665}
666
667fn check_motor(motor: &Motor, at: &str) -> Result<(), MotorFinderError> {
669 let class = motor.impulse_class.as_bytes();
670 if !(class.len() == 1 && class[0].is_ascii_uppercase()) {
671 return Err(field(format!("{at}.impulse_class"), &motor.impulse_class));
672 }
673 if motor.diameter_mm <= 0.0 {
674 return Err(field(format!("{at}.diameter_mm"), motor.diameter_mm));
675 }
676 let figures = [
677 ("total_impulse_ns", motor.total_impulse_ns),
678 ("avg_thrust_n", motor.avg_thrust_n),
679 ("burn_time_s", motor.burn_time_s),
680 ];
681 for (name, value) in figures {
682 if let Some(value) = value.filter(|v| *v < 0.0) {
683 return Err(field(format!("{at}.{name}"), value));
684 }
685 }
686 if usize::try_from(motor.listing_count).ok() != Some(motor.listings.len()) {
687 let value = format!(
688 "{} for {} listings",
689 motor.listing_count,
690 motor.listings.len()
691 );
692 return Err(field(format!("{at}.listing_count"), value));
693 }
694 if motor.cheapest_in_stock.is_some() != motor.in_stock {
695 let value = format!(
696 "in_stock {} beside {:?}",
697 motor.in_stock, motor.cheapest_in_stock
698 );
699 return Err(field(format!("{at}.cheapest_in_stock"), value));
700 }
701 if let Some(offer) = &motor.cheapest_in_stock {
702 let at = format!("{at}.cheapest_in_stock");
703 check_price(
704 &at,
705 offer.price_cents,
706 offer.unit_price_cents,
707 offer.pack_size,
708 )?;
709 }
710 for (j, listing) in motor.listings.iter().enumerate() {
711 let at = format!("{at}.listings[{j}]");
712 check_price(
713 &at,
714 listing.price_cents,
715 listing.unit_price_cents,
716 listing.pack_size,
717 )?;
718 }
719 Ok(())
720}
721
722fn check_price(
724 at: &str,
725 price_cents: Option<u64>,
726 unit_price_cents: Option<u64>,
727 pack_size: u32,
728) -> Result<(), MotorFinderError> {
729 if pack_size == 0 {
730 return Err(field(format!("{at}.pack_size"), pack_size));
731 }
732 if let (Some(price), Some(unit)) = (price_cents, unit_price_cents)
733 && unit > price
734 {
735 let value = format!("{unit} over a price of {price}");
736 return Err(field(format!("{at}.unit_price_cents"), value));
737 }
738 Ok(())
739}
740
741#[non_exhaustive]
743#[derive(Debug, thiserror::Error)]
744pub enum MotorFinderError {
745 #[error("the motor finder request's {what} is not one it serves: {value:?}")]
747 Request {
748 what: &'static str,
750 value: String,
752 },
753 #[error(transparent)]
755 Net(#[from] NetError),
756 #[error("the motor finder answer is not of the expected shape: {0}")]
758 Json(String),
759 #[error("the motor finder answer is schema version {found}, not {SCHEMA_VERSION}")]
761 Schema {
762 found: u32,
764 },
765 #[error("the motor finder answer counts {expected} but lists {found}")]
767 Count {
768 expected: u32,
770 found: usize,
772 },
773 #[error("the motor finder answer's {field} is not usable: {value}")]
775 Field {
776 field: String,
778 value: String,
780 },
781 #[error("asked the motor finder for {asked}, but its page is {found}'s")]
783 OtherMotor {
784 asked: String,
786 found: String,
788 },
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[test]
796 fn urls_follow_the_apis_names() {
797 assert_eq!(
798 Endpoint::InStock.url(),
799 "https://motor.fusionspace.co/api/v1/in-stock.json"
800 );
801 let f27 = Endpoint::motor("AeroTech", "F27R/L").unwrap();
802 assert_eq!(
803 f27.url(),
804 "https://motor.fusionspace.co/api/v1/motors/aerotech/F27R~L.json"
805 );
806 let cti = Endpoint::motor("CESARONI", "3683L851-P").unwrap();
807 assert!(cti.url().ends_with("/motors/cesaroni/3683L851-P.json"));
808 let loki = Endpoint::motor("Loki Research", "D2.3T").unwrap();
809 assert!(loki.url().ends_with("/motors/loki/D2.3T.json"));
810 }
811
812 #[test]
813 fn requests_outside_the_apis_files_are_refused() {
814 let refusal = |manufacturer: &str, designation: &str| match Endpoint::motor(
815 manufacturer,
816 designation,
817 ) {
818 Err(MotorFinderError::Request { what, value }) => (what, value),
819 other => panic!("{manufacturer}/{designation}: {other:?}"),
820 };
821 assert_eq!(refusal("Estes", "C6"), ("manufacturer", "Estes".to_owned()));
822 for designation in [
823 "", ".", "..", "H128W?x", "H128W#x", "a b", "H128W%2F", "~", "é",
824 ] {
825 let expected = ("designation", designation.to_owned());
826 assert_eq!(refusal("aerotech", designation), expected);
827 }
828 }
829
830 #[test]
831 fn times_read_as_the_api_writes_them() {
832 assert_eq!(unix_s("2026-10-01T07:07:29+00:00"), Some(1_790_838_449));
833 assert_eq!(unix_s("2026-10-01T07:07:29Z"), Some(1_790_838_449));
834 assert_eq!(unix_s("2026-10-01T07:07:29.123Z"), Some(1_790_838_449));
835 assert_eq!(unix_s("1970-01-01T00:00:00Z"), Some(0));
836 for bad in [
837 "2026-10-01T07:07:29",
838 "2026-10-01T07:07:29+01:00",
839 "2026-10-01 07:07:29Z",
840 "2026-13-01T07:07:29Z",
841 "2026-02-30T07:07:29Z",
842 "2026-10-01T24:00:00Z",
843 "2026-10-01T07:60:00Z",
844 "2026-10-01T07:07:60Z",
845 "2026-10-01T07:07:29.Z",
846 "2026-10-01T07:07:29.1aZ",
847 "2026-10-01T07:07Z",
848 "2026-10-01T07:07:29:00Z",
849 "26-10-01T07:07:29Z",
850 "2026-10-1T07:07:29Z",
851 "+026-10-01T07:07:29Z",
852 "",
853 ] {
854 assert_eq!(unix_s(bad), None, "{bad}");
855 }
856 }
857}