tmdb_sans_io/
themoviedb.rs

1use crate::model::{ConfigDetails, FindResult, Movie, SearchMovie, SearchResult, Tv, TvSeason};
2
3pub trait Executable<T>
4where
5    T: serde::de::DeserializeOwned,
6{
7    fn finish(&self) -> HttpGet<T>;
8}
9
10pub trait Search<'a> {
11    fn title(&mut self, title: &'a str) -> &mut SearchData<'a>;
12    fn year(&mut self, year: u64) -> &mut SearchData<'a>;
13}
14
15#[derive(Clone, Debug)]
16pub struct SearchData<'a> {
17    tmdb: TMDb,
18    title: &'a str,
19    year: Option<u64>,
20}
21
22impl<'a> Search<'a> for SearchData<'a> {
23    fn title(&mut self, title: &'a str) -> &mut SearchData<'a> {
24        self.title = title;
25        self
26    }
27
28    fn year(&mut self, year: u64) -> &mut SearchData<'a> {
29        self.year = Some(year);
30        self
31    }
32}
33
34#[derive(Clone, Debug)]
35pub struct HttpGet<T>
36where
37    T: serde::de::DeserializeOwned,
38{
39    request_url: String,
40    response_ty: std::marker::PhantomData<T>,
41}
42impl<T> HttpGet<T>
43where
44    T: serde::de::DeserializeOwned,
45{
46    fn new(request_url: String) -> Self {
47        Self {
48            request_url,
49            response_ty: std::marker::PhantomData,
50        }
51    }
52
53    /// Returns the URL needed to fulfill the request
54    #[must_use]
55    pub fn request_url(&self) -> &str {
56        &self.request_url
57    }
58    /// Parses the response string into the desired result
59    ///
60    /// Convenience function for [`Self::receive_response`]
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the string is not valid JSON for the expected data model type
65    pub fn receive_response_str(self, response: &str) -> Result<T, Error> {
66        self.receive_response(response.as_bytes())
67    }
68    /// Parses the response into the desired result
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the string is not valid JSON for the expected data model type
73    pub fn receive_response(self, response: impl std::io::Read) -> Result<T, Error> {
74        serde_json::from_reader(response)
75            .map_err(ErrorKind::SerdeDecode)
76            .map_err(|kind| Error { kind })
77    }
78}
79
80#[derive(Debug)]
81pub struct Error {
82    kind: ErrorKind,
83}
84#[derive(Debug)]
85enum ErrorKind {
86    SerdeDecode(serde_json::Error),
87}
88impl std::error::Error for Error {
89    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
90        match &self.kind {
91            ErrorKind::SerdeDecode(e) => Some(e),
92        }
93    }
94}
95impl std::fmt::Display for Error {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        let Self { kind } = self;
98        match kind {
99            ErrorKind::SerdeDecode(_) => write!(f, "failed to decode JSON response"),
100        }
101    }
102}
103
104impl Executable<SearchResult> for SearchData<'_> {
105    fn finish(&self) -> HttpGet<SearchResult> {
106        let relative_url: String = match self.year {
107            None => format!(
108                "/search/movie?api_key={}&language={}&query={}&append_to_response=images",
109                self.tmdb.api_key, // rustfmt hint
110                self.tmdb.language,
111                self.title
112            ),
113            Some(year) => format!(
114                "/search/movie?api_key={}&language={}&query={}&year={}&append_to_response=images",
115                self.tmdb.api_key, // rustfmt hint
116                self.tmdb.language,
117                self.title,
118                year
119            ),
120        };
121        let url = build_url(&relative_url);
122        HttpGet::new(url)
123    }
124}
125
126#[derive(Clone, Copy, Debug)]
127pub enum Appendable {
128    Videos,
129    Credits,
130}
131
132pub trait Fetch {
133    fn id(&mut self, id: u64) -> &mut FetchData;
134    fn append_videos(&mut self) -> &mut FetchData;
135    fn append_credits(&mut self) -> &mut FetchData;
136}
137
138#[derive(Clone, Debug)]
139pub struct FetchData {
140    tmdb: TMDb,
141    id: u64,
142    append_to_response: Vec<Appendable>,
143}
144
145impl FetchData {
146    pub fn tv_season(self, season_number: u32) -> FetchDataTvSeason {
147        FetchDataTvSeason {
148            fetch_data: self,
149            season_number,
150        }
151    }
152}
153impl Fetch for FetchData {
154    fn id(&mut self, id: u64) -> &mut FetchData {
155        self.id = id;
156        self
157    }
158
159    fn append_videos(&mut self) -> &mut FetchData {
160        self.append_to_response.push(Appendable::Videos);
161        self
162    }
163
164    fn append_credits(&mut self) -> &mut FetchData {
165        self.append_to_response.push(Appendable::Credits);
166        self
167    }
168}
169
170impl Executable<Movie> for FetchData {
171    fn finish(&self) -> HttpGet<Movie> {
172        let mut relative_url: String = format!(
173            "/movie/{}?api_key={}&language={}",
174            self.id, // rustfmt hint
175            self.tmdb.api_key,
176            self.tmdb.language
177        );
178
179        if !self.append_to_response.is_empty() {
180            relative_url.push_str("&append_to_response=");
181            for appendable in &self.append_to_response {
182                match appendable {
183                    Appendable::Videos => relative_url.push_str("videos,"),
184                    Appendable::Credits => relative_url.push_str("credits,"),
185                }
186            }
187        }
188
189        let url = build_url(&relative_url);
190
191        HttpGet::new(url)
192    }
193}
194
195impl Executable<Tv> for FetchData {
196    fn finish(&self) -> HttpGet<Tv> {
197        let mut relative_url: String = format!(
198            "/tv/{}?api_key={}&language={}",
199            self.id, // rustfmt hint
200            self.tmdb.api_key,
201            self.tmdb.language
202        );
203
204        if !self.append_to_response.is_empty() {
205            relative_url.push_str("&append_to_response=");
206            for appendable in &self.append_to_response {
207                match appendable {
208                    Appendable::Videos => relative_url.push_str("videos,"),
209                    Appendable::Credits => relative_url.push_str("credits,"),
210                }
211            }
212        }
213
214        let url = build_url(&relative_url);
215
216        HttpGet::new(url)
217    }
218}
219
220#[derive(Clone, Debug)]
221#[must_use]
222pub struct FetchDataTvSeason {
223    fetch_data: FetchData,
224    season_number: u32,
225}
226impl Fetch for FetchDataTvSeason {
227    fn id(&mut self, id: u64) -> &mut FetchData {
228        self.fetch_data.id(id)
229    }
230    fn append_videos(&mut self) -> &mut FetchData {
231        self.fetch_data.append_videos()
232    }
233    fn append_credits(&mut self) -> &mut FetchData {
234        self.fetch_data.append_credits()
235    }
236}
237
238impl Executable<TvSeason> for FetchDataTvSeason {
239    fn finish(&self) -> HttpGet<TvSeason> {
240        let Self {
241            fetch_data:
242                FetchData {
243                    tmdb: TMDb { api_key, language },
244                    id,
245                    append_to_response,
246                },
247            season_number,
248        } = self;
249
250        let mut relative_url: String =
251            format!("/tv/{id}/season/{season_number}?api_key={api_key}&language={language}");
252
253        if !append_to_response.is_empty() {
254            relative_url.push_str("&append_to_response=");
255            for appendable in append_to_response {
256                match appendable {
257                    Appendable::Videos => relative_url.push_str("videos,"),
258                    Appendable::Credits => relative_url.push_str("credits,"),
259                }
260            }
261        }
262
263        let url = build_url(&relative_url);
264
265        HttpGet::new(url)
266    }
267}
268
269pub trait Find<'a> {
270    fn imdb_id(&mut self, imdb_id: &'a str) -> &mut FindData<'a>;
271}
272
273#[derive(Clone, Debug)]
274pub struct FindData<'a> {
275    tmdb: TMDb,
276    imdb_id: &'a str,
277}
278
279impl<'a> Find<'a> for FindData<'a> {
280    fn imdb_id(&mut self, imdb_id: &'a str) -> &mut FindData<'a> {
281        self.imdb_id = imdb_id;
282        self
283    }
284}
285
286impl Executable<FindResult> for FindData<'_> {
287    fn finish(&self) -> HttpGet<FindResult> {
288        let relative_url = format!(
289            "/find/{}?api_key={}&external_source=imdb_id&language={}&append_to_response=images",
290            self.imdb_id, // rustfmt hint
291            self.tmdb.api_key,
292            self.tmdb.language
293        );
294        let url = build_url(&relative_url);
295        HttpGet::new(url)
296    }
297}
298
299#[derive(Clone, Debug)]
300pub struct FetchConfig {
301    tmdb: TMDb,
302}
303impl Executable<ConfigDetails> for FetchConfig {
304    fn finish(&self) -> HttpGet<ConfigDetails> {
305        let relative_url = format!(
306            "/configuration?api_key={}&language={}",
307            self.tmdb.api_key, self.tmdb.language
308        );
309        let url = build_url(&relative_url);
310        HttpGet::new(url)
311    }
312}
313
314pub trait TMDbApi {
315    fn search_title<'a>(&self, title: &'a str) -> SearchData<'a>;
316    fn fetch_id(&self, id: u64) -> FetchData;
317    fn find_id<'a>(&self, tmdb_id: &'a str) -> FindData<'a>;
318}
319
320#[derive(Clone, Debug)]
321pub struct TMDb {
322    pub api_key: &'static str,
323    pub language: &'static str,
324}
325
326impl TMDbApi for TMDb {
327    fn search_title<'a>(&self, title: &'a str) -> SearchData<'a> {
328        let tmdb = self.clone();
329        SearchData {
330            tmdb,
331            title,
332            year: None,
333        }
334    }
335
336    fn fetch_id(&self, id: u64) -> FetchData {
337        let tmdb = self.clone();
338        FetchData {
339            tmdb,
340            id,
341            append_to_response: vec![],
342        }
343    }
344
345    fn find_id<'a>(&self, imdb_id: &'a str) -> FindData<'a> {
346        let tmdb = self.clone();
347        FindData { tmdb, imdb_id }
348    }
349}
350// TODO: do any of the methods above need to be in a trait?
351impl TMDb {
352    #[must_use]
353    pub fn fetch_config(&self) -> FetchConfig {
354        let tmdb = self.clone();
355        FetchConfig { tmdb }
356    }
357}
358
359pub trait Fetchable {
360    fn fetch(&self, tmdb: &TMDb) -> HttpGet<Movie>;
361}
362
363impl Fetchable for SearchMovie {
364    fn fetch(&self, tmdb: &TMDb) -> HttpGet<Movie> {
365        tmdb.fetch_id(self.id).finish()
366    }
367}
368
369fn build_url(relative_raw: &str) -> String {
370    /// <https://url.spec.whatwg.org/#query-percent-encode-set>
371    const QUERY: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
372        .add(b' ')
373        .add(b'"')
374        .add(b'#')
375        .add(b'<')
376        .add(b'>');
377
378    const BASE_URL: &str = "https://api.themoviedb.org/3";
379
380    let absolute_raw = format!("{BASE_URL}{relative_raw}");
381
382    percent_encoding::utf8_percent_encode(&absolute_raw, QUERY).to_string()
383}