use reqwest::{IntoUrl, StatusCode, Url};
use serde::{Deserialize, Serialize};
use snafu::{prelude::*, Backtrace};
use unicode_segmentation::UnicodeSegmentation;
use std::collections::HashMap;
use std::fmt::{Debug, Display};
type StdResult<T, E> = std::result::Result<T, E>;
#[non_exhaustive]
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("Invalid URL for the Pinboard API: {source}"))]
BadUrl {
source: reqwest::Error,
backtrace: Backtrace,
},
#[snafu(display("HTTP error: {source}"))]
Http {
source: reqwest::Error,
backtrace: Backtrace,
},
#[snafu(display("\"{text}\" is not a valid tag"))]
InvalidTag { text: String, backtrace: Backtrace },
#[snafu(display("\"{text}\" is not a valid title"))]
InvalidTitle { text: String, backtrace: Backtrace },
#[snafu(display("Pinboard API error: {status} ({text:?})"))]
Pinboard {
status: reqwest::StatusCode,
text: Option<String>,
backtrace: Backtrace,
},
#[snafu(display("Rate limit hit"))]
RateLimit,
}
pub type Result<T> = StdResult<T, Error>;
struct Response(reqwest::Response);
impl Response {
async fn into_result(self) -> Result<()> {
let status = self.0.status();
if status.is_success() {
Ok(())
} else if status == StatusCode::TOO_MANY_REQUESTS {
return Err(Error::RateLimit);
} else {
return PinboardSnafu {
status,
text: self.0.text().await.ok(),
}
.fail();
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(try_from = "String")]
pub struct Tag {
#[serde(deserialize_with = "deserialize_tag")]
value: String,
}
impl Tag {
pub fn new(text: &str) -> Result<Tag> {
Tag::validate_text(text, 256)?;
Ok(Tag { value: text.into() })
}
pub fn private(text: &str) -> Result<Tag> {
Tag::validate_text(text, 255)?;
Ok(Tag {
value: format!(".{}", text),
})
}
fn validate_text(text: &str, max_grapheme_clusters: usize) -> Result<()> {
if text.contains(char::is_whitespace)
|| text.contains(',')
|| UnicodeSegmentation::graphemes(text, true).count() >= max_grapheme_clusters
{
return InvalidTagSnafu {
text: text.to_string(),
}
.fail();
}
Ok(())
}
}
impl std::convert::AsRef<str> for Tag {
fn as_ref(&self) -> &str {
&self.value
}
}
impl Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "{}", self.value)
}
}
impl std::convert::TryFrom<String> for Tag {
type Error = Error;
fn try_from(s: String) -> StdResult<Self, Self::Error> {
if b'.'
== *s
.as_bytes()
.iter()
.take(1)
.next()
.ok_or_else(|| InvalidTagSnafu { text: s.clone() }.build())?
{
Tag::validate_text(
std::str::from_utf8(&s.as_bytes()[1..]).expect("Bad UTF-8"),
254,
)?;
Ok(Tag { value: s })
} else {
Tag::validate_text(&s, 255)?;
Ok(Tag { value: s })
}
}
}
impl std::convert::From<Tag> for String {
fn from(t: Tag) -> Self {
t.value
}
}
impl std::str::FromStr for Tag {
type Err = Error;
fn from_str(s: &str) -> StdResult<Self, Self::Err> {
Tag::try_from(s.to_string())
}
}
#[derive(Debug)]
pub struct Title {
title: String,
}
impl Title {
pub fn new(text: &str) -> Result<Title> {
Title::validate_text(text)?;
Ok(Title { title: text.into() })
}
fn validate_text(text: &str) -> Result<()> {
if UnicodeSegmentation::graphemes(text, true).count() > 255 {
return InvalidTitleSnafu {
text: text.to_string(),
}
.fail();
}
Ok(())
}
}
impl std::convert::AsRef<str> for Title {
fn as_ref(&self) -> &str {
&self.title
}
}
impl Display for Title {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "{}", self.title)
}
}
impl std::str::FromStr for Title {
type Err = Error;
fn from_str(s: &str) -> StdResult<Self, Self::Err> {
Title::validate_text(s)?;
Ok(Title { title: s.into() })
}
}
#[cfg(test)]
mod entity_tests {
use super::*;
#[test]
fn test_tag() {
let x = Tag::new("foo");
assert!(x.is_ok());
let x = x.unwrap();
assert_eq!(format!("{}", x), "foo");
let x = Tag::private("bar");
assert!(x.is_ok());
let x = x.unwrap();
assert_eq!(format!("{}", x), ".bar");
let x = Tag::new(&"好".repeat(255));
assert!(x.is_ok());
let x = Tag::new(&"好".repeat(256));
assert!(x.is_err());
let x = Tag::private(&"好".repeat(254));
assert!(x.is_ok());
let x = Tag::private(&"好".repeat(255));
assert!(x.is_err());
let x = Tag::new("a,b");
assert!(x.is_err());
let x = Tag::new("a b");
assert!(x.is_err());
}
#[derive(Debug, Deserialize)]
struct TagTest {
tag: Tag,
}
#[test]
fn test_serde() {
let trivial: TagTest = toml::from_str(r#"tag = "foo""#).unwrap();
assert_eq!("foo", trivial.tag.as_ref());
let bad: StdResult<TagTest, toml::de::Error> = toml::from_str(r#"tag = "foo bar""#);
assert!(bad.is_err());
}
}
#[derive(Clone, Debug)]
pub struct Client {
url: Url,
client: reqwest::Client,
token: String,
}
impl Display for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "Pinboard Client({}:{})", self.url, &self.token[0..8])
}
}
#[derive(Debug)]
pub struct Post {
link: Url,
title: Title,
tags: Vec<Tag>,
read_later: bool,
}
impl Display for Post {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> StdResult<(), std::fmt::Error> {
write!(f, "{{Pinboard Post: {}|{}}}", self.link, self.title)
}
}
impl Post {
pub fn new<I>(link: Url, title: Title, tags: I, read_later: bool) -> Post
where
I: Iterator<Item = Tag>,
{
Post {
link,
title,
tags: tags.collect(),
read_later,
}
}
}
impl Client {
pub fn new<U: IntoUrl>(url: U, token: &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 {})?,
token: token.to_string(),
})
}
#[tracing::instrument]
pub async fn get_all_tags(&self) -> Result<HashMap<String, usize>> {
let rsp = self
.client
.get(
self.url
.join("v1/tags/get")
.expect("Invalid URL in get_all_tags()"),
)
.query(&[("format", "json"), ("auth_token", &self.token)])
.send()
.await
.context(HttpSnafu {})?;
if StatusCode::OK != rsp.status() {
eprintln!("{:#?}", rsp);
return PinboardSnafu {
status: rsp.status(),
text: rsp.text().await.ok(),
}
.fail();
}
rsp.json::<HashMap<String, usize>>()
.await
.context(HttpSnafu {})
}
#[tracing::instrument]
pub async fn send_post(&self, post: &Post) -> Result<()> {
Response(
self.client
.get(
self.url
.join("v1/posts/add")
.expect("Invalid URL in send_posts()"),
)
.query(&[
("url", post.link.as_ref()),
("description", post.title.as_ref()),
(
"tags",
&post.tags.iter().fold(String::from(""), |mut acc, x| {
acc.push(' ');
acc.push_str(x.as_ref());
acc
}),
),
(
"toread",
match post.read_later {
true => "yes",
false => "no",
},
),
("auth_token", &self.token),
("format", "json"),
])
.send()
.await
.context(HttpSnafu)?,
)
.into_result()
.await
}
#[tracing::instrument]
pub async fn all_posts<T>(&self, tags: T) -> Result<reqwest::Response>
where
T: Iterator<Item = Tag> + Debug,
{
let mut query_params = vec![
("auth_token", self.token.clone()),
("format", "json".into()),
];
for tag in tags {
query_params.push(("tag", tag.into()));
}
self.client
.get(
self.url
.join("v1/posts/all")
.expect("Invalid URL in send_posts()"),
)
.query(&query_params)
.send()
.await
.context(HttpSnafu)
}
#[tracing::instrument]
pub async fn delete_post(&self, url: Url) -> Result<()> {
Response(
self.client
.get(
self.url
.join("v1/posts/delete")
.expect("Invalid URL in delete_post()"),
)
.query(&[
("url", url.as_ref()),
("auth_token", &self.token),
("format", "json"),
])
.send()
.await
.context(HttpSnafu)?,
)
.into_result()
.await
}
#[tracing::instrument]
pub async fn rename_tag(&self, from: &Tag, to: &Tag) -> Result<()> {
Response(
self.client
.get(
self.url
.join("/v1/tags/rename")
.expect("Invalid URL in rename_tag"),
)
.query(&[
("old", from.as_ref()),
("new", to.as_ref()),
("auth_token", &self.token),
("format", "json"),
])
.send()
.await
.context(HttpSnafu)?,
)
.into_result()
.await
}
}
#[cfg(test)]
mod test {
use super::*;
use mockito::Matcher;
use test_log::test;
#[test(tokio::test)]
async fn test_get_all_tags() {
let _mock = mockito::mock("GET",
Matcher::Regex(r"/v1/tags/get.*$".to_string()))
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("format".into(), "json".into()),
mockito::Matcher::UrlEncoded(
"auth_token".into(),
"sp1ff:FFFFFFFFFFFFFFFFFFFF".into(),
),
]))
.with_status(200)
.with_header("content-type", "text/json; charset=utf-8")
.with_header("server", "Apache/2.4.18 (Ubuntu)")
.with_body("{\"1997\":1,\"2012\":1,\"2017\":1,\"2018\":3,\"2019\":6,\"2020\":51,\"2020-08-24\":1,\"2021\":103,\"2021-recall\":1}\t")
.create();
let client = Client::new(&mockito::server_url(), "sp1ff:FFFFFFFFFFFFFFFFFFFF").unwrap();
let rsp = client.get_all_tags().await;
assert!(rsp.is_ok());
let rsp = rsp.unwrap();
assert_eq!(rsp.get("2020"), Some(&51));
}
}