megamind/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(
3    missing_docs,
4    missing_debug_implementations,
5    missing_copy_implementations,
6    trivial_casts,
7    trivial_numeric_casts,
8    unsafe_code,
9    unused_import_braces,
10    unused_qualifications
11)]
12#![cfg_attr(docsrs, feature(doc_auto_cfg))]
13
14use log::info;
15use reqwest::{
16    header::{HeaderMap, HeaderValue, InvalidHeaderValue, AUTHORIZATION},
17    Client as ReqwestClient, Error as ReqwestError, StatusCode,
18};
19use serde::{de::DeserializeOwned, Serialize};
20use thiserror::Error;
21
22pub mod models;
23use models::*;
24
25/// The base URL for the API.
26pub const BASE_URL: &str = "https://api.genius.com";
27
28/// Client errors.
29#[derive(Debug, Error)]
30pub enum ClientError {
31    /// A general client error.
32    #[error("megamind client error: {0}")]
33    General(#[from] ReqwestError),
34    /// A rate limit error.
35    #[error("megamind rate limit error")]
36    RateLimited,
37}
38
39/// An HTTP client for interacting with the Genius API.
40///
41/// Essentially just a thin wrapper around [`reqwest::Client`],
42/// meaning that if you want more control/need to access a missing endpoint
43/// then you can just use the data models with Reqwest directly.
44///
45/// This also means that you can clone this client freely
46/// and **should not** use [`std::sync::Arc`] or [`std::rc::Rc`], much like [`reqwest::Client`].
47#[derive(Debug, Clone)]
48pub struct Client {
49    // internal Reqwest client
50    internal: ReqwestClient,
51}
52
53impl Client {
54    /// Make a generic GET request at a specified relative endpoint.
55    ///
56    /// # Args
57    ///
58    /// * `endpoint` - The relative endpoint; should have "/" prepended.
59    /// * `query` - Any query parameters; matches the signature for [`reqwest::RequestBuilder::query`].
60    ///
61    /// # Returns
62    ///
63    /// A [`Response`].
64    /// [`reqwest::Error`]s can occur if the request fails at the [`reqwest`] level, which includes HTTP related things and JSON parsing.
65    async fn get<T: DeserializeOwned, S: AsRef<str>, P: Serialize + AsRef<str>>(
66        &self,
67        endpoint: S,
68        query: &[(&str, P)],
69    ) -> Result<Response<T>, ClientError> {
70        info!(
71            target: "megamind::get",
72            "endpoint: \"{}\", queries: \"{}\"",
73            endpoint.as_ref(),
74            query
75                .iter()
76                .map(|q| format!("{}={}", q.0, q.1.as_ref()))
77                .collect::<Vec<String>>()
78                .join(",")
79        );
80        let response = self
81            .internal
82            .get(format!("{}{}", BASE_URL, endpoint.as_ref()))
83            .query(query)
84            .send()
85            .await?;
86        let resp_url = response.url().clone();
87        if response.status() == StatusCode::TOO_MANY_REQUESTS {
88            return Err(ClientError::RateLimited);
89        }
90        Ok(response
91            .json::<Response<T>>()
92            .await
93            .map_err(|e| e.with_url(resp_url))?)
94    }
95
96    /// Get the account info for the currently authed user.
97    ///
98    /// Requires scope: `me`.
99    ///
100    /// # Returns
101    ///
102    /// The current user.
103    pub async fn account(&self) -> Result<Response<AccountResponse>, ClientError> {
104        self.get("/account", &[("text_format", "html,plain")]).await
105    }
106
107    ///  Get an annotation.
108    ///
109    /// # Args
110    ///
111    /// * `id` - A Genius ID.
112    ///
113    /// # Returns
114    ///
115    /// The annotation associated with the ID.
116    pub async fn annotation(
117        &self,
118        id: u32,
119    ) -> Result<Response<AnnotationResponse>, ClientError> {
120        self.get(
121            format!("/annotations/{}", id),
122            &[("text_format", "html,plain")],
123        )
124        .await
125    }
126
127    /// Get an artist.
128    ///
129    /// # Args
130    ///
131    /// * `id` - A Genius ID.
132    ///
133    /// # Returns
134    ///
135    /// The artist associated with the ID.
136    pub async fn artist(
137        &self,
138        id: u32,
139    ) -> Result<Response<ArtistResponse>, ClientError> {
140        self.get(format!("/artists/{}", id), &[("text_format", "html,plain")])
141            .await
142    }
143
144    /// Get referents.
145    ///
146    /// # Args
147    ///
148    /// * `created_by` - A Genius ID.
149    /// * `associated` - The associated web page or song.
150    /// * `per_page` - A per-page limit.
151    /// * `page` - A page offset, starting at 1.
152    ///
153    /// # Returns
154    ///
155    /// The referents that are associated with the web page or song
156    /// and/or are created by a user with the given Genius ID.
157    /// Results follow the `per_page` and `page` rules, and there are
158    /// some failure cases that the argument types can't prevent so please
159    /// visit the [Genius documentation](https://docs.genius.com/#referents-h2) for more information.
160    pub async fn referents(
161        &self,
162        created_by: Option<u32>,
163        associated: Option<ReferentAssociation>,
164        per_page: Option<u8>,
165        page: Option<u8>,
166    ) -> Result<Response<ReferentsResponse>, ClientError> {
167        let mut queries = vec![("text_format", String::from("html,plain"))];
168        if let Some(created_by_id) = created_by {
169            queries.push(("created_by_id", created_by_id.to_string()));
170        }
171        if let Some(association) = associated {
172            let params = match association {
173                ReferentAssociation::SongId(id) => ("song_id", id.to_string()),
174                ReferentAssociation::WebPageId(id) => ("web_page_id", id.to_string()),
175            };
176            queries.push(params);
177        }
178        if let Some(per_page) = per_page {
179            queries.push(("per_page", per_page.to_string()));
180        }
181        if let Some(page) = page {
182            queries.push(("page", page.to_string()));
183        }
184        self.get("/referents", &queries).await
185    }
186
187    /// Get search results.
188    ///
189    /// # Args
190    ///
191    /// * `query` - A search term to match against.
192    ///
193    /// # Returns
194    ///
195    /// Search results associated with the query.
196    pub async fn search<S: AsRef<str>>(
197        &self,
198        query: S,
199    ) -> Result<Response<SearchResponse>, ClientError> {
200        self.get("/search", &[("q", query.as_ref())]).await
201    }
202
203    /// Get a song.
204    ///
205    /// # Args
206    ///
207    /// * `id` - A Genius ID.
208    ///
209    /// # Returns
210    ///
211    /// The song associated with the ID.
212    pub async fn song(&self, id: u32) -> Result<Response<SongResponse>, ClientError> {
213        self.get(format!("/songs/{}", id), &[("text_format", "html,plain")])
214            .await
215    }
216
217    /// Get a user.
218    ///
219    /// # Args
220    ///
221    /// * `id` - A Genius ID.
222    ///
223    /// # Returns
224    ///
225    /// The user associated with the ID.
226    pub async fn user(&self, id: u32) -> Result<Response<UserResponse>, ClientError> {
227        self.get(format!("/users/{}", id), &[("text_format", "html,plain")])
228            .await
229    }
230
231    /// Get a web page.
232    ///
233    /// # Args
234    ///
235    /// * `raw_annotatable_url` - The URL as it would appear in a browser.
236    /// * `canonical_url` - The URL as specified by an appropriate <link> tag in a page's <head>.
237    /// * `og_url` - The URL as specified by an og:url <meta> tag in a page's <head>.
238    ///
239    /// # Returns
240    ///
241    /// The web page associated with the above arguments.
242    pub async fn web_pages(
243        &self,
244        raw_annotatable_url: Option<&str>,
245        canonical_url: Option<&str>,
246        og_url: Option<&str>,
247    ) -> Result<Response<WebPageResponse>, ClientError> {
248        let mut queries = Vec::new();
249        if let Some(rau) = raw_annotatable_url {
250            queries.push(("raw_annotatable_url", rau));
251        }
252        if let Some(cu) = canonical_url {
253            queries.push(("canonical_url", cu));
254        }
255        if let Some(ou) = og_url {
256            queries.push(("og_url", ou));
257        }
258        self.get("/web_pages/lookup", &queries).await
259    }
260}
261
262/// A web page or song ID that is associated with a referent.
263#[derive(Debug, Clone, Copy, PartialEq)]
264pub enum ReferentAssociation {
265    /// A song via Genius ID.
266    SongId(u32),
267    /// A web page via Genius ID.
268    WebPageId(u32),
269}
270
271/// Builder for [`Client`]s.
272#[derive(Default, Debug, Clone)]
273pub struct ClientBuilder {
274    /// auth token
275    auth_token: Option<String>,
276}
277
278impl ClientBuilder {
279    /// Create a new [`ClientBuilder`].
280    ///
281    /// # Returns
282    ///
283    /// A new [`ClientBuilder`], with the base API URL configured to the production API URL.
284    pub fn new() -> Self {
285        ClientBuilder { auth_token: None }
286    }
287
288    /// Set the auth token.
289    ///
290    /// **Note**: does not protect you from entering invalid tokens (e.g., an empty string, an expired token, token with invalid characters, etc.).
291    ///
292    /// # Args
293    ///
294    /// * `auth_token` - The auth token for API requests.
295    ///
296    /// # Returns
297    ///
298    /// The modified [`ClientBuilder`].
299    pub fn auth_token<S: Into<String>>(mut self, auth_token: S) -> Self {
300        self.auth_token = Some(auth_token.into());
301        self
302    }
303
304    /// Build a [`Client`].
305    ///
306    /// # Returns
307    /// A configured [`Client`].
308    /// [`ClientBuilderError`]s can occur if the auth token is missing or contains invalid characters.
309    /// [`ClientBuilderError::ReqwestBuilder`] can technically happen but it wouldn't be clear as to why it would occur.
310    pub fn build(self) -> Result<Client, ClientBuilderError> {
311        if let Some(auth_token) = self.auth_token {
312            let mut headers = HeaderMap::new();
313            let mut header_val =
314                HeaderValue::from_str(&format!("Bearer {}", auth_token))?;
315            header_val.set_sensitive(true);
316            headers.insert(AUTHORIZATION, header_val);
317            Ok(Client {
318                internal: ReqwestClient::builder().default_headers(headers).build()?,
319            })
320        } else {
321            Err(ClientBuilderError::MissingAuthToken)
322        }
323    }
324}
325
326/// Errors that can occur during [`ClientBuilder::build`].
327#[derive(Debug, Error)]
328pub enum ClientBuilderError {
329    /// Missing auth token.
330    #[error("missing auth token")]
331    MissingAuthToken,
332    /// [`reqwest::ClientBuilder::build`] failed.
333    #[error("internal client build error: {0}")]
334    ReqwestBuilder(#[from] ReqwestError),
335    /// Invalid value for the [`reqwest::header::AUTHORIZATION`] header.
336    #[error("invalid auth header value: {0}")]
337    AuthHeaderValue(#[from] InvalidHeaderValue),
338}