Skip to main content

flyr/
query.rs

1use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
2use base64::Engine;
3
4use crate::error::FlightError;
5use crate::proto;
6
7#[derive(Debug, Clone)]
8pub struct FlightLeg {
9    pub date: String,
10    pub from_airport: String,
11    pub to_airport: String,
12    pub max_stops: Option<u32>,
13    pub airlines: Option<Vec<String>>,
14}
15
16#[derive(Debug, Clone)]
17pub struct Passengers {
18    pub adults: u32,
19    pub children: u32,
20    pub infants_in_seat: u32,
21    pub infants_on_lap: u32,
22}
23
24impl Default for Passengers {
25    fn default() -> Self {
26        Self {
27            adults: 1,
28            children: 0,
29            infants_in_seat: 0,
30            infants_on_lap: 0,
31        }
32    }
33}
34
35#[derive(Debug, Clone)]
36pub enum Seat {
37    Economy,
38    PremiumEconomy,
39    Business,
40    First,
41}
42
43impl Seat {
44    pub fn from_str_loose(s: &str) -> Result<Self, FlightError> {
45        match s {
46            "economy" => Ok(Self::Economy),
47            "premium-economy" => Ok(Self::PremiumEconomy),
48            "business" => Ok(Self::Business),
49            "first" => Ok(Self::First),
50            _ => Err(FlightError::Validation(format!("invalid seat class: {s}"))),
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56pub enum TripType {
57    RoundTrip,
58    OneWay,
59    MultiCity,
60}
61
62impl TripType {
63    pub fn from_str_loose(s: &str) -> Result<Self, FlightError> {
64        match s {
65            "round-trip" => Ok(Self::RoundTrip),
66            "one-way" => Ok(Self::OneWay),
67            "multi-city" => Ok(Self::MultiCity),
68            _ => Err(FlightError::Validation(format!("invalid trip type: {s}"))),
69        }
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct QueryParams {
75    pub legs: Vec<FlightLeg>,
76    pub passengers: Passengers,
77    pub seat: Seat,
78    pub trip: TripType,
79    pub language: String,
80    pub currency: String,
81}
82
83fn validate_airport(code: &str) -> Result<(), FlightError> {
84    if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
85        return Err(FlightError::InvalidAirport(code.to_string()));
86    }
87    Ok(())
88}
89
90fn days_in_month(year: u32, month: u32) -> u32 {
91    match month {
92        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
93        4 | 6 | 9 | 11 => 30,
94        2 => {
95            if (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) {
96                29
97            } else {
98                28
99            }
100        }
101        _ => 0,
102    }
103}
104
105fn validate_date(date: &str) -> Result<(), FlightError> {
106    let parts: Vec<&str> = date.split('-').collect();
107    if parts.len() != 3 {
108        return Err(FlightError::InvalidDate(date.to_string()));
109    }
110    let year: u32 = parts[0]
111        .parse()
112        .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
113    let month: u32 = parts[1]
114        .parse()
115        .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
116    let day: u32 = parts[2]
117        .parse()
118        .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
119
120    if year < 2000 || !(1..=12).contains(&month) {
121        return Err(FlightError::InvalidDate(date.to_string()));
122    }
123
124    if day < 1 || day > days_in_month(year, month) {
125        return Err(FlightError::InvalidDate(date.to_string()));
126    }
127
128    Ok(())
129}
130
131impl QueryParams {
132    pub fn validate(&self) -> Result<(), FlightError> {
133        if self.legs.is_empty() {
134            return Err(FlightError::Validation(
135                "at least one flight leg required".into(),
136            ));
137        }
138
139        for leg in &self.legs {
140            validate_airport(&leg.from_airport)?;
141            validate_airport(&leg.to_airport)?;
142            validate_date(&leg.date)?;
143        }
144
145        let total = self.passengers.adults
146            + self.passengers.children
147            + self.passengers.infants_in_seat
148            + self.passengers.infants_on_lap;
149
150        if total > 9 {
151            return Err(FlightError::Validation(format!(
152                "total passengers ({total}) exceeds maximum of 9"
153            )));
154        }
155
156        if total == 0 {
157            return Err(FlightError::Validation(
158                "at least one passenger required".into(),
159            ));
160        }
161
162        if self.passengers.infants_on_lap > self.passengers.adults {
163            return Err(FlightError::Validation(
164                "infants on lap cannot exceed number of adults".into(),
165            ));
166        }
167
168        Ok(())
169    }
170
171    pub fn to_url_params(&self) -> Vec<(String, String)> {
172        let encoded = proto::encode(&self.legs, &self.passengers, &self.seat, &self.trip);
173        let b64 = STANDARD.encode(&encoded);
174
175        let mut params = vec![("tfs".to_string(), b64)];
176
177        if !self.language.is_empty() {
178            params.push(("hl".to_string(), self.language.clone()));
179        }
180        if !self.currency.is_empty() {
181            params.push(("curr".to_string(), self.currency.clone()));
182        }
183
184        params
185    }
186}
187
188pub enum SearchQuery {
189    Structured(QueryParams),
190    NaturalLanguage(String),
191}
192
193impl SearchQuery {
194    pub fn to_url_params(&self) -> Vec<(String, String)> {
195        match self {
196            Self::Structured(q) => q.to_url_params(),
197            Self::NaturalLanguage(text) => vec![("q".to_string(), text.clone())],
198        }
199    }
200}
201
202pub fn to_google_flights_url(params: &QueryParams) -> String {
203    let encoded = proto::encode(&params.legs, &params.passengers, &params.seat, &params.trip);
204    let tfs = URL_SAFE_NO_PAD.encode(&encoded);
205
206    let mut url = format!(
207        "https://www.google.com/travel/flights/search?tfs={tfs}&tfu=EgYIABAAGAA"
208    );
209
210    if !params.currency.is_empty() {
211        url.push_str(&format!("&curr={}", params.currency));
212    }
213    if !params.language.is_empty() {
214        url.push_str(&format!("&hl={}", params.language));
215    }
216
217    url
218}