use crate::defs;
use crate::error::{Error, Result};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::{
body::Buf,
header::{self, HeaderValue},
Method, Request,
};
use hyper_tls::HttpsConnector;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client as HttpClient;
use serde::{de::DeserializeOwned, Serialize};
use url::Url;
use std::fmt;
#[derive(Debug, Clone)]
pub struct ApiKeyPair {
api_key: String,
api_secret: String,
}
impl ApiKeyPair {
pub const API_KEY_ENVVAR: &'static str = "LETTERBOXD_API_KEY";
pub const API_SECRET_ENVVAR: &'static str = "LETTERBOXD_API_SECRET";
pub fn new(api_key: String, api_secret: String) -> Self {
Self {
api_key,
api_secret,
}
}
pub fn from_env() -> Option<Self> {
match (
std::env::var(Self::API_KEY_ENVVAR),
std::env::var(Self::API_SECRET_ENVVAR),
) {
(Ok(api_key), Ok(api_secret)) => Some(Self::new(api_key, api_secret)),
_ => None,
}
}
}
pub struct Client {
api_key_pair: ApiKeyPair,
token: Option<defs::AccessToken>,
http_client: HttpClient<HttpsConnector<HttpConnector>, Full<Bytes>>,
}
impl Client {
const API_BASE_URL: &'static str = "https://api.letterboxd.com/api/v0/";
pub fn new(api_key_pair: ApiKeyPair) -> Self {
Self {
api_key_pair,
token: None,
http_client: http_client(),
}
}
pub fn with_token(api_key_pair: ApiKeyPair, token: defs::AccessToken) -> Self {
Self {
api_key_pair,
token: Some(token),
http_client: http_client(),
}
}
pub async fn authenticate(
api_key_pair: ApiKeyPair,
username: &str,
password: &str,
) -> Result<Self> {
let content_type = HeaderValue::from_static("application/x-www-form-urlencoded");
#[derive(Debug, Serialize)]
struct AuthRequest<'a> {
grant_type: &'static str,
username: &'a str,
password: &'a str,
}
let request = AuthRequest {
grant_type: "password",
username,
password,
};
let body = serde_url_params::to_vec(&request)?;
let mut client = Self::new(api_key_pair);
let buf = client
.request_bytes::<()>(
Method::POST,
"auth/token",
None,
Some(content_type),
Some(body),
)
.await?;
client.set_token(Some(serde_json::from_reader(&mut buf.reader())?));
Ok(client)
}
pub fn is_authenticated(&self) -> bool {
self.token.is_some()
}
pub fn token(&self) -> Option<&defs::AccessToken> {
self.token.as_ref()
}
pub fn set_token(&mut self, token: Option<defs::AccessToken>) {
self.token = token;
}
pub async fn films(&self, request: &defs::FilmsRequest) -> Result<defs::FilmsResponse> {
self.get_with_query("films", request).await
}
pub async fn film_services(&self) -> Result<defs::FilmServicesResponse> {
self.get("films/film-services").await
}
pub async fn film_genres(&self) -> Result<defs::GenresResponse> {
self.get("films/genres").await
}
pub async fn film_languages(&self) -> Result<defs::LanguagesResponse> {
self.get("films/languages").await
}
pub async fn film(&self, id: &str) -> Result<defs::Film> {
self.get(&format!("film/{}", id)).await
}
pub async fn film_availability(&self, id: &str) -> Result<defs::FilmAvailabilityResponse> {
self.get(&format!("film/{}/availability", id)).await
}
pub async fn film_relationship(&self, id: &str) -> Result<defs::FilmAvailabilityResponse> {
self.get(&format!("film/{}/me", id)).await
}
pub async fn update_film_relationship(
&self,
id: &str,
request: &defs::FilmRelationshipUpdateRequest,
) -> Result<defs::FilmRelationshipUpdateResponse> {
self.patch(&format!("film/{}/me", id), request).await
}
pub async fn film_relationship_members(
&self,
id: &str,
request: &defs::MemberFilmRelationshipsRequest,
) -> Result<defs::MemberFilmRelationshipsResponse> {
self.get_with_query(&format!("film/{}/members", id), request)
.await
}
pub async fn film_statistics(&self, id: &str) -> Result<defs::FilmStatistics> {
self.get(&format!("film/{}/statistics", id)).await
}
pub async fn lists(&self, request: &defs::ListsRequest) -> Result<defs::ListsResponse> {
self.get_with_query("lists", request).await
}
pub async fn create_list(
&self,
request: &defs::ListCreationRequest,
) -> Result<defs::ListCreateResponse> {
self.post("lists", request).await
}
pub async fn list(&self, id: &str) -> Result<defs::List> {
self.get(&format!("list/{}", id)).await
}
pub async fn update_list(
&self,
id: &str,
request: &defs::ListUpdateRequest,
) -> Result<defs::ListUpdateResponse> {
self.patch(&format!("list/{}", id), request).await
}
pub async fn delete_list(&self, id: &str) -> Result<()> {
self.delete(&format!("list/{}", id)).await
}
pub async fn list_entries(
&self,
id: &str,
request: &defs::ListEntriesRequest,
) -> Result<defs::ListEntriesResponse> {
self.get_with_query(&format!("list/{}/entries", id), request)
.await
}
pub async fn search(&self, request: &defs::SearchRequest) -> Result<defs::SearchResponse> {
self.get_with_query("search", request).await
}
async fn get<R>(&self, endpoint_path: &str) -> Result<R>
where
R: DeserializeOwned + 'static,
{
self.request::<(), (), _>(Method::GET, endpoint_path, None, None)
.await
}
async fn get_with_query<Q, R>(&self, endpoint_path: &str, query: &Q) -> Result<R>
where
Q: Serialize,
R: DeserializeOwned + 'static,
{
self.request::<_, (), _>(Method::GET, endpoint_path, Some(query), None)
.await
}
async fn patch<B, R>(&self, endpoint_path: &str, body: &B) -> Result<R>
where
B: Serialize,
R: DeserializeOwned + 'static,
{
self.request::<(), _, _>(Method::PATCH, endpoint_path, None, Some(body))
.await
}
async fn post<B, R>(&self, endpoint_path: &str, body: &B) -> Result<R>
where
B: Serialize,
R: DeserializeOwned + 'static,
{
self.request::<(), _, _>(Method::POST, endpoint_path, None, Some(body))
.await
}
async fn delete(&self, endpoint_path: &str) -> Result<()> {
self.request_bytes::<()>(Method::DELETE, endpoint_path, None, None, None)
.await?;
Ok(())
}
async fn request<Q, B, R>(
&self,
method: Method,
endpoint_path: &str,
query: Option<&Q>,
body: Option<&B>,
) -> Result<R>
where
Q: Serialize,
B: Serialize,
R: DeserializeOwned + 'static,
{
let content_type = HeaderValue::from_static("application/json");
let body = body.map(serde_json::to_vec).transpose()?;
let buf = self
.request_bytes(method, endpoint_path, query, Some(content_type), body)
.await?;
let res = serde_json::from_reader(&mut buf.reader())?;
Ok(res)
}
async fn request_bytes<Q>(
&self,
method: Method,
endpoint_path: &str,
query: Option<&Q>,
content_type: Option<HeaderValue>,
body: Option<Vec<u8>>,
) -> Result<impl Buf>
where
Q: Serialize,
{
let mut url = Url::parse(Self::API_BASE_URL)
.unwrap()
.join(endpoint_path)
.unwrap(); let query = query.map(serde_url_params::to_string).transpose()?;
url.set_query(query.as_ref().map(|s| s.as_ref()));
let body = body.unwrap_or_default();
let signed_url = self.sign_url(url, &method, &body);
let mut req = Request::builder()
.method(method)
.uri(signed_url.as_str())
.header(
header::ACCEPT_ENCODING,
HeaderValue::from_static("application/json"),
)
.header(
header::CONTENT_LENGTH,
HeaderValue::from_str(&format!("{}", body.len())).expect("invalid header value"),
);
if let Some(headers) = req.headers_mut() {
if let Some(content_type) = content_type {
headers.insert(header::CONTENT_TYPE, content_type);
}
if let Some(token) = self.token.as_ref() {
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", token.access_token))
.expect("invalid header value"),
);
}
}
let req = req.body(Full::from(body)).expect("invalid body");
let resp = self.http_client.request(req).await?;
let status = resp.status();
let mut buf = resp.into_body().collect().await?.to_bytes();
if !status.is_success() {
let mut content = String::new();
while buf.has_remaining() {
content.push_str(&String::from_utf8_lossy(buf.chunk()));
buf.advance(buf.chunk().len());
}
return Err(Error::server_error(
status,
content,
signed_url.as_str().parse()?,
));
}
Ok(buf)
}
fn sign_url(&self, mut url: Url, method: &Method, body: &[u8]) -> Url {
use hex::ToHex;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let nonce = uuid::Uuid::new_v4();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("SystemTime::duration_since failed")
.as_secs();
url.query_pairs_mut()
.append_pair("apikey", &self.api_key_pair.api_key)
.append_pair("nonce", &format!("{}", nonce))
.append_pair("timestamp", &format!("{}", timestamp));
let mut hmac = HmacSha256::new_from_slice(self.api_key_pair.api_secret.as_bytes())
.expect("HMAC can take key of any size");
hmac.update(method.as_str().as_bytes());
hmac.update(&[b'\0']);
hmac.update(url.as_str().as_bytes());
hmac.update(&[b'\0']);
hmac.update(body);
let signature: String = hmac.finalize().into_bytes().encode_hex();
url.query_pairs_mut().append_pair("signature", &signature);
url
}
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("Client")
.field("api_key_pair", &"[hidden]")
.field("token", &self.token)
.field("http_client", &self.http_client)
.finish()
}
}
fn http_client() -> HttpClient<HttpsConnector<HttpConnector>, Full<Bytes>> {
let https = HttpsConnector::new();
HttpClient::builder(hyper_util::rt::TokioExecutor::new()).build(https)
}