pinboard_rs/api/v1/posts/
add.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use crate::api::endpoint_prelude::*;
8use crate::api::v1::Limit;
9use chrono::NaiveDate;
10use derive_builder::Builder;
11
12/// Create an Add endpoint for posts.
13///
14/// <https://pinboard.in/api/#posts_add>
15///
16/// # Arguments
17/// This builder takes two required arguments.
18/// * `url` - URL of the bookmark
19/// * `description` - title or description of the bookmark
20///
21/// The remaining six arguments are optional.
22/// * `extended` - extended description of the bookmark
23/// * `tags` - vector of up to 100 tags
24/// * `dt` - creation time for this bookmark
25/// * `replace` - boolean indicating if this should replace existing bookmark (default: true)
26/// * `shared` - boolean to make the bookmark public
27/// * `toread` - boolean that marks the bookmark as toread
28///
29/// Note that if no `dt` is supplied, the date of the last bookmark will
30/// be used.
31///
32/// # Example
33/// ```rust
34/// # fn main() {
35/// # use crate::pinboard_rs::api::v1::posts::Add;
36/// # use crate::pinboard_rs::api::Endpoint;
37/// # use url::Url;
38/// let post_endpoint = Add::builder()
39///                     .url(Url::parse("https://example.com").unwrap())
40///                     .description("Example bookmark")
41///                     .build().unwrap();
42/// assert_eq!(post_endpoint.endpoint(), "v1/posts/add");
43/// # }
44/// ```
45#[derive(Builder, Debug)]
46#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
47pub struct Add<'a> {
48    /// The bookmark to save
49    url: url::Url,
50    /// The title of the link (backwards-compatible name)
51    #[builder(setter(into), default = "self.default_description()?.into()")]
52    description: Cow<'a, str>,
53    /// The description of the link (backwards-compatible name)
54    #[builder(setter(into), default)]
55    extended: Option<Cow<'a, str>>,
56    /// The tags to add (limit of 100)
57    #[builder(setter(into), default)]
58    tags: Option<Cow<'a, [&'a str]>>,
59    /// Creation time of this bookmark
60    #[builder(default)]
61    dt: Option<NaiveDate>,
62    /// Whether or not to replace the current bookmark (server default is yes)
63    /// An error is thrown when this is tru and a bookmark already exists for the url
64    #[builder(default)]
65    replace: Option<bool>,
66    /// Make the bookmark public
67    #[builder(default)]
68    shared: Option<bool>,
69    /// Marks the bookmark as unread.
70    #[builder(default)]
71    toread: Option<bool>,
72}
73
74impl<'a> AddBuilder<'a> {
75    // Ensure there is something for a default description
76    fn default_description(&self) -> Result<String, String> {
77        match self.url {
78            Some(ref url) => Ok(url.to_string()),
79            _ => Err("Could not make default `description` from `url`".to_string()),
80        }
81    }
82
83    // Check that the tags to not exceed 100
84    fn validate(&self) -> Result<(), String> {
85        if let Some(Some(ref xs)) = self.tags {
86            if xs.len() > 100 {
87                return Err(format!(
88                    "Endpoint only accepts up to 100 tags (received {})",
89                    xs.len()
90                ));
91            }
92        }
93        Ok(())
94    }
95}
96
97impl<'a> Add<'a> {
98    /// Create a builder for the endpoint
99    pub fn builder() -> AddBuilder<'a> {
100        AddBuilder::default()
101    }
102}
103
104impl<'a> Endpoint for Add<'a> {
105    fn method(&self) -> Method {
106        Method::GET
107    }
108
109    fn endpoint(&self) -> Cow<'static, str> {
110        "v1/posts/add".into()
111    }
112
113    fn parameters(&self) -> QueryParams {
114        let mut params = QueryParams::default();
115
116        params
117            .push("url", self.url.as_ref())
118            .push("description", self.description.as_ref())
119            .push_opt("extended", self.extended.as_ref())
120            .push_opt("tags", self.tags.as_ref().map(|xs| xs.join(" ")))
121            .push_opt("dt", self.dt)
122            .push_opt(
123                "replace",
124                self.replace.map(|x| if x { "yes" } else { "no" }),
125            )
126            .push_opt("shared", self.shared.map(|x| if x { "yes" } else { "no" }))
127            .push_opt("toread", self.toread.map(|x| if x { "yes" } else { "no" }));
128
129        params
130    }
131}
132
133impl<'a> Limit for Add<'a> {}
134
135#[cfg(test)]
136mod tests {
137    use crate::api::v1::{posts::Add, Limit};
138    use crate::api::{self, Query};
139    use crate::test::client::{ExpectedUrl, SingleTestClient};
140    use chrono::NaiveDate;
141
142    static TITLE: &str = "Some Title";
143    static URL: &str = "http://pinboard.test/";
144    fn test_url() -> url::Url {
145        url::Url::parse(URL).unwrap()
146    }
147
148    #[test]
149    fn url_is_required() {
150        let err = Add::builder().description(TITLE).build().unwrap_err();
151        assert_eq!(&err.to_string(), "`url` must be initialized")
152    }
153
154    #[test]
155    fn description_is_required() {
156        let add = Add::builder().url(test_url()).build().unwrap();
157        assert_eq!(add.description, URL)
158    }
159
160    #[test]
161    fn endpoint() {
162        let endpoint = ExpectedUrl::builder()
163            .endpoint("v1/posts/add")
164            .add_query_params(&[("url", URL), ("description", TITLE)])
165            .build()
166            .unwrap();
167        let client = SingleTestClient::new_raw(endpoint, "");
168
169        let endpoint = Add::builder()
170            .url(test_url())
171            .description(TITLE)
172            .build()
173            .unwrap();
174        api::ignore(endpoint).query(&client).unwrap();
175    }
176
177    #[test]
178    fn endpoint_extended() {
179        let endpoint = ExpectedUrl::builder()
180            .endpoint("v1/posts/add")
181            .add_query_params(&[
182                ("url", URL),
183                ("description", TITLE),
184                ("extended", "some extended text"),
185            ])
186            .build()
187            .unwrap();
188        let client = SingleTestClient::new_raw(endpoint, "");
189
190        let endpoint = Add::builder()
191            .url(test_url())
192            .description(TITLE)
193            .extended("some extended text")
194            .build()
195            .unwrap();
196        api::ignore(endpoint).query(&client).unwrap();
197    }
198
199    #[test]
200    fn endpoint_tags() {
201        let endpoint = ExpectedUrl::builder()
202            .endpoint("v1/posts/add")
203            .add_query_params(&[("url", URL), ("description", TITLE), ("tags", "one two")])
204            .build()
205            .unwrap();
206        let client = SingleTestClient::new_raw(endpoint, "");
207
208        let endpoint = Add::builder()
209            .url(test_url())
210            .description(TITLE)
211            .tags(vec!["one", "two"])
212            .build()
213            .unwrap();
214        api::ignore(endpoint).query(&client).unwrap();
215    }
216
217    #[test]
218    fn endpoint_tags_101() {
219        let err = Add::builder()
220            .url(test_url())
221            .description(TITLE)
222            .tags(vec!["one"; 101])
223            .build()
224            .unwrap_err();
225
226        assert_eq!(
227            &err.to_string(),
228            "Endpoint only accepts up to 100 tags (received 101)"
229        )
230    }
231
232    #[test]
233    fn endpoint_dt() {
234        let endpoint = ExpectedUrl::builder()
235            .endpoint("v1/posts/add")
236            .add_query_params(&[("url", URL), ("description", TITLE), ("dt", "2021-03-04")])
237            .build()
238            .unwrap();
239        let client = SingleTestClient::new_raw(endpoint, "");
240
241        let endpoint = Add::builder()
242            .url(test_url())
243            .description(TITLE)
244            .dt(NaiveDate::from_ymd_opt(2021, 3, 4).expect("Valid date"))
245            .build()
246            .unwrap();
247        api::ignore(endpoint).query(&client).unwrap();
248    }
249
250    #[test]
251    fn endpoint_replace() {
252        let endpoint = ExpectedUrl::builder()
253            .endpoint("v1/posts/add")
254            .add_query_params(&[("url", URL), ("description", TITLE), ("replace", "yes")])
255            .build()
256            .unwrap();
257        let client = SingleTestClient::new_raw(endpoint, "");
258
259        let endpoint = Add::builder()
260            .url(test_url())
261            .description(TITLE)
262            .replace(true)
263            .build()
264            .unwrap();
265        api::ignore(endpoint).query(&client).unwrap();
266    }
267
268    #[test]
269    fn endpoint_shared() {
270        let endpoint = ExpectedUrl::builder()
271            .endpoint("v1/posts/add")
272            .add_query_params(&[("url", URL), ("description", TITLE), ("shared", "yes")])
273            .build()
274            .unwrap();
275        let client = SingleTestClient::new_raw(endpoint, "");
276
277        let endpoint = Add::builder()
278            .url(test_url())
279            .description(TITLE)
280            .shared(true)
281            .build()
282            .unwrap();
283        api::ignore(endpoint).query(&client).unwrap();
284    }
285
286    #[test]
287    fn endpoint_toread() {
288        let endpoint = ExpectedUrl::builder()
289            .endpoint("v1/posts/add")
290            .add_query_params(&[("url", URL), ("description", TITLE), ("toread", "yes")])
291            .build()
292            .unwrap();
293        let client = SingleTestClient::new_raw(endpoint, "");
294
295        let endpoint = Add::builder()
296            .url(test_url())
297            .description(TITLE)
298            .toread(true)
299            .build()
300            .unwrap();
301        api::ignore(endpoint).query(&client).unwrap();
302    }
303
304    #[test]
305    fn limit() {
306        assert_eq!(Add::secs_between_calls(), 3)
307    }
308}