linkding/
lib.rs

1#[cfg(feature = "ffi")]
2uniffi::setup_scaffolding!();
3
4pub mod bookmark_assets;
5pub mod bookmarks;
6pub mod tags;
7pub mod users;
8
9use bookmark_assets::{BookmarkAsset, ListBookmarkAssetsResponse};
10pub use bookmarks::{
11    Bookmark, CheckUrlResponse, CreateBookmarkBody, ListBookmarksArgs, ListBookmarksResponse,
12    UpdateBookmarkBody,
13};
14use reqwest::{
15    blocking::multipart::Part,
16    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
17    StatusCode,
18};
19pub use tags::{ListTagsArgs, ListTagsResponse, TagData};
20use thiserror::Error;
21pub use users::{DateDisplay, LinkTarget, SelectedTheme, SortBy, TagSearchMethod, UserProfile};
22
23#[derive(Error, Debug)]
24#[cfg_attr(feature = "ffi", derive(uniffi::Error))]
25#[cfg_attr(feature = "ffi", uniffi(flat_error))]
26pub enum LinkDingError {
27    #[error("Error building URL")]
28    ParseUrl(url::ParseError),
29    #[error("Error sending HTTP request")]
30    SendHttpError(#[from] reqwest::Error),
31    #[error("Could not parse response from API")]
32    ParseResponse(#[from] std::io::Error),
33    #[error("Could not serialize JSON body")]
34    JsonSerialize(#[from] serde_json::Error),
35}
36
37#[derive(Debug, Clone)]
38#[cfg_attr(feature = "ffi", derive(uniffi::Enum))]
39pub enum Endpoint {
40    ListBookmarks(ListBookmarksArgs),
41    ListArchivedBookmarks(ListBookmarksArgs),
42    GetBookmark(i32),
43    CheckUrl(String),
44    CreateBookmark,
45    UpdateBookmark(i32),
46    ArchiveBookmark(i32),
47    UnarchiveBookmark(i32),
48    DeleteBookmark(i32),
49    ListTags(ListTagsArgs),
50    GetTag(i32),
51    CreateTag,
52    GetUserProfile,
53    ListBookmarkAssets(i32),
54    RetrieveBookmarkAsset(i32, i32),
55    DownloadBookmarkAsset(i32, i32),
56    UploadBookmarkAsset(i32),
57    DeleteBookmarkAsset(i32, i32),
58}
59
60impl QueryString for Endpoint {
61    fn query_string(&self) -> String {
62        match self {
63            Self::ListBookmarks(args) | Self::ListArchivedBookmarks(args) => args.query_string(),
64            Self::ListTags(args) => args.query_string(),
65            Self::GetBookmark(_)
66            | Self::CheckUrl(_)
67            | Self::CreateBookmark
68            | Self::UpdateBookmark(_)
69            | Self::ArchiveBookmark(_)
70            | Self::UnarchiveBookmark(_)
71            | Self::DeleteBookmark(_)
72            | Self::GetTag(_)
73            | Self::CreateTag
74            | Self::GetUserProfile
75            | Self::ListBookmarkAssets(_)
76            | Self::RetrieveBookmarkAsset(_, _)
77            | Self::DownloadBookmarkAsset(_, _)
78            | Self::UploadBookmarkAsset(_)
79            | Self::DeleteBookmarkAsset(_, _) => "".to_string(),
80        }
81    }
82}
83
84impl From<Endpoint> for String {
85    fn from(val: Endpoint) -> Self {
86        let path = match &val {
87            Endpoint::ListBookmarks(_) => "/api/bookmarks/".to_string(),
88            Endpoint::ListArchivedBookmarks(_) => "/api/bookmarks/archived/".to_string(),
89            Endpoint::GetBookmark(id)
90            | Endpoint::UpdateBookmark(id)
91            | Endpoint::DeleteBookmark(id) => {
92                format!("/api/bookmarks/{}/", &id)
93            }
94            Endpoint::CheckUrl(_) => "/api/bookmarks/check/".to_string(),
95            Endpoint::CreateBookmark => "/api/bookmarks/".to_string(),
96            Endpoint::ArchiveBookmark(id) => format!("/api/bookmarks/{}/archive/", &id),
97            Endpoint::UnarchiveBookmark(id) => format!("/api/bookmarks/{}/unarchive/", &id),
98            Endpoint::ListTags(_) | Endpoint::CreateTag => "/api/tags/".to_string(),
99            Endpoint::GetTag(id) => format!("/api/tags/{}/", &id),
100            Endpoint::GetUserProfile => "/api/user/profile/".to_string(),
101            Endpoint::ListBookmarkAssets(id) => format!("/api/bookmarks/{}/assets/", id),
102            Endpoint::RetrieveBookmarkAsset(bookmark_id, asset_id)
103            | Endpoint::DeleteBookmarkAsset(bookmark_id, asset_id) => {
104                format!("/api/bookmarks/{}/assets/{}/", bookmark_id, asset_id)
105            }
106            Endpoint::DownloadBookmarkAsset(bookmark_id, asset_id) => format!(
107                "/api/bookmarks/{}/assets/{}/download/",
108                bookmark_id, asset_id
109            ),
110            Endpoint::UploadBookmarkAsset(id) => format!("/api/bookmarks/{}/assets/upload/", id),
111        };
112        match &val {
113            Endpoint::ListBookmarks(args) | Endpoint::ListArchivedBookmarks(args) => {
114                let query_string = args.query_string();
115                format!("{}?{}", path, query_string)
116            }
117            Endpoint::ListTags(args) => {
118                let query_string = args.query_string();
119                format!("{}?{}", path, query_string)
120            }
121            Endpoint::CheckUrl(url) => {
122                format!("{}?url={}", path, url)
123            }
124            Endpoint::GetBookmark(_)
125            | Endpoint::UpdateBookmark(_)
126            | Endpoint::ArchiveBookmark(_)
127            | Endpoint::UnarchiveBookmark(_)
128            | Endpoint::DeleteBookmark(_)
129            | Endpoint::CreateBookmark
130            | Endpoint::GetTag(_)
131            | Endpoint::CreateTag
132            | Endpoint::GetUserProfile
133            | Endpoint::ListBookmarkAssets(_)
134            | Endpoint::RetrieveBookmarkAsset(_, _)
135            | Endpoint::DownloadBookmarkAsset(_, _)
136            | Endpoint::UploadBookmarkAsset(_)
137            | Endpoint::DeleteBookmarkAsset(_, _) => path,
138        }
139    }
140}
141
142impl From<Endpoint> for reqwest::Method {
143    fn from(val: Endpoint) -> Self {
144        match val {
145            Endpoint::ListBookmarks(_) => reqwest::Method::GET,
146            Endpoint::ListArchivedBookmarks(_) => reqwest::Method::GET,
147            Endpoint::GetBookmark(_) => reqwest::Method::GET,
148            Endpoint::CheckUrl(_) => reqwest::Method::GET,
149            Endpoint::CreateBookmark => reqwest::Method::POST,
150            Endpoint::UpdateBookmark(_) => reqwest::Method::PATCH,
151            Endpoint::ArchiveBookmark(_) => reqwest::Method::POST,
152            Endpoint::UnarchiveBookmark(_) => reqwest::Method::POST,
153            Endpoint::DeleteBookmark(_) => reqwest::Method::DELETE,
154            Endpoint::ListTags(_) => reqwest::Method::GET,
155            Endpoint::GetTag(_) => reqwest::Method::GET,
156            Endpoint::CreateTag => reqwest::Method::POST,
157            Endpoint::GetUserProfile => reqwest::Method::GET,
158            Endpoint::ListBookmarkAssets(_) => reqwest::Method::GET,
159            Endpoint::RetrieveBookmarkAsset(_, _) => reqwest::Method::GET,
160            Endpoint::DownloadBookmarkAsset(_, _) => reqwest::Method::GET,
161            Endpoint::UploadBookmarkAsset(_) => reqwest::Method::POST,
162            Endpoint::DeleteBookmarkAsset(_, _) => reqwest::Method::DELETE,
163        }
164    }
165}
166
167impl From<Endpoint> for reqwest::header::HeaderMap {
168    fn from(val: Endpoint) -> Self {
169        let mut headers = reqwest::header::HeaderMap::new();
170        match val {
171            Endpoint::ListBookmarks(_)
172            | Endpoint::ListArchivedBookmarks(_)
173            | Endpoint::GetBookmark(_)
174            | Endpoint::CheckUrl(_)
175            | Endpoint::CreateBookmark
176            | Endpoint::UpdateBookmark(_)
177            | Endpoint::ArchiveBookmark(_)
178            | Endpoint::UnarchiveBookmark(_)
179            | Endpoint::DeleteBookmark(_)
180            | Endpoint::ListTags(_)
181            | Endpoint::GetTag(_)
182            | Endpoint::CreateTag
183            | Endpoint::GetUserProfile
184            | Endpoint::ListBookmarkAssets(_)
185            | Endpoint::RetrieveBookmarkAsset(_, _)
186            | Endpoint::UploadBookmarkAsset(_)
187            | Endpoint::DeleteBookmarkAsset(_, _) => {
188                headers.insert(
189                    CONTENT_TYPE,
190                    "application/json"
191                        .parse()
192                        .expect("Could not parse content type header value"),
193                );
194                headers.insert(
195                    ACCEPT,
196                    "application/json"
197                        .parse()
198                        .expect("Could not parse accept header value"),
199                );
200            }
201            Endpoint::DownloadBookmarkAsset(_, _) => {
202                headers.insert(ACCEPT, reqwest::header::HeaderValue::from_static("*/*"));
203            }
204        };
205        headers
206    }
207}
208
209trait QueryString {
210    fn query_string(&self) -> String;
211}
212
213/// A sync client for the LinkDing API.
214///
215/// This client is used to interact with the LinkDing API. It provides methods for
216/// managing bookmarks and tags, the full capability of the LinkDing API.
217///
218/// # Example
219///
220/// ```
221/// use linkding::{LinkDingClient, LinkDingError, CreateBookmarkBody};
222///
223/// fn main() -> Result<(), LinkDingError> {
224///     let client = LinkDingClient::new("https://linkding.local:9090", "YOUR_API_TOKEN");
225///     let new_bookmark = CreateBookmarkBody {
226///         url: "https://example.com".to_string(),
227///         ..Default::default()
228///     };
229///     let bookmark = client.create_bookmark(new_bookmark)?;
230///     println!("Bookmark created: {:?}", bookmark);
231///     client.delete_bookmark(bookmark.id)?;
232///     println!("Bookmark deleted");
233///     Ok(())
234/// }
235/// ```
236#[derive(Debug, Clone)]
237#[cfg_attr(feature = "ffi", derive(uniffi::Object))]
238pub struct LinkDingClient {
239    token: String,
240    url: String,
241    client: reqwest::blocking::Client,
242}
243
244impl LinkDingClient {
245    fn prepare_request(
246        &self,
247        endpoint: Endpoint,
248    ) -> Result<reqwest::blocking::RequestBuilder, LinkDingError> {
249        let base_url: reqwest::Url = self.url.parse().map_err(LinkDingError::ParseUrl)?;
250        let path_and_query: String = endpoint.clone().into();
251        let url = base_url
252            .join(&path_and_query)
253            .map_err(LinkDingError::ParseUrl)?;
254        let method: reqwest::Method = endpoint.clone().into();
255        let mut endpoint_headers: reqwest::header::HeaderMap = endpoint.clone().into();
256        endpoint_headers.insert(
257            AUTHORIZATION,
258            format!("Token {}", &self.token)
259                .parse()
260                .expect("Could not parse authorization header value"),
261        );
262
263        let builder = self.client.request(method, url).headers(endpoint_headers);
264
265        Ok(builder)
266    }
267}
268
269#[cfg_attr(feature = "ffi", uniffi::export)]
270impl LinkDingClient {
271    #[cfg_attr(feature = "ffi", uniffi::constructor)]
272    pub fn new(url: &str, token: &str) -> Self {
273        LinkDingClient {
274            token: token.to_string(),
275            url: url.to_string(),
276            client: reqwest::blocking::Client::builder()
277                .build()
278                .expect("Could not create web client"),
279        }
280    }
281
282    /// List unarchived bookmarks
283    pub fn list_bookmarks(
284        &self,
285        args: ListBookmarksArgs,
286    ) -> Result<ListBookmarksResponse, LinkDingError> {
287        let endpoint = Endpoint::ListBookmarks(args);
288        let request = self.prepare_request(endpoint)?.build()?;
289        let body: ListBookmarksResponse = self.client.execute(request)?.json()?;
290
291        Ok(body)
292    }
293
294    /// List archived bookmarks
295    pub fn list_archived_bookmarks(
296        &self,
297        args: ListBookmarksArgs,
298    ) -> Result<ListBookmarksResponse, LinkDingError> {
299        let endpoint = Endpoint::ListArchivedBookmarks(args);
300        let request = self.prepare_request(endpoint)?.build()?;
301        let body: ListBookmarksResponse = self.client.execute(request)?.json()?;
302        Ok(body)
303    }
304
305    /// Get a bookmark by ID
306    pub fn get_bookmark(&self, id: i32) -> Result<Bookmark, LinkDingError> {
307        let endpoint = Endpoint::GetBookmark(id);
308        let request = self.prepare_request(endpoint)?.build()?;
309        let body: Bookmark = self.client.execute(request)?.json()?;
310        Ok(body)
311    }
312
313    /// Check if a URL has been bookmarked
314    ///
315    /// If the URL has already been bookmarked this will return the bookmark
316    /// data, otherwise the bookmark data will be `None`. The metadata of the
317    /// webpage will always be returned.
318    pub fn check_url(&self, url: &str) -> Result<CheckUrlResponse, LinkDingError> {
319        let endpoint = Endpoint::CheckUrl(url.to_string());
320        let request = self.prepare_request(endpoint)?.build()?;
321        let body: CheckUrlResponse = self.client.execute(request)?.json()?;
322        Ok(body)
323    }
324
325    /// Create a bookmark
326    ///
327    /// If the bookmark already exists, it will be updated with the new data passed in the `body` parameter.
328    pub fn create_bookmark(&self, body: CreateBookmarkBody) -> Result<Bookmark, LinkDingError> {
329        let endpoint = Endpoint::CreateBookmark;
330        let request = self
331            .prepare_request(endpoint)?
332            .body(serde_json::to_string(&body)?)
333            .build()?;
334        let body: Bookmark = self.client.execute(request)?.json()?;
335        Ok(body)
336    }
337
338    /// Update a bookmark
339    ///
340    /// Pass only the fields you want to update in the `body` parameter.
341    pub fn update_bookmark(
342        &self,
343        id: i32,
344        body: UpdateBookmarkBody,
345    ) -> Result<Bookmark, LinkDingError> {
346        let endpoint = Endpoint::UpdateBookmark(id);
347        let request = self
348            .prepare_request(endpoint)?
349            .body(serde_json::to_string(&body)?)
350            .build()?;
351        let body: Bookmark = self.client.execute(request)?.json()?;
352        Ok(body)
353    }
354
355    /// Archive a bookmark
356    pub fn archive_bookmark(&self, id: i32) -> Result<bool, LinkDingError> {
357        let endpoint = Endpoint::ArchiveBookmark(id);
358        let request = self.prepare_request(endpoint)?.build()?;
359        let response = self.client.execute(request)?;
360
361        Ok(response.status() == StatusCode::NO_CONTENT)
362    }
363
364    /// Take a bookmark out of the archive
365    pub fn unarchive_bookmark(&self, id: i32) -> Result<bool, LinkDingError> {
366        let endpoint = Endpoint::UnarchiveBookmark(id);
367        let request = self.prepare_request(endpoint)?.build()?;
368        let response = self.client.execute(request)?;
369        Ok(response.status() == StatusCode::NO_CONTENT)
370    }
371
372    /// Delete a bookmark
373    pub fn delete_bookmark(&self, id: i32) -> Result<bool, LinkDingError> {
374        let endpoint = Endpoint::DeleteBookmark(id);
375        let request = self.prepare_request(endpoint)?.build()?;
376        let response = self.client.execute(request)?;
377        Ok(response.status() == StatusCode::NO_CONTENT)
378    }
379
380    /// List tags
381    pub fn list_tags(&self, args: ListTagsArgs) -> Result<ListTagsResponse, LinkDingError> {
382        let endpoint = Endpoint::ListTags(args);
383        let request = self.prepare_request(endpoint)?.build()?;
384        let body: ListTagsResponse = self.client.execute(request)?.json()?;
385        Ok(body)
386    }
387
388    /// Get a tag by ID
389    pub fn get_tag(&self, id: i32) -> Result<TagData, LinkDingError> {
390        let endpoint = Endpoint::GetTag(id);
391        let request = self.prepare_request(endpoint)?.build()?;
392        let body: TagData = self.client.execute(request)?.json()?;
393        Ok(body)
394    }
395
396    /// Create a tag
397    pub fn create_tag(&self, name: &str) -> Result<TagData, LinkDingError> {
398        let endpoint = Endpoint::CreateTag;
399        let body = serde_json::json!({ "name": name });
400        let request = self
401            .prepare_request(endpoint)?
402            .body(serde_json::to_string(&body)?)
403            .build()?;
404        let body: TagData = self.client.execute(request)?.json()?;
405        Ok(body)
406    }
407
408    /// Get the user's profile
409    pub fn get_user_profile(&self) -> Result<UserProfile, LinkDingError> {
410        let endpoint = Endpoint::GetUserProfile;
411        let request = self.prepare_request(endpoint)?.build()?;
412        let body: UserProfile = self.client.execute(request)?.json()?;
413        Ok(body)
414    }
415
416    /// Lists a bookmarks' assets
417    pub fn list_bookmark_assets(
418        &self,
419        id: i32,
420    ) -> Result<ListBookmarkAssetsResponse, LinkDingError> {
421        let endpoint = Endpoint::ListBookmarkAssets(id);
422        let request = self.prepare_request(endpoint)?.build()?;
423        let body: ListBookmarkAssetsResponse = self.client.execute(request)?.json()?;
424        Ok(body)
425    }
426
427    /// Retrieve info for a single asset of a bookmark
428    pub fn retrieve_bookmark_asset(
429        &self,
430        bookmark_id: i32,
431        asset_id: i32,
432    ) -> Result<BookmarkAsset, LinkDingError> {
433        let endpoint = Endpoint::RetrieveBookmarkAsset(bookmark_id, asset_id);
434        let request = self.prepare_request(endpoint)?.build()?;
435        let body: BookmarkAsset = self.client.execute(request)?.json()?;
436        Ok(body)
437    }
438
439    /// Download a bookmark's asset
440    pub fn download_bookmark_asset(
441        &self,
442        bookmark_id: i32,
443        asset_id: i32,
444    ) -> Result<Vec<u8>, LinkDingError> {
445        let endpoint = Endpoint::DownloadBookmarkAsset(bookmark_id, asset_id);
446        let request = self.prepare_request(endpoint)?.build()?;
447        let response = self.client.execute(request)?;
448        Ok(response.bytes()?.into())
449    }
450
451    /// Upload an asset for a bookmark
452    pub fn upload_bookmark_asset(
453        &self,
454        bookmark_id: i32,
455        bytes: &[u8],
456    ) -> Result<BookmarkAsset, LinkDingError> {
457        let endpoint = Endpoint::UploadBookmarkAsset(bookmark_id);
458        let bytes_part = Part::bytes(bytes.to_owned());
459        let form = reqwest::blocking::multipart::Form::new().part("file", bytes_part);
460        let request = self.prepare_request(endpoint)?.multipart(form).build()?;
461        let body: BookmarkAsset = self.client.execute(request)?.json()?;
462        Ok(body)
463    }
464
465    /// Delete a bookmark's asset
466    pub fn delete_bookmark_asset(
467        &self,
468        bookmark_id: i32,
469        asset_id: i32,
470    ) -> Result<bool, LinkDingError> {
471        let endpoint = Endpoint::DeleteBookmarkAsset(bookmark_id, asset_id);
472        let request = self.prepare_request(endpoint)?.build()?;
473        let response = self.client.execute(request)?;
474        Ok(response.status() == StatusCode::NO_CONTENT)
475    }
476}