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(¶ms);
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(¶ms);
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(¶ms);
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}