pocketbase_rs/
lib.rs

1//! `pocketbase-rs` is a Rust wrapper around `PocketBase`'s REST API.
2//!
3//! # Usage
4//!
5//! ```rust,ignore
6//! use std::error::Error;
7//!
8//! use pocketbase_rs::{PocketBase, Collection, RequestError};
9//! use serde::Deserialize;
10//!
11//! #[derive(Default, Deserialize, Clone)]
12//! struct Article {
13//!     title: String,
14//!     content: String,
15//! }
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<dyn Error>> {
19//!     let mut pb = PocketBase::new("http://localhost:8090");
20//!
21//!     let auth_data = pb
22//!         .collection("users")
23//!         .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
24//!         .await?;
25//!
26//!     let article: Article = pb
27//!         .collection("articles")
28//!         .get_one::<Article>("record_id_123")
29//!         .call()
30//!         .await?;
31//!
32//!     println!("Article Title: {}", article.title);
33//!
34//!     Ok(())
35//! }
36//! ```
37
38#![deny(missing_docs)]
39#![warn(clippy::nursery)]
40#![warn(clippy::pedantic)]
41#![allow(clippy::missing_errors_doc)]
42#![allow(clippy::module_name_repetitions)]
43#![allow(dead_code)]
44
45pub use error::*;
46pub use records::auth::{AuthStore, AuthStoreRecord};
47use reqwest::RequestBuilder;
48pub use reqwest::multipart::{Form, Part};
49use serde::{Deserialize, Serialize};
50
51pub mod error;
52pub(crate) mod records;
53
54/// Represents a specific collection in a `PocketBase` database.
55///
56/// The `Collection` struct provides an interface for interacting with a specific collection
57/// within a `PocketBase` instance. Instances of this struct are created using the
58/// [`PocketBase::collection`] method. All operations on the target collection, such as retrieving,
59/// creating, updating, or deleting records, are accessible through methods implemented on
60/// this struct.
61///
62/// # Fields
63/// - `client`: A mutable reference to the `PocketBase` client instance.
64///   This allows the `Collection` to send requests to `PocketBase`.
65/// - `name`: The name of the collection being interacted with.
66pub struct Collection<'a> {
67    pub(crate) client: &'a mut PocketBase,
68    pub(crate) name: &'a str,
69}
70
71impl PocketBase {
72    /// Creates a new [`Collection`] instance for the specified collection name.
73    ///
74    /// This method provides access to operations related to a specific collection in `PocketBase`.
75    /// Most interactions with the `PocketBase` API are performed through the [`Collection`] instance returned
76    /// by this method.
77    ///
78    /// # Arguments
79    /// * `collection_name` - The name of the collection to interact with, provided as a static string.
80    ///
81    /// # Returns
82    /// A [`Collection`] instance configured for the specified collection.
83    ///
84    /// # Example
85    /// ```rust,ignore
86    /// let mut pb = PocketBase::new("http://localhost:8090");
87    ///
88    /// pb.collection("users")
89    ///     .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
90    ///     .await?;
91    ///
92    /// let article = pb
93    ///     .collection("articles")
94    ///     .get_first_list_item::<Article>()
95    ///     .filter("language='en'")
96    ///     .call()
97    ///     .await?;
98    /// ```
99    ///
100    /// # Panics
101    ///
102    /// This method will panic if the collection name is empty or contains invalid characters.
103    pub fn collection(&mut self, collection_name: &'static str) -> Collection {
104        // Validate collection name
105        assert!(
106            !collection_name.is_empty(),
107            "Collection name cannot be empty"
108        );
109
110        // Collection names should only contain alphanumeric characters and underscores
111        assert!(
112            collection_name
113                .chars()
114                .all(|c| c.is_alphanumeric() || c == '_'),
115            "Collection name contains invalid characters. Only alphanumeric characters and underscores are allowed"
116        );
117
118        Collection {
119            client: self,
120            name: collection_name,
121        }
122    }
123}
124
125/// Represents a paginated list of records retrieved from a `PocketBase` collection.
126///
127/// The `RecordList` struct encapsulates the results of a paginated query to a collection.
128/// It contains metadata about the pagination state (such as the current page, total items,
129/// and total pages) as well as the records themselves.
130///
131/// This struct is typically returned by methods that fetch a list of records from a
132/// collection, such as [`Collection::get_list`].
133///
134/// # Type Parameters
135/// - `T`: The type of the records contained in the `items` list. This is typically a
136///   deserialized struct that matches the schema of the records in the collection.
137///
138/// # Fields
139/// - `page`: The current page number (starting from 1).
140/// - `per_page`: The maximum number of records returned per page (default is 30).
141/// - `total_items`: The total number of records in the collection that match the query.
142/// - `total_pages`: The total number of pages available for the query.
143/// - `items`: A vector containing the records for the current page.
144#[derive(Debug, Clone, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct RecordList<T> {
147    /// The page (aka. offset) of the paginated list *(default to 1)*.
148    pub page: i32,
149    /// The max returned records per page *(default to 30)*.
150    pub per_page: i32,
151    /// The total amount of records found in the collection.
152    pub total_items: i32,
153    /// The total amount of pages found in the collection.
154    pub total_pages: i32,
155    /// A list of all records for the given page.
156    pub items: Vec<T>,
157}
158
159/// Response structure for API errors from `PocketBase`.
160#[derive(Deserialize, Debug)]
161pub(crate) struct ErrorResponse {
162    /// HTTP status code
163    pub code: u16,
164    /// Error message from the server
165    pub message: String,
166    /// Additional error data, if any
167    pub data: Option<serde_json::Value>,
168}
169
170/// A `PocketBase` client for sending requests to a `PocketBase` instance.
171///
172/// The `Debug` implementation for this struct redacts sensitive authentication data
173/// to prevent accidental exposure in logs.
174///
175/// # Example
176/// ```rust,ignore
177/// use std::error::Error;
178/// use pocketbase_rs::PocketBase;
179/// use serde::Deserialize;
180///
181/// #[derive(Deserialize)]
182/// struct Article {
183///     id: String,
184///     title: String,
185/// }
186///
187/// #[tokio::main]
188/// async fn main() -> Result<(), Box<dyn Error>> {
189///     let mut pb = PocketBase::new("http://localhost:8090");
190///
191///     pb.collection("users")
192///         .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
193///         .await?;
194///
195///     let article = pb
196///         .collection("articles")
197///         .get_one::<Article>("record_id")
198///         .call()
199///         .await?;
200///
201///     println!("Article: {:?}", article);
202///
203///     Ok(())
204/// }
205/// ```
206#[derive(Clone)]
207pub struct PocketBase {
208    pub(crate) base_url: String,
209    pub(crate) auth_store: Option<AuthStore>,
210    pub(crate) reqwest_client: reqwest::Client,
211}
212
213impl std::fmt::Debug for PocketBase {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("PocketBase")
216            .field("base_url", &self.base_url)
217            .field(
218                "auth_store",
219                &self.auth_store.as_ref().map(|_| "***REDACTED***"),
220            )
221            .field("reqwest_client", &"Client")
222            .finish()
223    }
224}
225
226impl PocketBase {
227    /// Creates a new instance of the `PocketBase` client.
228    ///
229    /// # Example
230    /// ```rust
231    /// let pb = PocketBase::new("http://localhost:8090");
232    /// // Use the client for further operations like authentication or fetching records
233    /// ```
234    /// # Panics
235    ///
236    /// This method will panic if the provided `base_url` is not a valid URL.
237    #[must_use]
238    pub fn new(base_url: &str) -> Self {
239        // Validate URL format
240        let trimmed_url = base_url.trim_end_matches('/');
241        assert!(
242            trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://"),
243            "Invalid base_url: must start with http:// or https://"
244        );
245
246        // Create client with sensible defaults
247        let client = reqwest::Client::builder()
248            .timeout(std::time::Duration::from_secs(30))
249            .connect_timeout(std::time::Duration::from_secs(10))
250            .build()
251            .expect("Failed to create HTTP client");
252
253        Self {
254            base_url: trimmed_url.to_string(),
255            auth_store: None,
256            reqwest_client: client,
257        }
258    }
259
260    /// Creates a new `PocketBase` client with a custom reqwest client.
261    ///
262    /// # Example
263    /// ```rust
264    /// use std::time::Duration;
265    ///
266    /// let reqwest_client = reqwest::Client::builder()
267    ///     .timeout(Duration::from_secs(60))
268    ///     .build()
269    ///     .expect("Failed to build client");
270    ///
271    /// let pb = PocketBase::new_with_client("http://localhost:8090", reqwest_client);
272    /// ```
273    ///
274    /// # Panics
275    ///
276    /// This method will panic if the provided `base_url` is not a valid URL.
277    #[must_use]
278    pub fn new_with_client(base_url: &str, client: reqwest::Client) -> Self {
279        // Validate URL format
280        let trimmed_url = base_url.trim_end_matches('/');
281        assert!(
282            trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://"),
283            "Invalid base_url: must start with http:// or https://"
284        );
285
286        Self {
287            base_url: trimmed_url.to_string(),
288            auth_store: None,
289            reqwest_client: client,
290        }
291    }
292
293    /// Retrieves the current auth store, if available.
294    ///
295    /// # Example
296    /// ```rust,ignore
297    /// let pb = PocketBase::new("http://localhost:8090");
298    ///
299    /// // ...
300    ///
301    /// if let Some(auth_store) = pb.auth_store() {
302    ///     println!("Authenticated with token: {}", auth_store.token);
303    /// } else {
304    ///     println!("Not authenticated");
305    /// }
306    /// ```
307    #[must_use]
308    pub fn auth_store(&self) -> Option<AuthStore> {
309        self.auth_store.clone()
310    }
311
312    /// Retrieves the current authentication token, if available.
313    ///
314    /// # Example
315    /// ```rust,ignore
316    /// let pb = PocketBase::new("http://localhost:8090");
317    ///
318    /// // ...
319    ///
320    /// if let Some(token) = pb.token() {
321    ///     println!("Authenticated with token: {}", token);
322    /// } else {
323    ///     println!("Not authenticated");
324    /// }
325    /// ```
326    #[must_use]
327    pub fn token(&self) -> Option<String> {
328        self.auth_store
329            .as_ref()
330            .map(|auth_store| auth_store.token.clone())
331    }
332
333    /// Returns the base URL of the `PocketBase` server.
334    ///
335    /// # Example
336    /// ```rust,ignore
337    /// let pb = PocketBase::new("http://localhost:8090");
338    /// assert_eq!(pb.base_url(), "http://localhost:8090".to_string());
339    /// ```
340    #[must_use]
341    pub fn base_url(&self) -> String {
342        self.base_url.clone()
343    }
344
345    pub(crate) fn update_auth_store(&mut self, new_auth_store: AuthStore) {
346        self.auth_store = Some(new_auth_store);
347    }
348}
349
350impl PocketBase {
351    /// Adds an authorization token to the request, if available.
352    ///
353    /// This method attaches a bearer authentication token to the provided `RequestBuilder`
354    /// if the client is currently authenticated. If no token is available, the request is
355    /// returned unchanged.
356    ///
357    /// # Arguments
358    /// * `request_builder` - A `reqwest::RequestBuilder` to which the token will be added.
359    ///
360    /// # Returns
361    /// A `reqwest::RequestBuilder` with the authorization token, if applicable.
362    pub(crate) fn with_authorization_token(
363        &self,
364        request_builder: reqwest::RequestBuilder,
365    ) -> reqwest::RequestBuilder {
366        if let Some(auth_store) = self.auth_store() {
367            request_builder.bearer_auth(auth_store.token)
368        } else {
369            request_builder
370        }
371    }
372
373    /// Creates a POST request builder for the specified endpoint.
374    ///
375    /// This method initializes a `POST` request to the given endpoint and adds
376    /// an authorization token if available.
377    ///
378    /// # Arguments
379    /// * `endpoint` - The API endpoint to send the `POST` request to.
380    ///
381    /// # Returns
382    /// A `reqwest::RequestBuilder` for the `POST` request.
383    pub(crate) fn request_post(&self, endpoint: &str) -> RequestBuilder {
384        let request_builder = self.reqwest_client.post(endpoint);
385        self.with_authorization_token(request_builder)
386    }
387
388    /// Creates a PATCH request builder with JSON body for the specified endpoint.
389    ///
390    /// This method initializes a `PATCH` request to the given endpoint with a JSON body,
391    /// and adds an authorization token if available.
392    ///
393    /// # Arguments
394    /// * `endpoint` - The API endpoint to send the `PATCH` request to.
395    /// * `params` - A reference to a serializable type to use as the JSON body of the request.
396    ///
397    /// # Returns
398    /// A `reqwest::RequestBuilder` for the `PATCH` request.
399    pub(crate) fn request_patch_json<T: Default + Serialize + Clone + Send>(
400        &self,
401        endpoint: &str,
402        params: &T,
403    ) -> RequestBuilder {
404        let request_builder = self.reqwest_client.patch(endpoint).json(&params);
405        self.with_authorization_token(request_builder)
406    }
407
408    /// Creates a POST request builder with JSON body for the specified endpoint.
409    ///
410    /// This method initializes a `POST` request to the given endpoint with a JSON body,
411    /// and adds an authorization token if available.
412    ///
413    /// # Arguments
414    /// * `endpoint` - The API endpoint to send the `POST` request to.
415    /// * `params` - A reference to a serializable type to use as the JSON body of the request.
416    ///
417    /// # Returns
418    /// A `reqwest::RequestBuilder` for the `POST` request.
419    pub(crate) fn request_post_json<T: Default + Serialize + Clone + Send>(
420        &self,
421        endpoint: &str,
422        params: &T,
423    ) -> RequestBuilder {
424        let request_builder = self.reqwest_client.post(endpoint).json(&params);
425        self.with_authorization_token(request_builder)
426    }
427
428    /// Creates a POST request builder with a form body for the specified endpoint.
429    ///
430    /// This method initializes a `POST` request to the given endpoint with a multipart form body,
431    /// and adds an authorization token if available.
432    ///
433    /// # Arguments
434    /// * `endpoint` - The API endpoint to send the `POST` request to.
435    /// * `form` - A `reqwest::multipart::Form` representing the form data for the request.
436    ///
437    /// # Returns
438    /// A `reqwest::RequestBuilder` for the `POST` request.
439    pub(crate) fn request_post_form(&self, endpoint: &str, form: Form) -> RequestBuilder {
440        let request_builder = self.reqwest_client.post(endpoint).multipart(form);
441        self.with_authorization_token(request_builder)
442    }
443
444    /// Creates a GET request builder for the specified endpoint.
445    ///
446    /// This method initializes a `GET` request to the given endpoint, adds an `Accept` header
447    /// for JSON responses, attaches query parameters if provided, and adds an authorization
448    /// token if available.
449    ///
450    /// # Arguments
451    /// * `endpoint` - The API endpoint to send the `GET` request to.
452    /// * `params` - An optional vector of key-value pairs to include as query parameters.
453    ///
454    /// # Returns
455    /// A `reqwest::RequestBuilder` for the `GET` request.
456    pub(crate) fn request_get(
457        &self,
458        endpoint: &str,
459        params: Option<Vec<(&str, &str)>>,
460    ) -> RequestBuilder {
461        let mut request_builder = self
462            .reqwest_client
463            .get(endpoint)
464            .header("Accept", "application/json");
465
466        if let Some(params) = params {
467            request_builder = request_builder.query(&params);
468        }
469
470        self.with_authorization_token(request_builder)
471    }
472
473    /// Creates a DELETE request builder for the specified endpoint.
474    ///
475    /// This method initializes a `DELETE` request to the given endpoint and adds
476    /// an authorization token if available.
477    ///
478    /// # Arguments
479    /// * `endpoint` - The API endpoint to send the `DELETE` request to.
480    ///
481    /// # Returns
482    /// A `reqwest::RequestBuilder` for the `DELETE` request.
483    ///
484    /// # Example
485    /// ```rust,ignore
486    /// let pb = PocketBase::new("http://localhost:8090");
487    ///
488    /// let request = pb.request_delete("http://localhost:8090/api/collections/articles/record_id");
489    /// ```
490    pub(crate) fn request_delete(&self, endpoint: &str) -> RequestBuilder {
491        let request_builder = self.reqwest_client.delete(endpoint);
492
493        self.with_authorization_token(request_builder)
494    }
495}