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}