···11+# Rust specific ignores
22+/target
33+/Cargo.lock
44+55+# Ignore Mac system files
66+.DS_Store
+22
Cargo.toml
···11+[package]
22+name = "pocketbase-rs"
33+version = "0.1.2"
44+edition = "2024"
55+license = "MIT OR Apache-2.0"
66+authors = ["Klaus <klaus@fromhorizons.com>"]
77+repository = "https://github.com/fromhorizons/pocketbase-rs"
88+description = "A simple wrapper around PocketBase's Rest API. Uses the Builder Pattern."
99+readme = "README.md"
1010+keywords = ["pocketbase"]
1111+1212+[dependencies]
1313+chrono = { version = "0.4.38", features = ["serde"] }
1414+reqwest = { version = "0.12.9", features = ["cookies", "json", "multipart"] }
1515+serde = { version = "1.0.214", features = ["derive"] }
1616+serde_json = "1.0.132"
1717+thiserror = "2.0.3"
1818+time = { version = "0.3.36", features = ["serde"] }
1919+2020+[dev-dependencies]
2121+httpmock = "0.7.0"
2222+tokio = { version = "1.41.1", features = ["full"] }
+176
LICENSE-APACHE
···11+ Apache License
22+ Version 2.0, January 2004
33+ http://www.apache.org/licenses/
44+55+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
66+77+ 1. Definitions.
88+99+ "License" shall mean the terms and conditions for use, reproduction,
1010+ and distribution as defined by Sections 1 through 9 of this document.
1111+1212+ "Licensor" shall mean the copyright owner or entity authorized by
1313+ the copyright owner that is granting the License.
1414+1515+ "Legal Entity" shall mean the union of the acting entity and all
1616+ other entities that control, are controlled by, or are under common
1717+ control with that entity. For the purposes of this definition,
1818+ "control" means (i) the power, direct or indirect, to cause the
1919+ direction or management of such entity, whether by contract or
2020+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
2121+ outstanding shares, or (iii) beneficial ownership of such entity.
2222+2323+ "You" (or "Your") shall mean an individual or Legal Entity
2424+ exercising permissions granted by this License.
2525+2626+ "Source" form shall mean the preferred form for making modifications,
2727+ including but not limited to software source code, documentation
2828+ source, and configuration files.
2929+3030+ "Object" form shall mean any form resulting from mechanical
3131+ transformation or translation of a Source form, including but
3232+ not limited to compiled object code, generated documentation,
3333+ and conversions to other media types.
3434+3535+ "Work" shall mean the work of authorship, whether in Source or
3636+ Object form, made available under the License, as indicated by a
3737+ copyright notice that is included in or attached to the work
3838+ (an example is provided in the Appendix below).
3939+4040+ "Derivative Works" shall mean any work, whether in Source or Object
4141+ form, that is based on (or derived from) the Work and for which the
4242+ editorial revisions, annotations, elaborations, or other modifications
4343+ represent, as a whole, an original work of authorship. For the purposes
4444+ of this License, Derivative Works shall not include works that remain
4545+ separable from, or merely link (or bind by name) to the interfaces of,
4646+ the Work and Derivative Works thereof.
4747+4848+ "Contribution" shall mean any work of authorship, including
4949+ the original version of the Work and any modifications or additions
5050+ to that Work or Derivative Works thereof, that is intentionally
5151+ submitted to Licensor for inclusion in the Work by the copyright owner
5252+ or by an individual or Legal Entity authorized to submit on behalf of
5353+ the copyright owner. For the purposes of this definition, "submitted"
5454+ means any form of electronic, verbal, or written communication sent
5555+ to the Licensor or its representatives, including but not limited to
5656+ communication on electronic mailing lists, source code control systems,
5757+ and issue tracking systems that are managed by, or on behalf of, the
5858+ Licensor for the purpose of discussing and improving the Work, but
5959+ excluding communication that is conspicuously marked or otherwise
6060+ designated in writing by the copyright owner as "Not a Contribution."
6161+6262+ "Contributor" shall mean Licensor and any individual or Legal Entity
6363+ on behalf of whom a Contribution has been received by Licensor and
6464+ subsequently incorporated within the Work.
6565+6666+ 2. Grant of Copyright License. Subject to the terms and conditions of
6767+ this License, each Contributor hereby grants to You a perpetual,
6868+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
6969+ copyright license to reproduce, prepare Derivative Works of,
7070+ publicly display, publicly perform, sublicense, and distribute the
7171+ Work and such Derivative Works in Source or Object form.
7272+7373+ 3. Grant of Patent License. Subject to the terms and conditions of
7474+ this License, each Contributor hereby grants to You a perpetual,
7575+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
7676+ (except as stated in this section) patent license to make, have made,
7777+ use, offer to sell, sell, import, and otherwise transfer the Work,
7878+ where such license applies only to those patent claims licensable
7979+ by such Contributor that are necessarily infringed by their
8080+ Contribution(s) alone or by combination of their Contribution(s)
8181+ with the Work to which such Contribution(s) was submitted. If You
8282+ institute patent litigation against any entity (including a
8383+ cross-claim or counterclaim in a lawsuit) alleging that the Work
8484+ or a Contribution incorporated within the Work constitutes direct
8585+ or contributory patent infringement, then any patent licenses
8686+ granted to You under this License for that Work shall terminate
8787+ as of the date such litigation is filed.
8888+8989+ 4. Redistribution. You may reproduce and distribute copies of the
9090+ Work or Derivative Works thereof in any medium, with or without
9191+ modifications, and in Source or Object form, provided that You
9292+ meet the following conditions:
9393+9494+ (a) You must give any other recipients of the Work or
9595+ Derivative Works a copy of this License; and
9696+9797+ (b) You must cause any modified files to carry prominent notices
9898+ stating that You changed the files; and
9999+100100+ (c) You must retain, in the Source form of any Derivative Works
101101+ that You distribute, all copyright, patent, trademark, and
102102+ attribution notices from the Source form of the Work,
103103+ excluding those notices that do not pertain to any part of
104104+ the Derivative Works; and
105105+106106+ (d) If the Work includes a "NOTICE" text file as part of its
107107+ distribution, then any Derivative Works that You distribute must
108108+ include a readable copy of the attribution notices contained
109109+ within such NOTICE file, excluding those notices that do not
110110+ pertain to any part of the Derivative Works, in at least one
111111+ of the following places: within a NOTICE text file distributed
112112+ as part of the Derivative Works; within the Source form or
113113+ documentation, if provided along with the Derivative Works; or,
114114+ within a display generated by the Derivative Works, if and
115115+ wherever such third-party notices normally appear. The contents
116116+ of the NOTICE file are for informational purposes only and
117117+ do not modify the License. You may add Your own attribution
118118+ notices within Derivative Works that You distribute, alongside
119119+ or as an addendum to the NOTICE text from the Work, provided
120120+ that such additional attribution notices cannot be construed
121121+ as modifying the License.
122122+123123+ You may add Your own copyright statement to Your modifications and
124124+ may provide additional or different license terms and conditions
125125+ for use, reproduction, or distribution of Your modifications, or
126126+ for any such Derivative Works as a whole, provided Your use,
127127+ reproduction, and distribution of the Work otherwise complies with
128128+ the conditions stated in this License.
129129+130130+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131131+ any Contribution intentionally submitted for inclusion in the Work
132132+ by You to the Licensor shall be under the terms and conditions of
133133+ this License, without any additional terms or conditions.
134134+ Notwithstanding the above, nothing herein shall supersede or modify
135135+ the terms of any separate license agreement you may have executed
136136+ with Licensor regarding such Contributions.
137137+138138+ 6. Trademarks. This License does not grant permission to use the trade
139139+ names, trademarks, service marks, or product names of the Licensor,
140140+ except as required for reasonable and customary use in describing the
141141+ origin of the Work and reproducing the content of the NOTICE file.
142142+143143+ 7. Disclaimer of Warranty. Unless required by applicable law or
144144+ agreed to in writing, Licensor provides the Work (and each
145145+ Contributor provides its Contributions) on an "AS IS" BASIS,
146146+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147147+ implied, including, without limitation, any warranties or conditions
148148+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149149+ PARTICULAR PURPOSE. You are solely responsible for determining the
150150+ appropriateness of using or redistributing the Work and assume any
151151+ risks associated with Your exercise of permissions under this License.
152152+153153+ 8. Limitation of Liability. In no event and under no legal theory,
154154+ whether in tort (including negligence), contract, or otherwise,
155155+ unless required by applicable law (such as deliberate and grossly
156156+ negligent acts) or agreed to in writing, shall any Contributor be
157157+ liable to You for damages, including any direct, indirect, special,
158158+ incidental, or consequential damages of any character arising as a
159159+ result of this License or out of the use or inability to use the
160160+ Work (including but not limited to damages for loss of goodwill,
161161+ work stoppage, computer failure or malfunction, or any and all
162162+ other commercial damages or losses), even if such Contributor
163163+ has been advised of the possibility of such damages.
164164+165165+ 9. Accepting Warranty or Additional Liability. While redistributing
166166+ the Work or Derivative Works thereof, You may choose to offer,
167167+ and charge a fee for, acceptance of support, warranty, indemnity,
168168+ or other liability obligations and/or rights consistent with this
169169+ License. However, in accepting such obligations, You may act only
170170+ on Your own behalf and on Your sole responsibility, not on behalf
171171+ of any other Contributor, and only if You agree to indemnify,
172172+ defend, and hold each Contributor harmless for any liability
173173+ incurred by, or claims asserted against, such Contributor by reason
174174+ of your accepting any such warranty or additional liability.
175175+176176+ END OF TERMS AND CONDITIONS
+19
LICENSE-MIT
···11+MIT License
22+33+Permission is hereby granted, free of charge, to any person obtaining a copy
44+of this software and associated documentation files (the "Software"), to deal
55+in the Software without restriction, including without limitation the rights
66+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
77+copies of the Software, and to permit persons to whom the Software is
88+furnished to do so, subject to the following conditions:
99+1010+The above copyright notice and this permission notice shall be included in all
1111+copies or substantial portions of the Software.
1212+1313+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1414+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1515+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1616+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1717+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1818+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1919+SOFTWARE.
+78
README.md
···11+# pocketbase-rs
22+33+A Rust wrapper around [PocketBase Rest API](https://pocketbase.io/)'s REST API.
44+55+## Usage
66+77+Most of the methods in this SDK are named and organized as closely as possible to the official PocketBase SDK. Using this Rust crate is generally similar.
88+99+```rust
1010+use std::error::Error;
1111+1212+use pocketbase_rs::PocketBase;
1313+use serde::{Deserialize, Serialize};
1414+1515+#[derive(Default, Serialize, Deserialize, Clone, Debug)]
1616+pub struct Article {
1717+ name: String,
1818+ content: String,
1919+}
2020+2121+#[tokio::main]
2222+async fn main() -> Result<(), Error> {
2323+ let mut pb = PocketBase::new("http://localhost:8090");
2424+2525+ // Authenticate the new client
2626+ let auth_data = pb
2727+ .collection("users")
2828+ .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
2929+ .await?;
3030+3131+ // Create new record
3232+ let new_record = pb
3333+ .collection("articles")
3434+ .create::<Article>(Article {
3535+ name: "Vulpes Vulpes".to_string(),
3636+ content: "The red fox (Vulpes vulpes) is the largest of the true foxes and one of the most widely distributed members. [source: Wikipedia, the free encyclopedia]".to_string(),
3737+ })
3838+ .await?;
3939+4040+ println!("Created article: {:?}", new_record);
4141+4242+ // Get records list
4343+4444+ let records = pb
4545+ .collection("articles")
4646+ .get_list::<Article>()
4747+ .sort("-created,id")
4848+ .call()
4949+ .await?;
5050+5151+ for record in records.items {
5252+ println!("{record:?}");
5353+ }
5454+5555+ Ok(())
5656+}
5757+```
5858+5959+## Note
6060+6161+Not all SDK features are implemented yet and are generally added when needed for other projects.
6262+PRs aimed at adding these missing features, as well as other additions and fixes, are more than welcome.
6363+6464+This crate was last tested on `PocketBase` version `0.34.2`.
6565+6666+## Licence
6767+6868+This project is free and open source. All code in this repository is dual-licensed under either:
6969+7070+- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
7171+ <http://www.apache.org/licenses/LICENSE-2.0>)
7272+- MIT license ([LICENSE-MIT](LICENSE-MIT) or
7373+ <http://opensource.org/licenses/MIT>)
7474+7575+at your option.
7676+7777+Unless you explicitly state otherwise, any contribution intentionally submitted
7878+for inclusion in Rust by Example by you, as defined in the Apache-2.0 license, shall be dually licensed as above, without any additional terms or conditions.
+105
src/error.rs
···11+//! Various errors module.
22+33+use core::fmt;
44+use std::collections::HashMap;
55+66+use serde::Deserialize;
77+use thiserror::Error;
88+99+pub use crate::records::auth::auth_with_password::AuthenticationError;
1010+pub use crate::records::auth::impersonate::ImpersonateError;
1111+pub use crate::records::crud::create::CreateError;
1212+pub use crate::records::crud::update::UpdateError;
1313+1414+/// This error represents the error returned by the `PocketBase`
1515+/// instance in case of a 400 error.
1616+#[derive(Deserialize, Debug)]
1717+pub struct BadRequestResponse {
1818+ /// HTTP Status Code.
1919+ pub status: u16,
2020+ /// Description from given by `PocketBase` about why the error happened.
2121+ pub message: String,
2222+ /// A list of fields that caused the error.
2323+ pub data: HashMap<String, BadRequestField>,
2424+}
2525+2626+/// Represents an instance of one of the errors that could be returned on a bad request.
2727+///
2828+/// This struct holds detailed information about a single validation error,
2929+/// including the field name, error code, and a user-friendly message.
3030+#[derive(Deserialize, Debug)]
3131+pub struct BadRequestError {
3232+ /// Name of the field.
3333+ pub name: String,
3434+ /// Error code.
3535+ pub code: String,
3636+ /// More details about the error.
3737+ pub message: String,
3838+}
3939+4040+impl fmt::Display for BadRequestError {
4141+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4242+ write!(f, "{}: {} {}", self.name, self.code, self.message)
4343+ }
4444+}
4545+4646+/// Represents one of the fields that caused the Bad Request error.
4747+#[derive(Deserialize, Debug)]
4848+pub struct BadRequestField {
4949+ /// Error code *(example: `validation_required`)*.
5050+ pub code: String,
5151+ /// A text explaining in a readable way what this error is.
5252+ pub message: String,
5353+}
5454+5555+/// Represents errors when interacting with the `PocketBase` API.
5656+///
5757+/// This enum provides a set of error types that may occur during
5858+/// API requests, each indicating a specific issue encountered.
5959+#[derive(Error, Debug)]
6060+pub enum RequestError {
6161+ /// Communication with the `PocketBase` API was successful,
6262+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
6363+ ///
6464+ /// Your request may be missing fields or its content doesn't match what `PocketBase` expects to receive.
6565+ #[error("Bad Request: Something went wrong while processing your request. {0}")]
6666+ BadRequest(String),
6767+ /// Communication with the `PocketBase` API was successful,
6868+ /// but returned a [401 Unauthorized]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401") HTTP error response.
6969+ ///
7070+ /// The request may require an Authorization Token.
7171+ #[error("Unauthorized: The request may require an Authorization Token.")]
7272+ Unauthorized,
7373+ /// Communication with the `PocketBase` API was successful,
7474+ /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response.
7575+ ///
7676+ /// The authenticated user may not have permissions for this interaction.
7777+ #[error("Forbidden: The authenticated user may not have permissions for this interaction.")]
7878+ Forbidden,
7979+ /// Communication with the `PocketBase` API was successful,
8080+ /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response.
8181+ #[error("Not Found: The requested resource could not be found.")]
8282+ NotFound,
8383+ /// The response could not be parsed into the expected data structure.
8484+ #[error(
8585+ "Parse Error: Could not parse response into the expected data structure. It usually means that there is a missmatch between the provided Generic Type Parameter and your Collection definition. - {0}"
8686+ )]
8787+ ParseError(String),
8888+ /// The `PocketBase` API interaction timed out. It may be offline.
8989+ #[error(
9090+ "Unreachable: The PocketBase API interaction timed out, or the service may be offline."
9191+ )]
9292+ Unreachable,
9393+ /// Too many requests were sent to the API.
9494+ ///
9595+ /// The server is rate limiting requests. Wait before retrying.
9696+ #[error(
9797+ "Too Many Requests: The server is rate limiting requests. Please wait before retrying."
9898+ )]
9999+ TooManyRequests,
100100+ /// Unhandled error.
101101+ ///
102102+ /// Usually emitted when something unexpected happened, and isn't handled correctly by this crate.
103103+ #[error("Unhandled Error: An unexpected error occurred.")]
104104+ Unhandled,
105105+}
+495
src/lib.rs
···11+//! `pocketbase-rs` is a Rust wrapper around `PocketBase`'s REST API.
22+//!
33+//! # Usage
44+//!
55+//! ```rust,ignore
66+//! use std::error::Error;
77+//!
88+//! use pocketbase_rs::{PocketBase, Collection, RequestError};
99+//! use serde::Deserialize;
1010+//!
1111+//! #[derive(Default, Deserialize, Clone)]
1212+//! struct Article {
1313+//! title: String,
1414+//! content: String,
1515+//! }
1616+//!
1717+//! #[tokio::main]
1818+//! async fn main() -> Result<(), Box<dyn Error>> {
1919+//! let mut pb = PocketBase::new("http://localhost:8090");
2020+//!
2121+//! let auth_data = pb
2222+//! .collection("users")
2323+//! .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
2424+//! .await?;
2525+//!
2626+//! let article: Article = pb
2727+//! .collection("articles")
2828+//! .get_one::<Article>("record_id_123")
2929+//! .call()
3030+//! .await?;
3131+//!
3232+//! println!("Article Title: {}", article.title);
3333+//!
3434+//! Ok(())
3535+//! }
3636+//! ```
3737+3838+#![deny(missing_docs)]
3939+#![warn(clippy::nursery)]
4040+#![warn(clippy::pedantic)]
4141+#![allow(clippy::missing_errors_doc)]
4242+#![allow(clippy::module_name_repetitions)]
4343+#![allow(dead_code)]
4444+4545+pub use error::*;
4646+pub use records::auth::{AuthStore, AuthStoreRecord};
4747+use reqwest::RequestBuilder;
4848+pub use reqwest::multipart::{Form, Part};
4949+use serde::{Deserialize, Serialize};
5050+5151+pub mod error;
5252+pub(crate) mod records;
5353+5454+/// Represents a specific collection in a `PocketBase` database.
5555+///
5656+/// The `Collection` struct provides an interface for interacting with a specific collection
5757+/// within a `PocketBase` instance. Instances of this struct are created using the
5858+/// [`PocketBase::collection`] method. All operations on the target collection, such as retrieving,
5959+/// creating, updating, or deleting records, are accessible through methods implemented on
6060+/// this struct.
6161+///
6262+/// # Fields
6363+/// - `client`: A mutable reference to the `PocketBase` client instance.
6464+/// This allows the `Collection` to send requests to `PocketBase`.
6565+/// - `name`: The name of the collection being interacted with.
6666+pub struct Collection<'a> {
6767+ pub(crate) client: &'a mut PocketBase,
6868+ pub(crate) name: &'a str,
6969+}
7070+7171+impl PocketBase {
7272+ /// Creates a new [`Collection`] instance for the specified collection name.
7373+ ///
7474+ /// This method provides access to operations related to a specific collection in `PocketBase`.
7575+ /// Most interactions with the `PocketBase` API are performed through the [`Collection`] instance returned
7676+ /// by this method.
7777+ ///
7878+ /// # Arguments
7979+ /// * `collection_name` - The name of the collection to interact with, provided as a static string.
8080+ ///
8181+ /// # Returns
8282+ /// A [`Collection`] instance configured for the specified collection.
8383+ ///
8484+ /// # Example
8585+ /// ```rust,ignore
8686+ /// let mut pb = PocketBase::new("http://localhost:8090");
8787+ ///
8888+ /// pb.collection("users")
8989+ /// .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
9090+ /// .await?;
9191+ ///
9292+ /// let article = pb
9393+ /// .collection("articles")
9494+ /// .get_first_list_item::<Article>()
9595+ /// .filter("language='en'")
9696+ /// .call()
9797+ /// .await?;
9898+ /// ```
9999+ ///
100100+ /// # Panics
101101+ ///
102102+ /// This method will panic if the collection name is empty or contains invalid characters.
103103+ pub fn collection(&mut self, collection_name: &'static str) -> Collection {
104104+ // Validate collection name
105105+ assert!(
106106+ !collection_name.is_empty(),
107107+ "Collection name cannot be empty"
108108+ );
109109+110110+ // Collection names should only contain alphanumeric characters and underscores
111111+ assert!(
112112+ collection_name
113113+ .chars()
114114+ .all(|c| c.is_alphanumeric() || c == '_'),
115115+ "Collection name contains invalid characters. Only alphanumeric characters and underscores are allowed"
116116+ );
117117+118118+ Collection {
119119+ client: self,
120120+ name: collection_name,
121121+ }
122122+ }
123123+}
124124+125125+/// Represents a paginated list of records retrieved from a `PocketBase` collection.
126126+///
127127+/// The `RecordList` struct encapsulates the results of a paginated query to a collection.
128128+/// It contains metadata about the pagination state (such as the current page, total items,
129129+/// and total pages) as well as the records themselves.
130130+///
131131+/// This struct is typically returned by methods that fetch a list of records from a
132132+/// collection, such as [`Collection::get_list`].
133133+///
134134+/// # Type Parameters
135135+/// - `T`: The type of the records contained in the `items` list. This is typically a
136136+/// deserialized struct that matches the schema of the records in the collection.
137137+///
138138+/// # Fields
139139+/// - `page`: The current page number (starting from 1).
140140+/// - `per_page`: The maximum number of records returned per page (default is 30).
141141+/// - `total_items`: The total number of records in the collection that match the query.
142142+/// - `total_pages`: The total number of pages available for the query.
143143+/// - `items`: A vector containing the records for the current page.
144144+#[derive(Debug, Clone, Deserialize)]
145145+#[serde(rename_all = "camelCase")]
146146+pub struct RecordList<T> {
147147+ /// The page (aka. offset) of the paginated list *(default to 1)*.
148148+ pub page: i32,
149149+ /// The max returned records per page *(default to 30)*.
150150+ pub per_page: i32,
151151+ /// The total amount of records found in the collection.
152152+ pub total_items: i32,
153153+ /// The total amount of pages found in the collection.
154154+ pub total_pages: i32,
155155+ /// A list of all records for the given page.
156156+ pub items: Vec<T>,
157157+}
158158+159159+/// Response structure for API errors from `PocketBase`.
160160+#[derive(Deserialize, Debug)]
161161+pub(crate) struct ErrorResponse {
162162+ /// HTTP status code
163163+ pub code: u16,
164164+ /// Error message from the server
165165+ pub message: String,
166166+ /// Additional error data, if any
167167+ pub data: Option<serde_json::Value>,
168168+}
169169+170170+/// A `PocketBase` client for sending requests to a `PocketBase` instance.
171171+///
172172+/// The `Debug` implementation for this struct redacts sensitive authentication data
173173+/// to prevent accidental exposure in logs.
174174+///
175175+/// # Example
176176+/// ```rust,ignore
177177+/// use std::error::Error;
178178+/// use pocketbase_rs::PocketBase;
179179+/// use serde::Deserialize;
180180+///
181181+/// #[derive(Deserialize)]
182182+/// struct Article {
183183+/// id: String,
184184+/// title: String,
185185+/// }
186186+///
187187+/// #[tokio::main]
188188+/// async fn main() -> Result<(), Box<dyn Error>> {
189189+/// let mut pb = PocketBase::new("http://localhost:8090");
190190+///
191191+/// pb.collection("users")
192192+/// .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
193193+/// .await?;
194194+///
195195+/// let article = pb
196196+/// .collection("articles")
197197+/// .get_one::<Article>("record_id")
198198+/// .call()
199199+/// .await?;
200200+///
201201+/// println!("Article: {:?}", article);
202202+///
203203+/// Ok(())
204204+/// }
205205+/// ```
206206+#[derive(Clone)]
207207+pub struct PocketBase {
208208+ pub(crate) base_url: String,
209209+ pub(crate) auth_store: Option<AuthStore>,
210210+ pub(crate) reqwest_client: reqwest::Client,
211211+}
212212+213213+impl std::fmt::Debug for PocketBase {
214214+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215215+ f.debug_struct("PocketBase")
216216+ .field("base_url", &self.base_url)
217217+ .field(
218218+ "auth_store",
219219+ &self.auth_store.as_ref().map(|_| "***REDACTED***"),
220220+ )
221221+ .field("reqwest_client", &"Client")
222222+ .finish()
223223+ }
224224+}
225225+226226+impl PocketBase {
227227+ /// Creates a new instance of the `PocketBase` client.
228228+ ///
229229+ /// # Example
230230+ /// ```rust
231231+ /// let pb = PocketBase::new("http://localhost:8090");
232232+ /// // Use the client for further operations like authentication or fetching records
233233+ /// ```
234234+ /// # Panics
235235+ ///
236236+ /// This method will panic if the provided `base_url` is not a valid URL.
237237+ #[must_use]
238238+ pub fn new(base_url: &str) -> Self {
239239+ // Validate URL format
240240+ let trimmed_url = base_url.trim_end_matches('/');
241241+ assert!(
242242+ trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://"),
243243+ "Invalid base_url: must start with http:// or https://"
244244+ );
245245+246246+ // Create client with sensible defaults
247247+ let client = reqwest::Client::builder()
248248+ .timeout(std::time::Duration::from_secs(30))
249249+ .connect_timeout(std::time::Duration::from_secs(10))
250250+ .build()
251251+ .expect("Failed to create HTTP client");
252252+253253+ Self {
254254+ base_url: trimmed_url.to_string(),
255255+ auth_store: None,
256256+ reqwest_client: client,
257257+ }
258258+ }
259259+260260+ /// Creates a new `PocketBase` client with a custom reqwest client.
261261+ ///
262262+ /// # Example
263263+ /// ```rust
264264+ /// use std::time::Duration;
265265+ ///
266266+ /// let reqwest_client = reqwest::Client::builder()
267267+ /// .timeout(Duration::from_secs(60))
268268+ /// .build()
269269+ /// .expect("Failed to build client");
270270+ ///
271271+ /// let pb = PocketBase::new_with_client("http://localhost:8090", reqwest_client);
272272+ /// ```
273273+ ///
274274+ /// # Panics
275275+ ///
276276+ /// This method will panic if the provided `base_url` is not a valid URL.
277277+ #[must_use]
278278+ pub fn new_with_client(base_url: &str, client: reqwest::Client) -> Self {
279279+ // Validate URL format
280280+ let trimmed_url = base_url.trim_end_matches('/');
281281+ assert!(
282282+ trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://"),
283283+ "Invalid base_url: must start with http:// or https://"
284284+ );
285285+286286+ Self {
287287+ base_url: trimmed_url.to_string(),
288288+ auth_store: None,
289289+ reqwest_client: client,
290290+ }
291291+ }
292292+293293+ /// Retrieves the current auth store, if available.
294294+ ///
295295+ /// # Example
296296+ /// ```rust,ignore
297297+ /// let pb = PocketBase::new("http://localhost:8090");
298298+ ///
299299+ /// // ...
300300+ ///
301301+ /// if let Some(auth_store) = pb.auth_store() {
302302+ /// println!("Authenticated with token: {}", auth_store.token);
303303+ /// } else {
304304+ /// println!("Not authenticated");
305305+ /// }
306306+ /// ```
307307+ #[must_use]
308308+ pub fn auth_store(&self) -> Option<AuthStore> {
309309+ self.auth_store.clone()
310310+ }
311311+312312+ /// Retrieves the current authentication token, if available.
313313+ ///
314314+ /// # Example
315315+ /// ```rust,ignore
316316+ /// let pb = PocketBase::new("http://localhost:8090");
317317+ ///
318318+ /// // ...
319319+ ///
320320+ /// if let Some(token) = pb.token() {
321321+ /// println!("Authenticated with token: {}", token);
322322+ /// } else {
323323+ /// println!("Not authenticated");
324324+ /// }
325325+ /// ```
326326+ #[must_use]
327327+ pub fn token(&self) -> Option<String> {
328328+ self.auth_store
329329+ .as_ref()
330330+ .map(|auth_store| auth_store.token.clone())
331331+ }
332332+333333+ /// Returns the base URL of the `PocketBase` server.
334334+ ///
335335+ /// # Example
336336+ /// ```rust,ignore
337337+ /// let pb = PocketBase::new("http://localhost:8090");
338338+ /// assert_eq!(pb.base_url(), "http://localhost:8090".to_string());
339339+ /// ```
340340+ #[must_use]
341341+ pub fn base_url(&self) -> String {
342342+ self.base_url.clone()
343343+ }
344344+345345+ pub(crate) fn update_auth_store(&mut self, new_auth_store: AuthStore) {
346346+ self.auth_store = Some(new_auth_store);
347347+ }
348348+}
349349+350350+impl PocketBase {
351351+ /// Adds an authorization token to the request, if available.
352352+ ///
353353+ /// This method attaches a bearer authentication token to the provided `RequestBuilder`
354354+ /// if the client is currently authenticated. If no token is available, the request is
355355+ /// returned unchanged.
356356+ ///
357357+ /// # Arguments
358358+ /// * `request_builder` - A `reqwest::RequestBuilder` to which the token will be added.
359359+ ///
360360+ /// # Returns
361361+ /// A `reqwest::RequestBuilder` with the authorization token, if applicable.
362362+ pub(crate) fn with_authorization_token(
363363+ &self,
364364+ request_builder: reqwest::RequestBuilder,
365365+ ) -> reqwest::RequestBuilder {
366366+ if let Some(auth_store) = self.auth_store() {
367367+ request_builder.bearer_auth(auth_store.token)
368368+ } else {
369369+ request_builder
370370+ }
371371+ }
372372+373373+ /// Creates a POST request builder for the specified endpoint.
374374+ ///
375375+ /// This method initializes a `POST` request to the given endpoint and adds
376376+ /// an authorization token if available.
377377+ ///
378378+ /// # Arguments
379379+ /// * `endpoint` - The API endpoint to send the `POST` request to.
380380+ ///
381381+ /// # Returns
382382+ /// A `reqwest::RequestBuilder` for the `POST` request.
383383+ pub(crate) fn request_post(&self, endpoint: &str) -> RequestBuilder {
384384+ let request_builder = self.reqwest_client.post(endpoint);
385385+ self.with_authorization_token(request_builder)
386386+ }
387387+388388+ /// Creates a PATCH request builder with JSON body for the specified endpoint.
389389+ ///
390390+ /// This method initializes a `PATCH` request to the given endpoint with a JSON body,
391391+ /// and adds an authorization token if available.
392392+ ///
393393+ /// # Arguments
394394+ /// * `endpoint` - The API endpoint to send the `PATCH` request to.
395395+ /// * `params` - A reference to a serializable type to use as the JSON body of the request.
396396+ ///
397397+ /// # Returns
398398+ /// A `reqwest::RequestBuilder` for the `PATCH` request.
399399+ pub(crate) fn request_patch_json<T: Default + Serialize + Clone + Send>(
400400+ &self,
401401+ endpoint: &str,
402402+ params: &T,
403403+ ) -> RequestBuilder {
404404+ let request_builder = self.reqwest_client.patch(endpoint).json(¶ms);
405405+ self.with_authorization_token(request_builder)
406406+ }
407407+408408+ /// Creates a POST request builder with JSON body for the specified endpoint.
409409+ ///
410410+ /// This method initializes a `POST` request to the given endpoint with a JSON body,
411411+ /// and adds an authorization token if available.
412412+ ///
413413+ /// # Arguments
414414+ /// * `endpoint` - The API endpoint to send the `POST` request to.
415415+ /// * `params` - A reference to a serializable type to use as the JSON body of the request.
416416+ ///
417417+ /// # Returns
418418+ /// A `reqwest::RequestBuilder` for the `POST` request.
419419+ pub(crate) fn request_post_json<T: Default + Serialize + Clone + Send>(
420420+ &self,
421421+ endpoint: &str,
422422+ params: &T,
423423+ ) -> RequestBuilder {
424424+ let request_builder = self.reqwest_client.post(endpoint).json(¶ms);
425425+ self.with_authorization_token(request_builder)
426426+ }
427427+428428+ /// Creates a POST request builder with a form body for the specified endpoint.
429429+ ///
430430+ /// This method initializes a `POST` request to the given endpoint with a multipart form body,
431431+ /// and adds an authorization token if available.
432432+ ///
433433+ /// # Arguments
434434+ /// * `endpoint` - The API endpoint to send the `POST` request to.
435435+ /// * `form` - A `reqwest::multipart::Form` representing the form data for the request.
436436+ ///
437437+ /// # Returns
438438+ /// A `reqwest::RequestBuilder` for the `POST` request.
439439+ pub(crate) fn request_post_form(&self, endpoint: &str, form: Form) -> RequestBuilder {
440440+ let request_builder = self.reqwest_client.post(endpoint).multipart(form);
441441+ self.with_authorization_token(request_builder)
442442+ }
443443+444444+ /// Creates a GET request builder for the specified endpoint.
445445+ ///
446446+ /// This method initializes a `GET` request to the given endpoint, adds an `Accept` header
447447+ /// for JSON responses, attaches query parameters if provided, and adds an authorization
448448+ /// token if available.
449449+ ///
450450+ /// # Arguments
451451+ /// * `endpoint` - The API endpoint to send the `GET` request to.
452452+ /// * `params` - An optional vector of key-value pairs to include as query parameters.
453453+ ///
454454+ /// # Returns
455455+ /// A `reqwest::RequestBuilder` for the `GET` request.
456456+ pub(crate) fn request_get(
457457+ &self,
458458+ endpoint: &str,
459459+ params: Option<Vec<(&str, &str)>>,
460460+ ) -> RequestBuilder {
461461+ let mut request_builder = self
462462+ .reqwest_client
463463+ .get(endpoint)
464464+ .header("Accept", "application/json");
465465+466466+ if let Some(params) = params {
467467+ request_builder = request_builder.query(¶ms);
468468+ }
469469+470470+ self.with_authorization_token(request_builder)
471471+ }
472472+473473+ /// Creates a DELETE request builder for the specified endpoint.
474474+ ///
475475+ /// This method initializes a `DELETE` request to the given endpoint and adds
476476+ /// an authorization token if available.
477477+ ///
478478+ /// # Arguments
479479+ /// * `endpoint` - The API endpoint to send the `DELETE` request to.
480480+ ///
481481+ /// # Returns
482482+ /// A `reqwest::RequestBuilder` for the `DELETE` request.
483483+ ///
484484+ /// # Example
485485+ /// ```rust,ignore
486486+ /// let pb = PocketBase::new("http://localhost:8090");
487487+ ///
488488+ /// let request = pb.request_delete("http://localhost:8090/api/collections/articles/record_id");
489489+ /// ```
490490+ pub(crate) fn request_delete(&self, endpoint: &str) -> RequestBuilder {
491491+ let request_builder = self.reqwest_client.delete(endpoint);
492492+493493+ self.with_authorization_token(request_builder)
494494+ }
495495+}
+47
src/records/auth/auth_refresh.rs
···11+use crate::error::RequestError;
22+use crate::{AuthStore, Collection};
33+44+impl Collection<'_> {
55+ /// Returns a new auth response (token and record data) for an **already authenticated record**.
66+ ///
77+ /// This method is usually called by users on page/screen reload to ensure that the previously stored data in `pb.auth_store()` is still valid and up-to-date.
88+ ///
99+ /// # Example
1010+ /// ```rust,ignore
1111+ /// let auth_data = pb.collection("users")
1212+ /// .auth_refresh()
1313+ /// .await?;
1414+ ///
1515+ /// println!("New token: {}", auth_data.token);
1616+ /// ```
1717+ pub async fn auth_refresh(&mut self) -> Result<AuthStore, RequestError> {
1818+ let url = format!(
1919+ "{}/api/collections/{}/auth-refresh",
2020+ self.client.base_url(),
2121+ self.name
2222+ );
2323+2424+ let request = self.client.request_post(&url).send().await;
2525+2626+ match request {
2727+ Ok(response) => match response.status() {
2828+ reqwest::StatusCode::OK => {
2929+ let Ok(auth_store) = response.json::<AuthStore>().await else {
3030+ return Err(RequestError::Unhandled);
3131+ };
3232+3333+ self.client.update_auth_store(auth_store.clone());
3434+3535+ Ok(auth_store)
3636+ }
3737+3838+ reqwest::StatusCode::UNAUTHORIZED => Err(RequestError::Unauthorized),
3939+ reqwest::StatusCode::FORBIDDEN => Err(RequestError::Forbidden),
4040+ reqwest::StatusCode::NOT_FOUND => Err(RequestError::NotFound),
4141+4242+ _ => Err(RequestError::Unhandled),
4343+ },
4444+ Err(_) => Err(RequestError::Unhandled),
4545+ }
4646+ }
4747+}
+60
src/records/auth/auth_refresh_for_user.rs
···11+use crate::error::RequestError;
22+use crate::{AuthStore, Collection};
33+44+impl<'a> Collection<'a> {
55+ /// Refresh the authentication token for a specific user.
66+ ///
77+ /// Useful when managing tokens for other users (e.g., as a superuser).
88+ ///
99+ /// # Example
1010+ /// ```rust,ignore
1111+ /// let auth_data = pb
1212+ /// .collection("users")
1313+ /// .auth_refresh_for_user("USER_TOKEN")
1414+ /// .await?;
1515+ ///
1616+ /// println!("New token: {}", auth_data.token);
1717+ /// ```
1818+ pub async fn auth_refresh_for_user(
1919+ &mut self,
2020+ user_token: &'a str,
2121+ ) -> Result<AuthStore, RequestError> {
2222+ let url = format!(
2323+ "{}/api/collections/{}/auth-refresh",
2424+ self.client.base_url(),
2525+ self.name
2626+ );
2727+2828+ // Usually we would do `let request = self.client.request_post(&url).bearer_auth(user_token).send().await;`,
2929+ // but in our wrapper methods around `Reqwest`, we already use the `.bearer_auth()` method on our
3030+ // `RequestBuilder` with the token of the currently logged in user.
3131+ // When we try to reuse `.bearer_auth()` for a second time, for example here to put the **Token** of
3232+ // the user to re-authenticate, it seems to be ignored. We could probably rewrite our wrapper methods, but honestly, I'm too lazy.
3333+ let request = self
3434+ .client
3535+ .reqwest_client
3636+ .post(&url)
3737+ .bearer_auth(user_token)
3838+ .send()
3939+ .await;
4040+4141+ match request {
4242+ Ok(response) => match response.status() {
4343+ reqwest::StatusCode::OK => {
4444+ let Ok(auth_store) = response.json::<AuthStore>().await else {
4545+ return Err(RequestError::Unhandled);
4646+ };
4747+4848+ Ok(auth_store)
4949+ }
5050+5151+ reqwest::StatusCode::UNAUTHORIZED => Err(RequestError::Unauthorized),
5252+ reqwest::StatusCode::FORBIDDEN => Err(RequestError::Forbidden),
5353+ reqwest::StatusCode::NOT_FOUND => Err(RequestError::NotFound),
5454+5555+ _ => Err(RequestError::Unhandled),
5656+ },
5757+ Err(_) => Err(RequestError::Unhandled),
5858+ }
5959+ }
6060+}
+190
src/records/auth/auth_with_password.rs
···11+use serde::Serialize;
22+use serde_json::Value;
33+use thiserror::Error;
44+55+use crate::{AuthStore, Collection, ErrorResponse};
66+77+#[derive(Clone, Default, Serialize)]
88+struct Credentials<'a> {
99+ pub(crate) identity: &'a str,
1010+ pub(crate) password: &'a str,
1111+}
1212+1313+/// Represents errors that can occur during the authentication process with the `PocketBase` API.
1414+///
1515+/// This enum defines various error types that may arise when attempting to authenticate,
1616+/// each providing details about the specific issue encountered.
1717+#[derive(Error, Debug)]
1818+pub enum AuthenticationError {
1919+ /// Communication with the `PocketBase` API was successful,
2020+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
2121+ ///
2222+ /// Tip: The credentials you provided may be incorrect.
2323+ #[error("Authentication failed: Invalid Credentials. Given email and/or password is wrong.")]
2424+ InvalidCredentials,
2525+ /// Email and/or Password cannot be empty.
2626+ ///
2727+ /// This variant indicates that certain fields in the authentication request need to be validated.
2828+ /// The fields are represented as booleans:
2929+ ///
3030+ /// - `identity`: is blank and shouldn't be.
3131+ /// - `password`: is blank and shouldn't be.
3232+ #[error("Authentication failed: Empty Credential Field. Given email and/or password is empty.")]
3333+ EmptyField {
3434+ /// Is identity blank.
3535+ identity: bool,
3636+ /// Is password blank.
3737+ password: bool,
3838+ },
3939+ /// The provided identity must be an email address.
4040+ ///
4141+ /// This variant indicates that the authentication request failed because the provided identity
4242+ /// does not conform to the expected email format. The `PocketBase` API requires the identity to
4343+ /// be a valid email address for authentication.
4444+ #[error("Authentication failed. Given identity is not a valid email.")]
4545+ IdentityMustBeEmail,
4646+ /// An HTTP error occurred while communicating with the `PocketBase` API.
4747+ ///
4848+ /// This variant wraps a [`reqwest::Error`] and indicates that the request could not be completed
4949+ /// due to network issues, invalid URL, timeouts, etc.
5050+ #[error("Authentication failed. Couldn't reach the PocketBase API: {0}")]
5151+ HttpError(reqwest::Error),
5252+ /// When something unexpected was returned by the `PocketBase` REST API.
5353+ ///
5454+ /// Would usually mean that there is an error somewhere in this API wrapper.
5555+ #[error(
5656+ "Authentication failed due to an unexpected response. Usually means a problem in the PocketBase API's wrapper."
5757+ )]
5858+ UnexpectedResponse,
5959+ /// Occurs when you try to authenticate a `PocketBase` client without providing the collection name.
6060+ #[error(
6161+ "Authentication failed due to missing collection name. [Example: PocketBaseClientBuilder::new(\"\")"
6262+ )]
6363+ MissingCollection,
6464+}
6565+6666+impl From<reqwest::Error> for AuthenticationError {
6767+ fn from(error: reqwest::Error) -> Self {
6868+ Self::HttpError(error)
6969+ }
7070+}
7171+7272+impl Collection<'_> {
7373+ /// Authenticate with combination of **email**/**username** and **password**.
7474+ ///
7575+ /// On success, the auth token is automatically stored and used for subsequent requests.
7676+ ///
7777+ /// # Example
7878+ /// ```rust,ignore
7979+ /// let auth_data = pb.collection("users")
8080+ /// .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD")
8181+ /// .await?;
8282+ ///
8383+ /// println!("Token: {}", auth_data.token);
8484+ /// ```
8585+ pub async fn auth_with_password(
8686+ &mut self,
8787+ identity: &str,
8888+ password: &str,
8989+ ) -> Result<AuthStore, AuthenticationError> {
9090+ let uri = format!(
9191+ "{}/api/collections/{}/auth-with-password",
9292+ self.client.base_url, self.name
9393+ );
9494+9595+ let credentials = Credentials { identity, password };
9696+9797+ let response = self
9898+ .client
9999+ .request_post_json(&uri, &credentials)
100100+ .send()
101101+ .await?;
102102+103103+ if response.status().is_success() {
104104+ let auth_store = response.json::<AuthStore>().await?;
105105+106106+ self.client.update_auth_store(auth_store.clone());
107107+108108+ return Ok(auth_store);
109109+ }
110110+111111+ if response.status() == reqwest::StatusCode::BAD_REQUEST {
112112+ let error_response: ErrorResponse =
113113+ response.json().await.unwrap_or_else(|_| ErrorResponse {
114114+ code: 400,
115115+ message: "Unknown error".to_string(),
116116+ data: None,
117117+ });
118118+119119+ if let Some(ref data) = error_response.data {
120120+ // {
121121+ // "code": 400,
122122+ // "message": "Failed to authenticate.",
123123+ // "data": {}
124124+ // }
125125+ if data.as_object().is_some_and(serde_json::Map::is_empty) {
126126+ return Err(AuthenticationError::InvalidCredentials);
127127+ }
128128+129129+ // Check for specific field validation errors
130130+ let identity_error = data
131131+ .get("identity")
132132+ .and_then(|v| v.get("code").and_then(Value::as_str));
133133+134134+ match identity_error {
135135+ // {
136136+ // "code": 400,
137137+ // "message": "Something went wrong while processing your request.",
138138+ // "data": {
139139+ // "identity": {
140140+ // "code": "validation_is_email",
141141+ // "message": "Must be a valid email address."
142142+ // }
143143+ // }
144144+ // }
145145+ Some("validation_is_email") => {
146146+ return Err(AuthenticationError::IdentityMustBeEmail);
147147+ }
148148+149149+ // {
150150+ // "code": 400,
151151+ // "message": "Something went wrong while processing your request.",
152152+ // "data": {
153153+ // "identity": {
154154+ // "code": "validation_required",
155155+ // "message": "Cannot be blank."
156156+ // },
157157+ // "password": {
158158+ // "code": "validation_required",
159159+ // "message": "Cannot be blank."
160160+ // }
161161+ // }
162162+ // }
163163+ Some("validation_required") => {
164164+ return Err(AuthenticationError::EmptyField {
165165+ identity: identity_error.is_some(),
166166+ password: data.get("password").is_some(),
167167+ });
168168+ }
169169+ None => {
170170+ let password_error = data.get("password").is_some();
171171+ return Err(AuthenticationError::EmptyField {
172172+ identity: false,
173173+ password: password_error,
174174+ });
175175+ }
176176+ _ => {}
177177+ }
178178+ }
179179+180180+ // {
181181+ // "code": 400,
182182+ // "message": "Failed to authenticate.",
183183+ // "data": {}
184184+ // }
185185+ return Err(AuthenticationError::InvalidCredentials);
186186+ }
187187+188188+ Err(AuthenticationError::UnexpectedResponse)
189189+ }
190190+}
+161
src/records/auth/impersonate.rs
···11+use serde::Deserialize;
22+use thiserror::Error;
33+44+use super::AuthStore;
55+use crate::{Collection, PocketBase};
66+77+/// Represents the various errors that can be obtained after a `impersonate` request.
88+#[derive(Error, Debug)]
99+pub enum ImpersonateError {
1010+ /// Communication with the `PocketBase` API was successful,
1111+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
1212+ ///
1313+ /// The request requires valid record authorization token to be set.
1414+ #[error("Bad Request: The request requires valid record authorization token to be set.")]
1515+ BadRequest,
1616+ /// Communication with the `PocketBase` API was successful,
1717+ /// but returned a [401 Unauthorized]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401") HTTP error response.
1818+ ///
1919+ /// The request requires valid record authorization token.
2020+ #[error("The request requires valid record authorization token.")]
2121+ Unauthorized,
2222+ /// Communication with the `PocketBase` API was successful,
2323+ /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response.
2424+ ///
2525+ /// The authorized record is not allowed to perform this action.
2626+ /// Are you impersonating a user from a non-superuser account?
2727+ #[error(
2828+ "The authorized record is not allowed to perform this action. Are you impersonating a user from a non-superuser account?"
2929+ )]
3030+ Forbidden,
3131+ /// Communication with the `PocketBase` API was successful,
3232+ /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response.
3333+ ///
3434+ /// The requested resource wasn't found.
3535+ /// The given user id is probably wrong.
3636+ #[error("The requested resource wasn't found.")]
3737+ NotFound,
3838+ /// Communication with the `PocketBase` API failed.
3939+ ///
4040+ /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK
4141+ /// and similar errors.
4242+ #[error("The communication with the PocketBase API failed: {0}")]
4343+ Unreachable(String),
4444+ /// The response from the `PocketBase` instance API was unexpected.
4545+ /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues").
4646+ #[error("An unhandled status code was returned by the PocketBase API: {0}")]
4747+ UnexpectedResponse(String),
4848+}
4949+5050+#[derive(Deserialize)]
5151+struct AuthData {
5252+ record: AuthDataRecord,
5353+ token: String,
5454+}
5555+5656+#[derive(Default, Clone, Deserialize)]
5757+#[serde(rename_all = "camelCase")]
5858+struct AuthDataRecord {
5959+ collection_id: String,
6060+ collection_name: String,
6161+ id: String,
6262+ email: String,
6363+ email_visibility: bool,
6464+ verified: bool,
6565+ created: String,
6666+ updated: String,
6767+}
6868+6969+pub struct CollectionImpersonateBuilder<'a> {
7070+ client: &'a PocketBase,
7171+ collection_name: &'a str,
7272+ user_id: &'a str,
7373+ duration: Option<String>,
7474+}
7575+7676+impl<'a> Collection<'a> {
7777+ /// Authenticate as a different user by generating a non-refreshable auth token.
7878+ ///
7979+ /// Only superusers can perform this action. Returns a new `PocketBase` client
8080+ /// with the impersonated user's auth token.
8181+ ///
8282+ /// # Example
8383+ /// ```rust,ignore
8484+ /// let impersonate_client = pb
8585+ /// .collection("users")
8686+ /// .impersonate("USER_RECORD_ID")
8787+ /// .duration(3600)
8888+ /// .call()
8989+ /// .await?;
9090+ ///
9191+ /// println!("Token: {}", impersonate_client.auth_store().unwrap().token);
9292+ /// ```
9393+ #[must_use]
9494+ pub const fn impersonate(self, user_id: &'a str) -> CollectionImpersonateBuilder<'a> {
9595+ CollectionImpersonateBuilder {
9696+ client: self.client,
9797+ collection_name: self.name,
9898+ user_id,
9999+ duration: None,
100100+ }
101101+ }
102102+}
103103+104104+impl CollectionImpersonateBuilder<'_> {
105105+ /// Set custom JWT duration in seconds (optional).
106106+ ///
107107+ /// If not set, uses the default collection auth token duration.
108108+ pub fn duration(mut self, duration: u128) -> Self {
109109+ self.duration = Some(duration.to_string());
110110+ self
111111+ }
112112+113113+ /// Execute the request and return a new `PocketBase` client with the impersonated user's token.
114114+ pub async fn call(self) -> Result<PocketBase, ImpersonateError> {
115115+ let url = format!(
116116+ "{}/api/collections/{}/impersonate/{}",
117117+ self.client.base_url, self.collection_name, self.user_id
118118+ );
119119+120120+ let request = {
121121+ if let Some(duration) = self.duration {
122122+ self.client
123123+ .request_post_form(
124124+ &url,
125125+ reqwest::multipart::Form::new().text("duration", duration),
126126+ )
127127+ .send()
128128+ .await
129129+ } else {
130130+ self.client.request_post(&url).send().await
131131+ }
132132+ };
133133+134134+ match request {
135135+ Ok(response) => match response.status() {
136136+ reqwest::StatusCode::OK => {
137137+ let Ok(auth_store) = response.json::<AuthStore>().await else {
138138+ return Err(ImpersonateError::UnexpectedResponse(
139139+ "Couldn't parse API response into Auth Data".to_string(),
140140+ ));
141141+ };
142142+143143+ let mut impersonate_client = PocketBase::new(&self.client.base_url());
144144+ impersonate_client.update_auth_store(auth_store);
145145+146146+ Ok(impersonate_client)
147147+ }
148148+149149+ reqwest::StatusCode::BAD_REQUEST => Err(ImpersonateError::BadRequest),
150150+ reqwest::StatusCode::UNAUTHORIZED => Err(ImpersonateError::Unauthorized),
151151+ reqwest::StatusCode::FORBIDDEN => Err(ImpersonateError::Forbidden),
152152+ reqwest::StatusCode::NOT_FOUND => Err(ImpersonateError::NotFound),
153153+154154+ _ => Err(ImpersonateError::UnexpectedResponse(
155155+ response.status().to_string(),
156156+ )),
157157+ },
158158+ Err(error) => Err(ImpersonateError::Unreachable(error.to_string())),
159159+ }
160160+ }
161161+}
+45
src/records/auth/mod.rs
···11+use serde::Deserialize;
22+33+pub mod auth_refresh;
44+pub mod auth_refresh_for_user;
55+pub mod auth_with_password;
66+pub mod impersonate;
77+pub mod request_verification;
88+99+/// Stores authentication details for a `PocketBase` user.
1010+///
1111+/// The `AuthStore` struct holds the authenticated user's record and a token
1212+/// used for making authenticated requests to the `PocketBase` API.
1313+#[derive(Clone, Debug, Deserialize)]
1414+pub struct AuthStore {
1515+ /// The authenticated user's record.
1616+ pub record: AuthStoreRecord,
1717+ /// The authentication token.
1818+ pub token: String,
1919+}
2020+2121+/// Represents the details of an authenticated user's record.
2222+///
2323+/// The `AuthStoreRecord` struct contains information about the user,
2424+/// such as their ID, email, etc. and other metadata related to the
2525+/// collection they belong to.
2626+#[derive(Clone, Debug, Deserialize)]
2727+#[serde(rename_all = "camelCase")]
2828+pub struct AuthStoreRecord {
2929+ /// The user's unique ID.
3030+ pub id: String,
3131+ /// The ID of the collection the user belongs to.
3232+ pub collection_id: String,
3333+ /// The name of the collection the user belongs to.
3434+ pub collection_name: String,
3535+ /// The timestamp when the record was created.
3636+ pub created: String,
3737+ /// The timestamp when the record was last updated.
3838+ pub updated: String,
3939+ /// The user's email address.
4040+ pub email: String,
4141+ /// Indicates whether the user's email is publicly visible.
4242+ pub email_visibility: bool,
4343+ /// Indicates whether the user's email has been verified.
4444+ pub verified: bool,
4545+}
···11+use serde::{Deserialize, Serialize};
22+use thiserror::Error;
33+44+use crate::Collection;
55+use crate::error::{BadRequestError, BadRequestResponse};
66+77+/// Represents the various errors that can be obtained after a `create` request.
88+#[derive(Error, Debug)]
99+pub enum CreateError {
1010+ /// Communication with the `PocketBase` API was successful,
1111+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
1212+ ///
1313+ /// Missing required value. `PocketBase`.
1414+ #[error("Failed to create record: {0:?}")]
1515+ BadRequest(Vec<BadRequestError>),
1616+ /// Communication with the `PocketBase` API was successful,
1717+ /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response.
1818+ ///
1919+ /// You are not allowed to perform this request.
2020+ #[error("You are not allowed to perform this request.")]
2121+ Forbidden,
2222+ /// Communication with the `PocketBase` API was successful,
2323+ /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response.
2424+ ///
2525+ /// The requested resource wasn't found. Missing collection context.
2626+ #[error("The requested resource wasn't found. Missing collection context.")]
2727+ NotFound,
2828+ /// Communication with the `PocketBase` API failed.
2929+ ///
3030+ /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK
3131+ /// and similar errors.
3232+ #[error("The communication with the PocketBase API failed: {0}")]
3333+ Unreachable(String),
3434+ /// The response could not be parsed into the expected data structure.
3535+ #[error(
3636+ "Could not parse response into the expected data structure. It usually means that there is a mismatch between the provided Generic Type Parameter and your Collection definition: {0}"
3737+ )]
3838+ ParseError(String),
3939+ /// An unexpected error occurred.
4040+ /// The response from the `PocketBase` instance API was unexpected.
4141+ /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues").
4242+ #[error("An unhandled status code was returned by the PocketBase API: {0}")]
4343+ UnexpectedResponse(String),
4444+}
4545+4646+// TODO: Include the actual record data based on Generic type parameter.
4747+//
4848+// pub struct CreateResponse<T> {
4949+// pub collection_name: String,
5050+// pub collection_id: String,
5151+// pub id: String,
5252+// pub updated: String,
5353+// pub created: String,
5454+// #[serde(flatten)]
5555+// pub record: T, // The actual record data
5656+// }
5757+5858+/// Contains information about the successfully created Record
5959+#[derive(Deserialize, Clone, Debug)]
6060+#[serde(rename_all = "camelCase")]
6161+pub struct CreateResponse {
6262+ pub collection_name: String,
6363+ pub collection_id: String,
6464+ pub id: String,
6565+ pub updated: String,
6666+ pub created: String,
6767+}
6868+6969+impl Collection<'_> {
7070+ /// Create a new record.
7171+ ///
7272+ /// For file uploads, use [`Collection::create_multipart()`].
7373+ ///
7474+ /// # Example
7575+ /// ```rust,ignore
7676+ /// #[derive(Default, Serialize, Clone, Debug)]
7777+ /// struct Article {
7878+ /// name: String,
7979+ /// content: String,
8080+ /// }
8181+ ///
8282+ /// let article = pb
8383+ /// .collection("articles")
8484+ /// .create::<Article>(Article {
8585+ /// name: "test".to_string(),
8686+ /// content: "an interesting article content.".to_string(),
8787+ /// })
8888+ /// .await?;
8989+ /// ```
9090+ pub async fn create<T: Default + Serialize + Clone + Send>(
9191+ self,
9292+ record: T,
9393+ ) -> Result<CreateResponse, CreateError> {
9494+ let endpoint = format!(
9595+ "{}/api/collections/{}/records",
9696+ self.client.base_url, self.name
9797+ );
9898+9999+ let request = self
100100+ .client
101101+ .request_post_json(&endpoint, &record)
102102+ .send()
103103+ .await;
104104+105105+ create_processing(request).await
106106+ }
107107+108108+ /// Create a new record with multipart form data (e.g., for file uploads).
109109+ ///
110110+ /// For simple JSON records without files, use [`Collection::create()`].
111111+ ///
112112+ /// # Example
113113+ /// ```rust,ignore
114114+ /// use std::fs;
115115+ /// use pocketbase_rs::{Form, Part};
116116+ ///
117117+ /// let image = fs::read("./vulpes_vulpes.jpg")?;
118118+ ///
119119+ /// let image_part = Part::bytes(image)
120120+ /// .file_name("vulpes_vulpes")
121121+ /// .mime_str("image/jpeg")?;
122122+ ///
123123+ /// let form = Form::new()
124124+ /// .text("name", "Red Fox")
125125+ /// .part("illustration", image_part);
126126+ ///
127127+ /// let record = pb
128128+ /// .collection("foxes")
129129+ /// .create_multipart(form)
130130+ /// .await?;
131131+ /// ```
132132+ pub async fn create_multipart(
133133+ self,
134134+ form: reqwest::multipart::Form,
135135+ ) -> Result<CreateResponse, CreateError> {
136136+ let collection_name = self.name;
137137+138138+ let endpoint = format!(
139139+ "{}/api/collections/{}/records",
140140+ self.client.base_url, collection_name
141141+ );
142142+143143+ let request = self.client.request_post_form(&endpoint, form).send().await;
144144+145145+ create_processing(request).await
146146+ }
147147+}
148148+149149+async fn create_processing(
150150+ request: Result<reqwest::Response, reqwest::Error>,
151151+) -> Result<CreateResponse, CreateError> {
152152+ match request {
153153+ Ok(response) => match response.status() {
154154+ reqwest::StatusCode::OK => {
155155+ let data = response.json::<CreateResponse>().await;
156156+157157+ match data {
158158+ Ok(data) => Ok(data),
159159+ Err(error) => Err(CreateError::ParseError(error.to_string())),
160160+ }
161161+ }
162162+163163+ reqwest::StatusCode::BAD_REQUEST => {
164164+ let data = response.json::<BadRequestResponse>().await;
165165+166166+ match data {
167167+ Ok(bad_response) => {
168168+ let mut errors: Vec<BadRequestError> = vec![];
169169+170170+ for (error_name, error_data) in bad_response.data {
171171+ errors.push(BadRequestError {
172172+ name: error_name,
173173+ code: error_data.code,
174174+ message: error_data.message,
175175+ });
176176+ }
177177+178178+ Err(CreateError::BadRequest(errors))
179179+ }
180180+ Err(error) => Err(CreateError::ParseError(error.to_string())),
181181+ }
182182+ }
183183+184184+ reqwest::StatusCode::FORBIDDEN => Err(CreateError::Forbidden),
185185+ reqwest::StatusCode::NOT_FOUND => Err(CreateError::NotFound),
186186+187187+ _ => Err(CreateError::UnexpectedResponse(
188188+ response.status().to_string(),
189189+ )),
190190+ },
191191+192192+ Err(error) => Err(CreateError::Unreachable(error.to_string())),
193193+ }
194194+}
+86
src/records/crud/delete.rs
···11+use crate::Collection;
22+use thiserror::Error;
33+44+#[derive(Error, Debug)]
55+pub enum DeleteError {
66+ /// Communication with the `PocketBase` API was successful,
77+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
88+ ///
99+ /// Failed to delete record. Make sure that the record is not part of a required relation reference. `PocketBase`.
1010+ #[error(
1111+ "Failed to delete record. Make sure that the record is not part of a required relation reference."
1212+ )]
1313+ BadRequest,
1414+ /// Communication with the `PocketBase` API was successful,
1515+ /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response.
1616+ ///
1717+ /// You are not allowed to perform this request.
1818+ #[error("You are not allowed to perform this request.")]
1919+ Forbidden,
2020+ /// Communication with the `PocketBase` API was successful,
2121+ /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response.
2222+ ///
2323+ /// The requested resource wasn't found.
2424+ #[error("The requested resource wasn't found.")]
2525+ NotFound,
2626+ /// Communication with the `PocketBase` API failed.
2727+ ///
2828+ /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK
2929+ /// and similar errors.
3030+ #[error("The communication with the PocketBase API failed: {0}")]
3131+ Unreachable(String),
3232+ /// An unexpected error occurred.
3333+ /// The response from the `PocketBase` instance API was unexpected.
3434+ /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues").
3535+ #[error("An unhandled status code was returned by the PocketBase API: {0}")]
3636+ UnexpectedResponse(String),
3737+}
3838+3939+impl<'a> Collection<'a> {
4040+ /// Delete a single record.
4141+ ///
4242+ /// # Example
4343+ /// ```rust,ignore
4444+ /// pb.collection("articles")
4545+ /// .delete("RECORD_ID")
4646+ /// .await?;
4747+ /// ```
4848+ pub async fn delete(&self, record_id: &'a str) -> Result<(), DeleteError> {
4949+ // Validate record_id
5050+ if record_id.is_empty() {
5151+ return Err(DeleteError::BadRequest);
5252+ }
5353+5454+ let endpoint = format!(
5555+ "{}/api/collections/{}/records/{}",
5656+ self.client.base_url, self.name, record_id
5757+ );
5858+ let request = self.client.request_delete(&endpoint).send().await;
5959+6060+ match request {
6161+ Ok(response) => match response.status() {
6262+ reqwest::StatusCode::NO_CONTENT | reqwest::StatusCode::OK => Ok(()),
6363+ reqwest::StatusCode::BAD_REQUEST => Err(DeleteError::BadRequest),
6464+ reqwest::StatusCode::FORBIDDEN => Err(DeleteError::Forbidden),
6565+ reqwest::StatusCode::NOT_FOUND => Err(DeleteError::NotFound),
6666+ _ => Err(DeleteError::UnexpectedResponse(format!(
6767+ "Status: {}, Collection: {}, Record: {}",
6868+ response.status(),
6969+ self.name,
7070+ record_id
7171+ ))),
7272+ },
7373+ Err(e) => {
7474+ if e.is_timeout() {
7575+ Err(DeleteError::Unreachable("Request timed out".to_string()))
7676+ } else if e.is_connect() {
7777+ Err(DeleteError::Unreachable(
7878+ "Failed to connect to server".to_string(),
7979+ ))
8080+ } else {
8181+ Err(DeleteError::Unreachable(e.to_string()))
8282+ }
8383+ }
8484+ }
8585+ }
8686+}
+148
src/records/crud/get_first_list_item.rs
···11+use serde::{Deserialize, de::DeserializeOwned};
22+33+use crate::PocketBase;
44+use crate::error::RequestError;
55+use crate::{Collection, RecordList};
66+77+pub struct CollectionGetFirstListItemBuilder<'a, T: Send + Deserialize<'a>> {
88+ client: &'a PocketBase,
99+ collection_name: &'a str,
1010+ sort: Option<&'a str>,
1111+ expand: Option<&'a str>,
1212+ filter: Option<&'a str>,
1313+ _marker: std::marker::PhantomData<T>,
1414+}
1515+1616+impl<'a> Collection<'a> {
1717+ /// Fetch the first record from the given collection.
1818+ ///
1919+ /// # Example
2020+ /// ```rust,ignore
2121+ /// #[derive(Default, Deserialize, Clone)]
2222+ /// struct Article {
2323+ /// id: String,
2424+ /// title: String,
2525+ /// content: String,
2626+ /// }
2727+ ///
2828+ /// let article = pb
2929+ /// .collection("articles")
3030+ /// .get_first_list_item::<Article>()
3131+ /// .sort("-created,id")
3232+ /// .filter("language='en'")
3333+ /// .call()
3434+ /// .await?;
3535+ /// ```
3636+ #[must_use]
3737+ pub const fn get_first_list_item<T: Default + DeserializeOwned + Clone + Send>(
3838+ self,
3939+ ) -> CollectionGetFirstListItemBuilder<'a, T> {
4040+ CollectionGetFirstListItemBuilder {
4141+ client: self.client,
4242+ collection_name: self.name,
4343+ sort: None,
4444+ expand: None,
4545+ filter: None,
4646+ _marker: std::marker::PhantomData,
4747+ }
4848+ }
4949+}
5050+5151+impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetFirstListItemBuilder<'a, T> {
5252+ /// Set the sort order. Prefix with `-` for DESC or `+` for ASC (default).
5353+ ///
5454+ /// # Example
5555+ /// ```rust,ignore
5656+ /// .sort("-created,id") // DESC by created, ASC by id
5757+ /// ```
5858+ pub const fn sort(mut self, sort: &'a str) -> Self {
5959+ self.sort = Some(sort);
6060+ self
6161+ }
6262+6363+ /// Filter the returned records.
6464+ ///
6565+ /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~`
6666+ /// and their "any/at least one" variants with `?` prefix.
6767+ /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping.
6868+ ///
6969+ /// # Example
7070+ /// ```rust,ignore
7171+ /// .filter("language='en' && created>'1970-01-01'")
7272+ /// ```
7373+ pub const fn filter(mut self, filter: &'a str) -> Self {
7474+ self.filter = Some(filter);
7575+ self
7676+ }
7777+7878+ /// Auto expand record relations (up to 6-levels deep).
7979+ ///
8080+ /// Expanded relations are appended under the `expand` property.
8181+ /// Only relations the user has view permissions for will be expanded.
8282+ ///
8383+ /// # Example
8484+ /// ```rust,ignore
8585+ /// .expand("author")
8686+ /// ```
8787+ pub const fn expand(mut self, expand: &'a str) -> Self {
8888+ self.expand = Some(expand);
8989+ self
9090+ }
9191+9292+ /// Execute the request and return the first matching record.
9393+ pub async fn call(self) -> Result<T, RequestError> {
9494+ let url = format!(
9595+ "{}/api/collections/{}/records",
9696+ self.client.base_url, self.collection_name
9797+ );
9898+9999+ let mut query_parameters: Vec<(&str, &str)> =
100100+ vec![("page", "1"), ("perPage", "1"), ("skipTotal", "true")];
101101+102102+ if let Some(sort) = self.sort {
103103+ query_parameters.push(("sort", sort));
104104+ }
105105+106106+ if let Some(filter) = self.filter {
107107+ query_parameters.push(("filter", filter));
108108+ }
109109+110110+ if let Some(expand) = self.expand {
111111+ query_parameters.push(("expand", expand));
112112+ }
113113+114114+ let request = self
115115+ .client
116116+ .request_get(&url, Some(query_parameters))
117117+ .send()
118118+ .await;
119119+120120+ let response = match request {
121121+ Ok(response) => response
122122+ .error_for_status()
123123+ .map_err(|err| match err.status() {
124124+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
125125+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
126126+ _ => RequestError::Unhandled,
127127+ })?,
128128+ Err(error) => {
129129+ return Err(match error.status() {
130130+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
131131+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
132132+ _ => RequestError::Unhandled,
133133+ });
134134+ }
135135+ };
136136+137137+ // Parse JSON response
138138+ let records = response
139139+ .json::<RecordList<T>>()
140140+ .await
141141+ .map_err(|error| RequestError::ParseError(error.to_string()))?;
142142+143143+ records.items.first().map_or_else(
144144+ || Err(RequestError::ParseError("No record found.".to_owned())),
145145+ |record| Ok(record.clone()),
146146+ )
147147+ }
148148+}
+189
src/records/crud/get_full_list.rs
···11+use serde::de::DeserializeOwned;
22+33+use crate::error::RequestError;
44+use crate::{Collection, RecordList};
55+66+/// Builder for fetching all records from a collection.
77+pub struct CollectionGetFullListBuilder<'a, T: Send> {
88+ client: &'a crate::PocketBase,
99+ collection_name: &'a str,
1010+ batch_size: u16,
1111+ sort: Option<&'a str>,
1212+ expand: Option<&'a str>,
1313+ filter: Option<&'a str>,
1414+ _marker: std::marker::PhantomData<T>,
1515+}
1616+1717+impl<'a> Collection<'a> {
1818+ /// Fetch all records from the collection.
1919+ ///
2020+ /// Automatically handles pagination by iterating through all pages.
2121+ /// For performance, `skipTotal` is automatically set to `true`.
2222+ ///
2323+ /// # Example
2424+ /// ```rust,ignore
2525+ /// #[derive(Default, Deserialize, Clone)]
2626+ /// struct Article {
2727+ /// id: String,
2828+ /// title: String,
2929+ /// content: String,
3030+ /// }
3131+ ///
3232+ /// let all_articles = pb
3333+ /// .collection("articles")
3434+ /// .get_full_list::<Article>()
3535+ /// .sort("-created")
3636+ /// .call()
3737+ /// .await?;
3838+ ///
3939+ /// println!("Total articles: {}", all_articles.len());
4040+ /// ```
4141+ #[must_use]
4242+ pub const fn get_full_list<T: Default + DeserializeOwned + Clone + Send>(
4343+ self,
4444+ ) -> CollectionGetFullListBuilder<'a, T> {
4545+ CollectionGetFullListBuilder {
4646+ client: self.client,
4747+ collection_name: self.name,
4848+ batch_size: 500, // Maximum allowed by PocketBase
4949+ sort: None,
5050+ expand: None,
5151+ filter: None,
5252+ _marker: std::marker::PhantomData,
5353+ }
5454+ }
5555+}
5656+5757+impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetFullListBuilder<'a, T> {
5858+ /// Set the batch size for pagination (default: 500, max: 500).
5959+ ///
6060+ /// Lower values reduce memory usage but increase request count.
6161+ pub fn batch_size(mut self, size: u16) -> Self {
6262+ self.batch_size = size.min(500); // Ensure we don't exceed PocketBase's limit
6363+ self
6464+ }
6565+6666+ /// Set the sort order. Prefix with `-` for DESC or `+` for ASC (default).
6767+ ///
6868+ /// # Example
6969+ /// ```rust,ignore
7070+ /// .sort("-created,id") // DESC by created, ASC by id
7171+ /// ```
7272+ pub const fn sort(mut self, sort: &'a str) -> Self {
7373+ self.sort = Some(sort);
7474+ self
7575+ }
7676+7777+ /// Filter the returned records.
7878+ ///
7979+ /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~`
8080+ /// and their "any/at least one" variants with `?` prefix.
8181+ /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping.
8282+ ///
8383+ /// # Example
8484+ /// ```rust,ignore
8585+ /// .filter("language='en' && created>'1970-01-01'")
8686+ /// ```
8787+ pub const fn filter(mut self, filter: &'a str) -> Self {
8888+ self.filter = Some(filter);
8989+ self
9090+ }
9191+9292+ /// Auto expand record relations (up to 6-levels deep).
9393+ ///
9494+ /// Expanded relations are appended under the `expand` property.
9595+ /// Only relations the user has view permissions for will be expanded.
9696+ ///
9797+ /// # Example
9898+ /// ```rust,ignore
9999+ /// .expand("author")
100100+ /// ```
101101+ pub const fn expand(mut self, expand: &'a str) -> Self {
102102+ self.expand = Some(expand);
103103+ self
104104+ }
105105+106106+ /// Execute the request and return all matching records.
107107+ ///
108108+ /// Automatically handles pagination by making multiple requests if needed.
109109+ pub async fn call(self) -> Result<Vec<T>, RequestError> {
110110+ let mut all_records = Vec::new();
111111+ let mut page = 1u32;
112112+ let batch_size_str = self.batch_size.to_string();
113113+114114+ loop {
115115+ let url = format!(
116116+ "{}/api/collections/{}/records",
117117+ self.client.base_url, self.collection_name
118118+ );
119119+120120+ let page_str = page.to_string();
121121+ let mut query_parameters: Vec<(&str, &str)> = vec![
122122+ ("page", &page_str),
123123+ ("perPage", &batch_size_str),
124124+ ("skipTotal", "true"),
125125+ ];
126126+127127+ if let Some(sort) = self.sort {
128128+ query_parameters.push(("sort", sort));
129129+ }
130130+131131+ if let Some(filter) = self.filter {
132132+ query_parameters.push(("filter", filter));
133133+ }
134134+135135+ if let Some(expand) = self.expand {
136136+ query_parameters.push(("expand", expand));
137137+ }
138138+139139+ let request = self
140140+ .client
141141+ .request_get(&url, Some(query_parameters))
142142+ .send()
143143+ .await;
144144+145145+ let response = match request {
146146+ Ok(response) => response
147147+ .error_for_status()
148148+ .map_err(|err| match err.status() {
149149+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
150150+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
151151+ Some(reqwest::StatusCode::UNAUTHORIZED) => RequestError::Unauthorized,
152152+ _ => RequestError::Unhandled,
153153+ })?,
154154+ Err(error) => {
155155+ return Err(if error.is_timeout() || error.is_connect() {
156156+ RequestError::Unreachable
157157+ } else {
158158+ match error.status() {
159159+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
160160+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
161161+ Some(reqwest::StatusCode::UNAUTHORIZED) => RequestError::Unauthorized,
162162+ _ => RequestError::Unhandled,
163163+ }
164164+ });
165165+ }
166166+ };
167167+168168+ // Parse JSON response
169169+ let records_page = response
170170+ .json::<RecordList<T>>()
171171+ .await
172172+ .map_err(|error| RequestError::ParseError(error.to_string()))?;
173173+174174+ let items_count = records_page.items.len();
175175+ all_records.extend(records_page.items);
176176+177177+ // Check if we've fetched all records
178178+ // Since we're using skipTotal=true, we can't rely on total_pages
179179+ // Instead, we check if we got fewer items than requested
180180+ if items_count < self.batch_size as usize {
181181+ break;
182182+ }
183183+184184+ page += 1;
185185+ }
186186+187187+ Ok(all_records)
188188+ }
189189+}
+185
src/records/crud/get_list.rs
···11+use serde::{Deserialize, de::DeserializeOwned};
22+33+use crate::PocketBase;
44+use crate::error::RequestError;
55+use crate::{Collection, RecordList};
66+77+pub struct CollectionGetListBuilder<'a, T: Send + Deserialize<'a>> {
88+ client: &'a PocketBase,
99+ collection_name: &'a str,
1010+ page: Option<String>,
1111+ per_page: Option<String>,
1212+ sort: Option<&'a str>,
1313+ expand: Option<&'a str>,
1414+ filter: Option<&'a str>,
1515+ skip_total: bool,
1616+ _marker: std::marker::PhantomData<T>,
1717+}
1818+1919+impl<'a> Collection<'a> {
2020+ /// Fetch a paginated records list from the given collection.
2121+ ///
2222+ /// # Example
2323+ /// ```rust,ignore
2424+ /// #[derive(Default, Deserialize, Clone)]
2525+ /// struct Article {
2626+ /// id: String,
2727+ /// title: String,
2828+ /// content: String,
2929+ /// }
3030+ ///
3131+ /// let articles = pb
3232+ /// .collection("articles")
3333+ /// .get_list::<Article>()
3434+ /// .sort("-created,id")
3535+ /// .call()
3636+ /// .await?;
3737+ ///
3838+ /// for article in articles.items {
3939+ /// println!("{article:?}");
4040+ /// }
4141+ /// ```
4242+ #[must_use]
4343+ pub const fn get_list<T: Default + DeserializeOwned + Clone + Send>(
4444+ self,
4545+ ) -> CollectionGetListBuilder<'a, T> {
4646+ CollectionGetListBuilder {
4747+ client: self.client,
4848+ collection_name: self.name,
4949+ page: None,
5050+ per_page: None,
5151+ sort: None,
5252+ expand: None,
5353+ filter: None,
5454+ skip_total: false,
5555+ _marker: std::marker::PhantomData,
5656+ }
5757+ }
5858+}
5959+6060+impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetListBuilder<'a, T> {
6161+ /// The page (aka. offset) of the paginated list (default to 1).
6262+ pub fn page(mut self, page: u16) -> Self {
6363+ self.page = Some(page.to_string());
6464+ self
6565+ }
6666+6767+ /// Set the max returned records per page (default: 30, max: 500).
6868+ pub fn per_page(mut self, per_page: u16) -> Self {
6969+ self.per_page = Some(per_page.to_string());
7070+ self
7171+ }
7272+7373+ /// Specify the records order attribute(s).
7474+ /// Add `-`/`+` (default) in front of the attribute for DESC / ASC order.
7575+ ///
7676+ /// # Example
7777+ /// ```rust,ignore
7878+ /// .sort("-created,id") // DESC by created, ASC by id
7979+ /// ```
8080+ pub const fn sort(mut self, sort: &'a str) -> Self {
8181+ self.sort = Some(sort);
8282+ self
8383+ }
8484+8585+ /// Filter the returned records.
8686+ ///
8787+ /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~`
8888+ /// and their "any/at least one" variants with `?` prefix.
8989+ /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping.
9090+ ///
9191+ /// # Example
9292+ /// ```rust,ignore
9393+ /// .filter("language='en' && created>'1970-01-01'")
9494+ /// ```
9595+ pub const fn filter(mut self, filter: &'a str) -> Self {
9696+ self.filter = Some(filter);
9797+ self
9898+ }
9999+100100+ /// Auto expand record relations (up to 6-levels deep).
101101+ ///
102102+ /// Expanded relations are appended under the `expand` property.
103103+ /// Only relations the user has view permissions for will be expanded.
104104+ ///
105105+ /// # Example
106106+ /// ```rust,ignore
107107+ /// .expand("author")
108108+ /// ```
109109+ pub const fn expand(mut self, expand: &'a str) -> Self {
110110+ self.expand = Some(expand);
111111+ self
112112+ }
113113+114114+ /// Skip total count query for better performance.
115115+ ///
116116+ /// When enabled, `totalItems` and `totalPages` will be `-1`.
117117+ /// Useful for cursor pagination or when totals aren't needed.
118118+ pub const fn skip_total(mut self, skip_total: bool) -> Self {
119119+ self.skip_total = skip_total;
120120+ self
121121+ }
122122+123123+ /// Execute the request and return the paginated results.
124124+ pub async fn call(self) -> Result<RecordList<T>, RequestError> {
125125+ let url = format!(
126126+ "{}/api/collections/{}/records",
127127+ self.client.base_url, self.collection_name
128128+ );
129129+130130+ let mut query_parameters: Vec<(&str, &str)> = vec![];
131131+132132+ if let Some(page) = self.page.as_deref() {
133133+ query_parameters.push(("page", page));
134134+ }
135135+136136+ if let Some(per_page) = self.per_page.as_deref() {
137137+ query_parameters.push(("perPage", per_page));
138138+ }
139139+140140+ if let Some(sort) = self.sort {
141141+ query_parameters.push(("sort", sort));
142142+ }
143143+144144+ if let Some(filter) = self.filter {
145145+ query_parameters.push(("filter", filter));
146146+ }
147147+148148+ if let Some(expand) = self.expand {
149149+ query_parameters.push(("expand", expand));
150150+ }
151151+152152+ let request = self
153153+ .client
154154+ .request_get(&url, Some(query_parameters))
155155+ .send()
156156+ .await;
157157+158158+ let response = match request {
159159+ Ok(response) => response
160160+ .error_for_status()
161161+ .map_err(|err| match err.status() {
162162+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
163163+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
164164+ Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests,
165165+ _ => RequestError::Unhandled,
166166+ })?,
167167+ Err(error) => {
168168+ return Err(match error.status() {
169169+ Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden,
170170+ Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound,
171171+ Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests,
172172+ _ => RequestError::Unhandled,
173173+ });
174174+ }
175175+ };
176176+177177+ // Parse JSON response
178178+ let records = response
179179+ .json::<RecordList<T>>()
180180+ .await
181181+ .map_err(|error| RequestError::ParseError(error.to_string()))?;
182182+183183+ Ok(records)
184184+ }
185185+}
···11+pub mod create;
22+pub mod delete;
33+mod get_first_list_item;
44+mod get_full_list;
55+mod get_list;
66+mod get_one;
77+pub mod update;
+158
src/records/crud/update.rs
···11+use serde::{Deserialize, Serialize};
22+use thiserror::Error;
33+44+use crate::error::{BadRequestError, BadRequestResponse};
55+use crate::{Collection, PocketBase};
66+77+/// Represents the various errors that can be obtained after a `update` request.
88+#[derive(Error, Debug)]
99+pub enum UpdateError {
1010+ /// Communication with the `PocketBase` API was successful,
1111+ /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response.
1212+ ///
1313+ /// One or more fields were not validated `PocketBase`.
1414+ #[error("One or more fields were not validated : {0:?}")]
1515+ BadRequest(Vec<BadRequestError>),
1616+ /// Communication with the `PocketBase` API was successful,
1717+ /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response.
1818+ ///
1919+ /// The authorized record is not allowed to perform this action.
2020+ #[error("The authorized record is not allowed to perform this action.")]
2121+ Forbidden,
2222+ /// Communication with the `PocketBase` API was successful,
2323+ /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response.
2424+ ///
2525+ /// The requested resource wasn't found. Missing collection context.
2626+ #[error("The requested resource wasn't found. Missing collection context.")]
2727+ NotFound,
2828+ /// Communication with the `PocketBase` API failed.
2929+ ///
3030+ /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK
3131+ /// and similar errors.
3232+ #[error("The communication with the PocketBase API failed: {0}")]
3333+ Unreachable(String),
3434+ /// The response could not be parsed into the expected data structure.
3535+ #[error(
3636+ "Could not parse response into the expected data structure. It usually means that there is a missmatch between the provided Generic Type Parameter and your Collection definition: {0}"
3737+ )]
3838+ ParseError(String),
3939+ /// The response from the `PocketBase` instance API was unexpected.
4040+ /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues").
4141+ #[error("An unhandled status code was returned by the PocketBase API: {0}")]
4242+ UnexpectedResponse(String),
4343+}
4444+4545+pub struct CollectionUpdateBuilder<'a, T: Send + Serialize + Deserialize<'a>> {
4646+ client: &'a PocketBase,
4747+ collection_name: &'a str,
4848+ record_id: &'a str,
4949+ data: T,
5050+ _marker: std::marker::PhantomData<T>,
5151+}
5252+5353+// TODO: Include the actual record data based on Generic type parameter.
5454+//
5555+// pub struct UpdateResponse<T> {
5656+// pub collection_name: String,
5757+// pub collection_id: String,
5858+// pub id: String,
5959+// pub updated: String,
6060+// pub created: String,
6161+// #[serde(flatten)]
6262+// pub record: T, // The actual record data
6363+// }
6464+6565+/// Contains information about the successfully updated Record
6666+#[derive(Deserialize, Clone, Debug)]
6767+#[serde(rename_all = "camelCase")]
6868+pub struct UpdateResponse {
6969+ pub collection_name: String,
7070+ pub collection_id: String,
7171+ pub id: String,
7272+ pub updated: String,
7373+ pub created: String,
7474+}
7575+7676+impl<'a> Collection<'a> {
7777+ /// Update a single record.
7878+ ///
7979+ /// # Example
8080+ /// ```rust,ignore
8181+ /// #[derive(Default, Serialize, Clone, Debug)]
8282+ /// struct Article {
8383+ /// name: String,
8484+ /// content: String,
8585+ /// }
8686+ ///
8787+ /// let updated_article = Article {
8888+ /// name: String::from("Updated Article Title"),
8989+ /// content: String::from("Updated article content"),
9090+ /// };
9191+ ///
9292+ /// let response = pb
9393+ /// .collection("articles")
9494+ /// .update::<Article>("record_id_123", updated_article)
9595+ /// .await?;
9696+ /// ```
9797+ pub async fn update<T: Default + Serialize + Clone + Send>(
9898+ self,
9999+ record_id: &'a str,
100100+ record: T,
101101+ ) -> Result<UpdateResponse, UpdateError> {
102102+ let collection_name = self.name;
103103+104104+ let endpoint = format!(
105105+ "{}/api/collections/{}/records/{}",
106106+ self.client.base_url, collection_name, record_id
107107+ );
108108+109109+ let request = self
110110+ .client
111111+ .request_patch_json(&endpoint, &record)
112112+ .send()
113113+ .await;
114114+115115+ match request {
116116+ Ok(response) => match response.status() {
117117+ reqwest::StatusCode::OK => {
118118+ let data = response.json::<UpdateResponse>().await;
119119+120120+ match data {
121121+ Ok(data) => Ok(data),
122122+ Err(error) => Err(UpdateError::ParseError(error.to_string())),
123123+ }
124124+ }
125125+126126+ reqwest::StatusCode::BAD_REQUEST => {
127127+ let data = response.json::<BadRequestResponse>().await;
128128+129129+ match data {
130130+ Ok(bad_response) => {
131131+ let mut errors: Vec<BadRequestError> = vec![];
132132+133133+ for (error_name, error_data) in bad_response.data {
134134+ errors.push(BadRequestError {
135135+ name: error_name,
136136+ code: error_data.code,
137137+ message: error_data.message,
138138+ });
139139+ }
140140+141141+ Err(UpdateError::BadRequest(errors))
142142+ }
143143+ Err(error) => Err(UpdateError::ParseError(error.to_string())),
144144+ }
145145+ }
146146+147147+ reqwest::StatusCode::FORBIDDEN => Err(UpdateError::Forbidden),
148148+ reqwest::StatusCode::NOT_FOUND => Err(UpdateError::NotFound),
149149+150150+ _ => Err(UpdateError::UnexpectedResponse(
151151+ response.status().to_string(),
152152+ )),
153153+ },
154154+155155+ Err(error) => Err(UpdateError::Unreachable(error.to_string())),
156156+ }
157157+ }
158158+}