use reqwest::{IntoUrl, StatusCode, Url};
use serde::Deserialize;
use snafu::{prelude::*, Backtrace};
use std::{collections::HashMap, fmt::Display};
type StdResult<T, E> = std::result::Result<T, E>;
#[non_exhaustive]
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("Invalid URL for the Instapaper API: {source}"))]
BadUrl {
source: reqwest::Error,
backtrace: Backtrace,
},
#[snafu(display("HTTP error: {source}"))]
Http {
source: reqwest::Error,
backtrace: Backtrace,
},
#[snafu(display("Instapaper API error {status}"))]
Instapaper { status: reqwest::StatusCode },
#[snafu(display("Instapaper JSON error: {source}"))]
Json {
source: reqwest::Error,
backtrace: Backtrace,
},
#[snafu(display("Rate limit exceeed/oo many requests"))]
RateLimit,
}
type Result<T> = StdResult<T, Error>;
#[derive(Debug)]
pub struct Client {
url: Url,
client: reqwest::Client,
username: String,
password: String,
}
impl Display for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "Instapaper Client({}:{})", self.url, &self.username)
}
}
#[derive(Debug)]
pub struct Post {
url: Url,
title: Option<String>,
selection: Option<String>,
}
impl Display for Post {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "{{Instapaper Post: {}|{:#?}}}", self.url, self.title)
}
}
impl Post {
pub fn new<U: IntoUrl>(url: U, title: Option<&str>, selection: Option<&str>) -> Result<Post> {
Ok(Post {
url: url.into_url().context(BadUrlSnafu)?,
title: title.map(|s| s.into()),
selection: selection.map(|s| s.into()),
})
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn title(&self) -> Option<&str> {
self.title.as_ref().map(|s| s.as_ref())
}
pub fn selection(&self) -> Option<&str> {
self.selection.as_ref().map(|s| s.as_ref())
}
}
#[derive(Deserialize)]
struct Response {
bookmark_id: usize,
}
impl Display for Response {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "{{Pinboard Response: {}}}", self.bookmark_id)
}
}
impl Client {
pub fn new<U: IntoUrl>(url: U, username: &str, password: &str) -> Result<Client> {
use crate::vars::PIN_UA;
Ok(Client {
url: url.into_url().context(BadUrlSnafu {})?,
client: reqwest::Client::builder()
.user_agent(PIN_UA)
.build()
.context(HttpSnafu {})?,
username: username.to_string(),
password: password.to_string(),
})
}
#[tracing::instrument]
pub async fn send_link(&self, post: &Post) -> Result<usize> {
let mut params = HashMap::new();
params.insert("url", post.url().as_str());
if let Some(title) = post.title() {
params.insert("title", title);
}
if let Some(selection) = post.selection() {
params.insert("selection", selection);
}
let rsp = self
.client
.get(self.url.join("api/add").expect("Invalid URL in send_link"))
.query(¶ms)
.basic_auth(&self.username, Some(&self.password))
.send()
.await
.context(HttpSnafu)?;
let status = rsp.status();
let rsp = rsp.json::<Response>().await.context(JsonSnafu)?;
if status.is_success() {
Ok(rsp.bookmark_id)
} else if status == StatusCode::BAD_REQUEST {
return Err(Error::RateLimit);
} else {
return InstapaperSnafu { status }.fail();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::{Matcher, Matcher::UrlEncoded};
use test_log::test;
#[test(tokio::test)]
async fn smoke() {
let _mock = mockito::mock("GET", Matcher::Regex(r"/api/add.*$".to_string()))
.match_query(mockito::Matcher::AllOf(vec![
UrlEncoded(
"url".into(),
"https://unherd.com/thepost/liz-cheneys-neoconservatism-is-dead/".into(),
),
UrlEncoded(
"title".into(),
"Liz Cheney's Neoconservativism is dead".into(),
),
UrlEncoded("selection".into(), "Courtesy of pin 0.2!".into()),
]))
.with_status(201)
.with_header("content-type", "text/plain")
.with_header("content-length", "42")
.with_header("connection", "keep-alive")
.with_header("server", "nginx/1.20.1")
.with_header(
"content-location",
"https, //unherd.com/thepost/liz-cheneys-neoconservatism-is-dead/",
)
.with_header("x-powered-by", "AMT")
.with_header("pragma", "no-cache")
.with_header("cache-control", "no-cache")
.with_header(
"x-instapaper-title",
"Liz Cheney's Neoconservativism is dead",
)
.with_body("{\"folders\": [], \"bookmark_id\": 1530380252}")
.create();
let client = Client::new(&mockito::server_url(), "sp1ff@pobox.com", "c0fee")
.expect("Failed to build client");
let post = Post::new(
"https://unherd.com/thepost/liz-cheneys-neoconservatism-is-dead/",
Some("Liz Cheney's Neoconservativism is dead"),
Some("Courtesy of pin 0.2!"),
)
.unwrap();
let id = client.send_link(&post).await.expect("Send-link failed");
assert_eq!(id, 1530380252);
}
}