A Rust wrapper around PocketBase Rest API's REST API.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

klaus d9c43042

+2532
+6
.gitignore
··· 1 + # Rust specific ignores 2 + /target 3 + /Cargo.lock 4 + 5 + # Ignore Mac system files 6 + .DS_Store
+22
Cargo.toml
··· 1 + [package] 2 + name = "pocketbase-rs" 3 + version = "0.1.2" 4 + edition = "2024" 5 + license = "MIT OR Apache-2.0" 6 + authors = ["Klaus <klaus@fromhorizons.com>"] 7 + repository = "https://github.com/fromhorizons/pocketbase-rs" 8 + description = "A simple wrapper around PocketBase's Rest API. Uses the Builder Pattern." 9 + readme = "README.md" 10 + keywords = ["pocketbase"] 11 + 12 + [dependencies] 13 + chrono = { version = "0.4.38", features = ["serde"] } 14 + reqwest = { version = "0.12.9", features = ["cookies", "json", "multipart"] } 15 + serde = { version = "1.0.214", features = ["derive"] } 16 + serde_json = "1.0.132" 17 + thiserror = "2.0.3" 18 + time = { version = "0.3.36", features = ["serde"] } 19 + 20 + [dev-dependencies] 21 + httpmock = "0.7.0" 22 + tokio = { version = "1.41.1", features = ["full"] }
+176
LICENSE-APACHE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS
+19
LICENSE-MIT
··· 1 + MIT License 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy 4 + of this software and associated documentation files (the "Software"), to deal 5 + in the Software without restriction, including without limitation the rights 6 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 + copies of the Software, and to permit persons to whom the Software is 8 + furnished to do so, subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included in all 11 + copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 + SOFTWARE.
+78
README.md
··· 1 + # pocketbase-rs 2 + 3 + A Rust wrapper around [PocketBase Rest API](https://pocketbase.io/)'s REST API. 4 + 5 + ## Usage 6 + 7 + 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. 8 + 9 + ```rust 10 + use std::error::Error; 11 + 12 + use pocketbase_rs::PocketBase; 13 + use serde::{Deserialize, Serialize}; 14 + 15 + #[derive(Default, Serialize, Deserialize, Clone, Debug)] 16 + pub struct Article { 17 + name: String, 18 + content: String, 19 + } 20 + 21 + #[tokio::main] 22 + async fn main() -> Result<(), Error> { 23 + let mut pb = PocketBase::new("http://localhost:8090"); 24 + 25 + // Authenticate the new client 26 + let auth_data = pb 27 + .collection("users") 28 + .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD") 29 + .await?; 30 + 31 + // Create new record 32 + let new_record = pb 33 + .collection("articles") 34 + .create::<Article>(Article { 35 + name: "Vulpes Vulpes".to_string(), 36 + 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(), 37 + }) 38 + .await?; 39 + 40 + println!("Created article: {:?}", new_record); 41 + 42 + // Get records list 43 + 44 + let records = pb 45 + .collection("articles") 46 + .get_list::<Article>() 47 + .sort("-created,id") 48 + .call() 49 + .await?; 50 + 51 + for record in records.items { 52 + println!("{record:?}"); 53 + } 54 + 55 + Ok(()) 56 + } 57 + ``` 58 + 59 + ## Note 60 + 61 + Not all SDK features are implemented yet and are generally added when needed for other projects. 62 + PRs aimed at adding these missing features, as well as other additions and fixes, are more than welcome. 63 + 64 + This crate was last tested on `PocketBase` version `0.34.2`. 65 + 66 + ## Licence 67 + 68 + This project is free and open source. All code in this repository is dual-licensed under either: 69 + 70 + - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 71 + <http://www.apache.org/licenses/LICENSE-2.0>) 72 + - MIT license ([LICENSE-MIT](LICENSE-MIT) or 73 + <http://opensource.org/licenses/MIT>) 74 + 75 + at your option. 76 + 77 + Unless you explicitly state otherwise, any contribution intentionally submitted 78 + 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
··· 1 + //! Various errors module. 2 + 3 + use core::fmt; 4 + use std::collections::HashMap; 5 + 6 + use serde::Deserialize; 7 + use thiserror::Error; 8 + 9 + pub use crate::records::auth::auth_with_password::AuthenticationError; 10 + pub use crate::records::auth::impersonate::ImpersonateError; 11 + pub use crate::records::crud::create::CreateError; 12 + pub use crate::records::crud::update::UpdateError; 13 + 14 + /// This error represents the error returned by the `PocketBase` 15 + /// instance in case of a 400 error. 16 + #[derive(Deserialize, Debug)] 17 + pub struct BadRequestResponse { 18 + /// HTTP Status Code. 19 + pub status: u16, 20 + /// Description from given by `PocketBase` about why the error happened. 21 + pub message: String, 22 + /// A list of fields that caused the error. 23 + pub data: HashMap<String, BadRequestField>, 24 + } 25 + 26 + /// Represents an instance of one of the errors that could be returned on a bad request. 27 + /// 28 + /// This struct holds detailed information about a single validation error, 29 + /// including the field name, error code, and a user-friendly message. 30 + #[derive(Deserialize, Debug)] 31 + pub struct BadRequestError { 32 + /// Name of the field. 33 + pub name: String, 34 + /// Error code. 35 + pub code: String, 36 + /// More details about the error. 37 + pub message: String, 38 + } 39 + 40 + impl fmt::Display for BadRequestError { 41 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 + write!(f, "{}: {} {}", self.name, self.code, self.message) 43 + } 44 + } 45 + 46 + /// Represents one of the fields that caused the Bad Request error. 47 + #[derive(Deserialize, Debug)] 48 + pub struct BadRequestField { 49 + /// Error code *(example: `validation_required`)*. 50 + pub code: String, 51 + /// A text explaining in a readable way what this error is. 52 + pub message: String, 53 + } 54 + 55 + /// Represents errors when interacting with the `PocketBase` API. 56 + /// 57 + /// This enum provides a set of error types that may occur during 58 + /// API requests, each indicating a specific issue encountered. 59 + #[derive(Error, Debug)] 60 + pub enum RequestError { 61 + /// Communication with the `PocketBase` API was successful, 62 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 63 + /// 64 + /// Your request may be missing fields or its content doesn't match what `PocketBase` expects to receive. 65 + #[error("Bad Request: Something went wrong while processing your request. {0}")] 66 + BadRequest(String), 67 + /// Communication with the `PocketBase` API was successful, 68 + /// but returned a [401 Unauthorized]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401") HTTP error response. 69 + /// 70 + /// The request may require an Authorization Token. 71 + #[error("Unauthorized: The request may require an Authorization Token.")] 72 + Unauthorized, 73 + /// Communication with the `PocketBase` API was successful, 74 + /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response. 75 + /// 76 + /// The authenticated user may not have permissions for this interaction. 77 + #[error("Forbidden: The authenticated user may not have permissions for this interaction.")] 78 + Forbidden, 79 + /// Communication with the `PocketBase` API was successful, 80 + /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response. 81 + #[error("Not Found: The requested resource could not be found.")] 82 + NotFound, 83 + /// The response could not be parsed into the expected data structure. 84 + #[error( 85 + "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}" 86 + )] 87 + ParseError(String), 88 + /// The `PocketBase` API interaction timed out. It may be offline. 89 + #[error( 90 + "Unreachable: The PocketBase API interaction timed out, or the service may be offline." 91 + )] 92 + Unreachable, 93 + /// Too many requests were sent to the API. 94 + /// 95 + /// The server is rate limiting requests. Wait before retrying. 96 + #[error( 97 + "Too Many Requests: The server is rate limiting requests. Please wait before retrying." 98 + )] 99 + TooManyRequests, 100 + /// Unhandled error. 101 + /// 102 + /// Usually emitted when something unexpected happened, and isn't handled correctly by this crate. 103 + #[error("Unhandled Error: An unexpected error occurred.")] 104 + Unhandled, 105 + }
+495
src/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 + 45 + pub use error::*; 46 + pub use records::auth::{AuthStore, AuthStoreRecord}; 47 + use reqwest::RequestBuilder; 48 + pub use reqwest::multipart::{Form, Part}; 49 + use serde::{Deserialize, Serialize}; 50 + 51 + pub mod error; 52 + pub(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. 66 + pub struct Collection<'a> { 67 + pub(crate) client: &'a mut PocketBase, 68 + pub(crate) name: &'a str, 69 + } 70 + 71 + impl 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")] 146 + pub 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)] 161 + pub(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)] 207 + pub struct PocketBase { 208 + pub(crate) base_url: String, 209 + pub(crate) auth_store: Option<AuthStore>, 210 + pub(crate) reqwest_client: reqwest::Client, 211 + } 212 + 213 + impl 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 + 226 + impl 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 + 350 + impl PocketBase { 351 + /// Adds an authorization token to the request, if available. 352 + /// 353 + /// This method attaches a bearer authentication token to the provided `RequestBuilder` 354 + /// if the client is currently authenticated. If no token is available, the request is 355 + /// returned unchanged. 356 + /// 357 + /// # Arguments 358 + /// * `request_builder` - A `reqwest::RequestBuilder` to which the token will be added. 359 + /// 360 + /// # Returns 361 + /// A `reqwest::RequestBuilder` with the authorization token, if applicable. 362 + pub(crate) fn with_authorization_token( 363 + &self, 364 + request_builder: reqwest::RequestBuilder, 365 + ) -> reqwest::RequestBuilder { 366 + if let Some(auth_store) = self.auth_store() { 367 + request_builder.bearer_auth(auth_store.token) 368 + } else { 369 + request_builder 370 + } 371 + } 372 + 373 + /// Creates a POST request builder for the specified endpoint. 374 + /// 375 + /// This method initializes a `POST` request to the given endpoint and adds 376 + /// an authorization token if available. 377 + /// 378 + /// # Arguments 379 + /// * `endpoint` - The API endpoint to send the `POST` request to. 380 + /// 381 + /// # Returns 382 + /// A `reqwest::RequestBuilder` for the `POST` request. 383 + pub(crate) fn request_post(&self, endpoint: &str) -> RequestBuilder { 384 + let request_builder = self.reqwest_client.post(endpoint); 385 + self.with_authorization_token(request_builder) 386 + } 387 + 388 + /// Creates a PATCH request builder with JSON body for the specified endpoint. 389 + /// 390 + /// This method initializes a `PATCH` request to the given endpoint with a JSON body, 391 + /// and adds an authorization token if available. 392 + /// 393 + /// # Arguments 394 + /// * `endpoint` - The API endpoint to send the `PATCH` request to. 395 + /// * `params` - A reference to a serializable type to use as the JSON body of the request. 396 + /// 397 + /// # Returns 398 + /// A `reqwest::RequestBuilder` for the `PATCH` request. 399 + pub(crate) fn request_patch_json<T: Default + Serialize + Clone + Send>( 400 + &self, 401 + endpoint: &str, 402 + params: &T, 403 + ) -> RequestBuilder { 404 + let request_builder = self.reqwest_client.patch(endpoint).json(&params); 405 + self.with_authorization_token(request_builder) 406 + } 407 + 408 + /// Creates a POST request builder with JSON body for the specified endpoint. 409 + /// 410 + /// This method initializes a `POST` request to the given endpoint with a JSON body, 411 + /// and adds an authorization token if available. 412 + /// 413 + /// # Arguments 414 + /// * `endpoint` - The API endpoint to send the `POST` request to. 415 + /// * `params` - A reference to a serializable type to use as the JSON body of the request. 416 + /// 417 + /// # Returns 418 + /// A `reqwest::RequestBuilder` for the `POST` request. 419 + pub(crate) fn request_post_json<T: Default + Serialize + Clone + Send>( 420 + &self, 421 + endpoint: &str, 422 + params: &T, 423 + ) -> RequestBuilder { 424 + let request_builder = self.reqwest_client.post(endpoint).json(&params); 425 + self.with_authorization_token(request_builder) 426 + } 427 + 428 + /// Creates a POST request builder with a form body for the specified endpoint. 429 + /// 430 + /// This method initializes a `POST` request to the given endpoint with a multipart form body, 431 + /// and adds an authorization token if available. 432 + /// 433 + /// # Arguments 434 + /// * `endpoint` - The API endpoint to send the `POST` request to. 435 + /// * `form` - A `reqwest::multipart::Form` representing the form data for the request. 436 + /// 437 + /// # Returns 438 + /// A `reqwest::RequestBuilder` for the `POST` request. 439 + pub(crate) fn request_post_form(&self, endpoint: &str, form: Form) -> RequestBuilder { 440 + let request_builder = self.reqwest_client.post(endpoint).multipart(form); 441 + self.with_authorization_token(request_builder) 442 + } 443 + 444 + /// Creates a GET request builder for the specified endpoint. 445 + /// 446 + /// This method initializes a `GET` request to the given endpoint, adds an `Accept` header 447 + /// for JSON responses, attaches query parameters if provided, and adds an authorization 448 + /// token if available. 449 + /// 450 + /// # Arguments 451 + /// * `endpoint` - The API endpoint to send the `GET` request to. 452 + /// * `params` - An optional vector of key-value pairs to include as query parameters. 453 + /// 454 + /// # Returns 455 + /// A `reqwest::RequestBuilder` for the `GET` request. 456 + pub(crate) fn request_get( 457 + &self, 458 + endpoint: &str, 459 + params: Option<Vec<(&str, &str)>>, 460 + ) -> RequestBuilder { 461 + let mut request_builder = self 462 + .reqwest_client 463 + .get(endpoint) 464 + .header("Accept", "application/json"); 465 + 466 + if let Some(params) = params { 467 + request_builder = request_builder.query(&params); 468 + } 469 + 470 + self.with_authorization_token(request_builder) 471 + } 472 + 473 + /// Creates a DELETE request builder for the specified endpoint. 474 + /// 475 + /// This method initializes a `DELETE` request to the given endpoint and adds 476 + /// an authorization token if available. 477 + /// 478 + /// # Arguments 479 + /// * `endpoint` - The API endpoint to send the `DELETE` request to. 480 + /// 481 + /// # Returns 482 + /// A `reqwest::RequestBuilder` for the `DELETE` request. 483 + /// 484 + /// # Example 485 + /// ```rust,ignore 486 + /// let pb = PocketBase::new("http://localhost:8090"); 487 + /// 488 + /// let request = pb.request_delete("http://localhost:8090/api/collections/articles/record_id"); 489 + /// ``` 490 + pub(crate) fn request_delete(&self, endpoint: &str) -> RequestBuilder { 491 + let request_builder = self.reqwest_client.delete(endpoint); 492 + 493 + self.with_authorization_token(request_builder) 494 + } 495 + }
+47
src/records/auth/auth_refresh.rs
··· 1 + use crate::error::RequestError; 2 + use crate::{AuthStore, Collection}; 3 + 4 + impl Collection<'_> { 5 + /// Returns a new auth response (token and record data) for an **already authenticated record**. 6 + /// 7 + /// 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. 8 + /// 9 + /// # Example 10 + /// ```rust,ignore 11 + /// let auth_data = pb.collection("users") 12 + /// .auth_refresh() 13 + /// .await?; 14 + /// 15 + /// println!("New token: {}", auth_data.token); 16 + /// ``` 17 + pub async fn auth_refresh(&mut self) -> Result<AuthStore, RequestError> { 18 + let url = format!( 19 + "{}/api/collections/{}/auth-refresh", 20 + self.client.base_url(), 21 + self.name 22 + ); 23 + 24 + let request = self.client.request_post(&url).send().await; 25 + 26 + match request { 27 + Ok(response) => match response.status() { 28 + reqwest::StatusCode::OK => { 29 + let Ok(auth_store) = response.json::<AuthStore>().await else { 30 + return Err(RequestError::Unhandled); 31 + }; 32 + 33 + self.client.update_auth_store(auth_store.clone()); 34 + 35 + Ok(auth_store) 36 + } 37 + 38 + reqwest::StatusCode::UNAUTHORIZED => Err(RequestError::Unauthorized), 39 + reqwest::StatusCode::FORBIDDEN => Err(RequestError::Forbidden), 40 + reqwest::StatusCode::NOT_FOUND => Err(RequestError::NotFound), 41 + 42 + _ => Err(RequestError::Unhandled), 43 + }, 44 + Err(_) => Err(RequestError::Unhandled), 45 + } 46 + } 47 + }
+60
src/records/auth/auth_refresh_for_user.rs
··· 1 + use crate::error::RequestError; 2 + use crate::{AuthStore, Collection}; 3 + 4 + impl<'a> Collection<'a> { 5 + /// Refresh the authentication token for a specific user. 6 + /// 7 + /// Useful when managing tokens for other users (e.g., as a superuser). 8 + /// 9 + /// # Example 10 + /// ```rust,ignore 11 + /// let auth_data = pb 12 + /// .collection("users") 13 + /// .auth_refresh_for_user("USER_TOKEN") 14 + /// .await?; 15 + /// 16 + /// println!("New token: {}", auth_data.token); 17 + /// ``` 18 + pub async fn auth_refresh_for_user( 19 + &mut self, 20 + user_token: &'a str, 21 + ) -> Result<AuthStore, RequestError> { 22 + let url = format!( 23 + "{}/api/collections/{}/auth-refresh", 24 + self.client.base_url(), 25 + self.name 26 + ); 27 + 28 + // Usually we would do `let request = self.client.request_post(&url).bearer_auth(user_token).send().await;`, 29 + // but in our wrapper methods around `Reqwest`, we already use the `.bearer_auth()` method on our 30 + // `RequestBuilder` with the token of the currently logged in user. 31 + // When we try to reuse `.bearer_auth()` for a second time, for example here to put the **Token** of 32 + // the user to re-authenticate, it seems to be ignored. We could probably rewrite our wrapper methods, but honestly, I'm too lazy. 33 + let request = self 34 + .client 35 + .reqwest_client 36 + .post(&url) 37 + .bearer_auth(user_token) 38 + .send() 39 + .await; 40 + 41 + match request { 42 + Ok(response) => match response.status() { 43 + reqwest::StatusCode::OK => { 44 + let Ok(auth_store) = response.json::<AuthStore>().await else { 45 + return Err(RequestError::Unhandled); 46 + }; 47 + 48 + Ok(auth_store) 49 + } 50 + 51 + reqwest::StatusCode::UNAUTHORIZED => Err(RequestError::Unauthorized), 52 + reqwest::StatusCode::FORBIDDEN => Err(RequestError::Forbidden), 53 + reqwest::StatusCode::NOT_FOUND => Err(RequestError::NotFound), 54 + 55 + _ => Err(RequestError::Unhandled), 56 + }, 57 + Err(_) => Err(RequestError::Unhandled), 58 + } 59 + } 60 + }
+190
src/records/auth/auth_with_password.rs
··· 1 + use serde::Serialize; 2 + use serde_json::Value; 3 + use thiserror::Error; 4 + 5 + use crate::{AuthStore, Collection, ErrorResponse}; 6 + 7 + #[derive(Clone, Default, Serialize)] 8 + struct Credentials<'a> { 9 + pub(crate) identity: &'a str, 10 + pub(crate) password: &'a str, 11 + } 12 + 13 + /// Represents errors that can occur during the authentication process with the `PocketBase` API. 14 + /// 15 + /// This enum defines various error types that may arise when attempting to authenticate, 16 + /// each providing details about the specific issue encountered. 17 + #[derive(Error, Debug)] 18 + pub enum AuthenticationError { 19 + /// Communication with the `PocketBase` API was successful, 20 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 21 + /// 22 + /// Tip: The credentials you provided may be incorrect. 23 + #[error("Authentication failed: Invalid Credentials. Given email and/or password is wrong.")] 24 + InvalidCredentials, 25 + /// Email and/or Password cannot be empty. 26 + /// 27 + /// This variant indicates that certain fields in the authentication request need to be validated. 28 + /// The fields are represented as booleans: 29 + /// 30 + /// - `identity`: is blank and shouldn't be. 31 + /// - `password`: is blank and shouldn't be. 32 + #[error("Authentication failed: Empty Credential Field. Given email and/or password is empty.")] 33 + EmptyField { 34 + /// Is identity blank. 35 + identity: bool, 36 + /// Is password blank. 37 + password: bool, 38 + }, 39 + /// The provided identity must be an email address. 40 + /// 41 + /// This variant indicates that the authentication request failed because the provided identity 42 + /// does not conform to the expected email format. The `PocketBase` API requires the identity to 43 + /// be a valid email address for authentication. 44 + #[error("Authentication failed. Given identity is not a valid email.")] 45 + IdentityMustBeEmail, 46 + /// An HTTP error occurred while communicating with the `PocketBase` API. 47 + /// 48 + /// This variant wraps a [`reqwest::Error`] and indicates that the request could not be completed 49 + /// due to network issues, invalid URL, timeouts, etc. 50 + #[error("Authentication failed. Couldn't reach the PocketBase API: {0}")] 51 + HttpError(reqwest::Error), 52 + /// When something unexpected was returned by the `PocketBase` REST API. 53 + /// 54 + /// Would usually mean that there is an error somewhere in this API wrapper. 55 + #[error( 56 + "Authentication failed due to an unexpected response. Usually means a problem in the PocketBase API's wrapper." 57 + )] 58 + UnexpectedResponse, 59 + /// Occurs when you try to authenticate a `PocketBase` client without providing the collection name. 60 + #[error( 61 + "Authentication failed due to missing collection name. [Example: PocketBaseClientBuilder::new(\"\")" 62 + )] 63 + MissingCollection, 64 + } 65 + 66 + impl From<reqwest::Error> for AuthenticationError { 67 + fn from(error: reqwest::Error) -> Self { 68 + Self::HttpError(error) 69 + } 70 + } 71 + 72 + impl Collection<'_> { 73 + /// Authenticate with combination of **email**/**username** and **password**. 74 + /// 75 + /// On success, the auth token is automatically stored and used for subsequent requests. 76 + /// 77 + /// # Example 78 + /// ```rust,ignore 79 + /// let auth_data = pb.collection("users") 80 + /// .auth_with_password("YOUR_EMAIL_OR_USERNAME", "YOUR_PASSWORD") 81 + /// .await?; 82 + /// 83 + /// println!("Token: {}", auth_data.token); 84 + /// ``` 85 + pub async fn auth_with_password( 86 + &mut self, 87 + identity: &str, 88 + password: &str, 89 + ) -> Result<AuthStore, AuthenticationError> { 90 + let uri = format!( 91 + "{}/api/collections/{}/auth-with-password", 92 + self.client.base_url, self.name 93 + ); 94 + 95 + let credentials = Credentials { identity, password }; 96 + 97 + let response = self 98 + .client 99 + .request_post_json(&uri, &credentials) 100 + .send() 101 + .await?; 102 + 103 + if response.status().is_success() { 104 + let auth_store = response.json::<AuthStore>().await?; 105 + 106 + self.client.update_auth_store(auth_store.clone()); 107 + 108 + return Ok(auth_store); 109 + } 110 + 111 + if response.status() == reqwest::StatusCode::BAD_REQUEST { 112 + let error_response: ErrorResponse = 113 + response.json().await.unwrap_or_else(|_| ErrorResponse { 114 + code: 400, 115 + message: "Unknown error".to_string(), 116 + data: None, 117 + }); 118 + 119 + if let Some(ref data) = error_response.data { 120 + // { 121 + // "code": 400, 122 + // "message": "Failed to authenticate.", 123 + // "data": {} 124 + // } 125 + if data.as_object().is_some_and(serde_json::Map::is_empty) { 126 + return Err(AuthenticationError::InvalidCredentials); 127 + } 128 + 129 + // Check for specific field validation errors 130 + let identity_error = data 131 + .get("identity") 132 + .and_then(|v| v.get("code").and_then(Value::as_str)); 133 + 134 + match identity_error { 135 + // { 136 + // "code": 400, 137 + // "message": "Something went wrong while processing your request.", 138 + // "data": { 139 + // "identity": { 140 + // "code": "validation_is_email", 141 + // "message": "Must be a valid email address." 142 + // } 143 + // } 144 + // } 145 + Some("validation_is_email") => { 146 + return Err(AuthenticationError::IdentityMustBeEmail); 147 + } 148 + 149 + // { 150 + // "code": 400, 151 + // "message": "Something went wrong while processing your request.", 152 + // "data": { 153 + // "identity": { 154 + // "code": "validation_required", 155 + // "message": "Cannot be blank." 156 + // }, 157 + // "password": { 158 + // "code": "validation_required", 159 + // "message": "Cannot be blank." 160 + // } 161 + // } 162 + // } 163 + Some("validation_required") => { 164 + return Err(AuthenticationError::EmptyField { 165 + identity: identity_error.is_some(), 166 + password: data.get("password").is_some(), 167 + }); 168 + } 169 + None => { 170 + let password_error = data.get("password").is_some(); 171 + return Err(AuthenticationError::EmptyField { 172 + identity: false, 173 + password: password_error, 174 + }); 175 + } 176 + _ => {} 177 + } 178 + } 179 + 180 + // { 181 + // "code": 400, 182 + // "message": "Failed to authenticate.", 183 + // "data": {} 184 + // } 185 + return Err(AuthenticationError::InvalidCredentials); 186 + } 187 + 188 + Err(AuthenticationError::UnexpectedResponse) 189 + } 190 + }
+161
src/records/auth/impersonate.rs
··· 1 + use serde::Deserialize; 2 + use thiserror::Error; 3 + 4 + use super::AuthStore; 5 + use crate::{Collection, PocketBase}; 6 + 7 + /// Represents the various errors that can be obtained after a `impersonate` request. 8 + #[derive(Error, Debug)] 9 + pub enum ImpersonateError { 10 + /// Communication with the `PocketBase` API was successful, 11 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 12 + /// 13 + /// The request requires valid record authorization token to be set. 14 + #[error("Bad Request: The request requires valid record authorization token to be set.")] 15 + BadRequest, 16 + /// Communication with the `PocketBase` API was successful, 17 + /// but returned a [401 Unauthorized]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401") HTTP error response. 18 + /// 19 + /// The request requires valid record authorization token. 20 + #[error("The request requires valid record authorization token.")] 21 + Unauthorized, 22 + /// Communication with the `PocketBase` API was successful, 23 + /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response. 24 + /// 25 + /// The authorized record is not allowed to perform this action. 26 + /// Are you impersonating a user from a non-superuser account? 27 + #[error( 28 + "The authorized record is not allowed to perform this action. Are you impersonating a user from a non-superuser account?" 29 + )] 30 + Forbidden, 31 + /// Communication with the `PocketBase` API was successful, 32 + /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response. 33 + /// 34 + /// The requested resource wasn't found. 35 + /// The given user id is probably wrong. 36 + #[error("The requested resource wasn't found.")] 37 + NotFound, 38 + /// Communication with the `PocketBase` API failed. 39 + /// 40 + /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK 41 + /// and similar errors. 42 + #[error("The communication with the PocketBase API failed: {0}")] 43 + Unreachable(String), 44 + /// The response from the `PocketBase` instance API was unexpected. 45 + /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues"). 46 + #[error("An unhandled status code was returned by the PocketBase API: {0}")] 47 + UnexpectedResponse(String), 48 + } 49 + 50 + #[derive(Deserialize)] 51 + struct AuthData { 52 + record: AuthDataRecord, 53 + token: String, 54 + } 55 + 56 + #[derive(Default, Clone, Deserialize)] 57 + #[serde(rename_all = "camelCase")] 58 + struct AuthDataRecord { 59 + collection_id: String, 60 + collection_name: String, 61 + id: String, 62 + email: String, 63 + email_visibility: bool, 64 + verified: bool, 65 + created: String, 66 + updated: String, 67 + } 68 + 69 + pub struct CollectionImpersonateBuilder<'a> { 70 + client: &'a PocketBase, 71 + collection_name: &'a str, 72 + user_id: &'a str, 73 + duration: Option<String>, 74 + } 75 + 76 + impl<'a> Collection<'a> { 77 + /// Authenticate as a different user by generating a non-refreshable auth token. 78 + /// 79 + /// Only superusers can perform this action. Returns a new `PocketBase` client 80 + /// with the impersonated user's auth token. 81 + /// 82 + /// # Example 83 + /// ```rust,ignore 84 + /// let impersonate_client = pb 85 + /// .collection("users") 86 + /// .impersonate("USER_RECORD_ID") 87 + /// .duration(3600) 88 + /// .call() 89 + /// .await?; 90 + /// 91 + /// println!("Token: {}", impersonate_client.auth_store().unwrap().token); 92 + /// ``` 93 + #[must_use] 94 + pub const fn impersonate(self, user_id: &'a str) -> CollectionImpersonateBuilder<'a> { 95 + CollectionImpersonateBuilder { 96 + client: self.client, 97 + collection_name: self.name, 98 + user_id, 99 + duration: None, 100 + } 101 + } 102 + } 103 + 104 + impl CollectionImpersonateBuilder<'_> { 105 + /// Set custom JWT duration in seconds (optional). 106 + /// 107 + /// If not set, uses the default collection auth token duration. 108 + pub fn duration(mut self, duration: u128) -> Self { 109 + self.duration = Some(duration.to_string()); 110 + self 111 + } 112 + 113 + /// Execute the request and return a new `PocketBase` client with the impersonated user's token. 114 + pub async fn call(self) -> Result<PocketBase, ImpersonateError> { 115 + let url = format!( 116 + "{}/api/collections/{}/impersonate/{}", 117 + self.client.base_url, self.collection_name, self.user_id 118 + ); 119 + 120 + let request = { 121 + if let Some(duration) = self.duration { 122 + self.client 123 + .request_post_form( 124 + &url, 125 + reqwest::multipart::Form::new().text("duration", duration), 126 + ) 127 + .send() 128 + .await 129 + } else { 130 + self.client.request_post(&url).send().await 131 + } 132 + }; 133 + 134 + match request { 135 + Ok(response) => match response.status() { 136 + reqwest::StatusCode::OK => { 137 + let Ok(auth_store) = response.json::<AuthStore>().await else { 138 + return Err(ImpersonateError::UnexpectedResponse( 139 + "Couldn't parse API response into Auth Data".to_string(), 140 + )); 141 + }; 142 + 143 + let mut impersonate_client = PocketBase::new(&self.client.base_url()); 144 + impersonate_client.update_auth_store(auth_store); 145 + 146 + Ok(impersonate_client) 147 + } 148 + 149 + reqwest::StatusCode::BAD_REQUEST => Err(ImpersonateError::BadRequest), 150 + reqwest::StatusCode::UNAUTHORIZED => Err(ImpersonateError::Unauthorized), 151 + reqwest::StatusCode::FORBIDDEN => Err(ImpersonateError::Forbidden), 152 + reqwest::StatusCode::NOT_FOUND => Err(ImpersonateError::NotFound), 153 + 154 + _ => Err(ImpersonateError::UnexpectedResponse( 155 + response.status().to_string(), 156 + )), 157 + }, 158 + Err(error) => Err(ImpersonateError::Unreachable(error.to_string())), 159 + } 160 + } 161 + }
+45
src/records/auth/mod.rs
··· 1 + use serde::Deserialize; 2 + 3 + pub mod auth_refresh; 4 + pub mod auth_refresh_for_user; 5 + pub mod auth_with_password; 6 + pub mod impersonate; 7 + pub mod request_verification; 8 + 9 + /// Stores authentication details for a `PocketBase` user. 10 + /// 11 + /// The `AuthStore` struct holds the authenticated user's record and a token 12 + /// used for making authenticated requests to the `PocketBase` API. 13 + #[derive(Clone, Debug, Deserialize)] 14 + pub struct AuthStore { 15 + /// The authenticated user's record. 16 + pub record: AuthStoreRecord, 17 + /// The authentication token. 18 + pub token: String, 19 + } 20 + 21 + /// Represents the details of an authenticated user's record. 22 + /// 23 + /// The `AuthStoreRecord` struct contains information about the user, 24 + /// such as their ID, email, etc. and other metadata related to the 25 + /// collection they belong to. 26 + #[derive(Clone, Debug, Deserialize)] 27 + #[serde(rename_all = "camelCase")] 28 + pub struct AuthStoreRecord { 29 + /// The user's unique ID. 30 + pub id: String, 31 + /// The ID of the collection the user belongs to. 32 + pub collection_id: String, 33 + /// The name of the collection the user belongs to. 34 + pub collection_name: String, 35 + /// The timestamp when the record was created. 36 + pub created: String, 37 + /// The timestamp when the record was last updated. 38 + pub updated: String, 39 + /// The user's email address. 40 + pub email: String, 41 + /// Indicates whether the user's email is publicly visible. 42 + pub email_visibility: bool, 43 + /// Indicates whether the user's email has been verified. 44 + pub verified: bool, 45 + }
+52
src/records/auth/request_verification.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use crate::Collection; 4 + use crate::error::RequestError; 5 + 6 + impl<'a> Collection<'a> { 7 + /// Sends users account verification request. 8 + /// 9 + /// # Example 10 + /// ```rust,ignore 11 + /// pb.collection("users") 12 + /// .request_verification("test@example.com") 13 + /// .await?; 14 + /// ``` 15 + pub async fn request_verification(&self, email: &'a str) -> Result<(), RequestError> { 16 + let url = format!( 17 + "{}/api/collections/{}/request-verification", 18 + self.client.base_url, self.name 19 + ); 20 + 21 + let email: HashMap<String, String> = HashMap::from([("email".to_string(), email.into())]); 22 + 23 + let request = (self.client.request_post_json(&url, &email)).send().await; 24 + 25 + match request { 26 + Ok(response) => match response.status() { 27 + reqwest::StatusCode::NO_CONTENT => Ok(()), 28 + reqwest::StatusCode::BAD_REQUEST => Err(RequestError::BadRequest(String::new())), 29 + reqwest::StatusCode::NOT_FOUND => Err(RequestError::NotFound), 30 + _ => Err(RequestError::Unhandled), 31 + }, 32 + Err(error) => { 33 + if let Some(error_status) = error.status() { 34 + match error_status { 35 + reqwest::StatusCode::UNAUTHORIZED => { 36 + return Err(RequestError::Unauthorized); 37 + } 38 + reqwest::StatusCode::FORBIDDEN => { 39 + return Err(RequestError::Forbidden); 40 + } 41 + reqwest::StatusCode::NOT_FOUND => { 42 + return Err(RequestError::NotFound); 43 + } 44 + _ => return Err(RequestError::Unhandled), 45 + } 46 + } 47 + 48 + Err(RequestError::Unhandled) 49 + } 50 + } 51 + } 52 + }
+194
src/records/crud/create.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use thiserror::Error; 3 + 4 + use crate::Collection; 5 + use crate::error::{BadRequestError, BadRequestResponse}; 6 + 7 + /// Represents the various errors that can be obtained after a `create` request. 8 + #[derive(Error, Debug)] 9 + pub enum CreateError { 10 + /// Communication with the `PocketBase` API was successful, 11 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 12 + /// 13 + /// Missing required value. `PocketBase`. 14 + #[error("Failed to create record: {0:?}")] 15 + BadRequest(Vec<BadRequestError>), 16 + /// Communication with the `PocketBase` API was successful, 17 + /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response. 18 + /// 19 + /// You are not allowed to perform this request. 20 + #[error("You are not allowed to perform this request.")] 21 + Forbidden, 22 + /// Communication with the `PocketBase` API was successful, 23 + /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response. 24 + /// 25 + /// The requested resource wasn't found. Missing collection context. 26 + #[error("The requested resource wasn't found. Missing collection context.")] 27 + NotFound, 28 + /// Communication with the `PocketBase` API failed. 29 + /// 30 + /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK 31 + /// and similar errors. 32 + #[error("The communication with the PocketBase API failed: {0}")] 33 + Unreachable(String), 34 + /// The response could not be parsed into the expected data structure. 35 + #[error( 36 + "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}" 37 + )] 38 + ParseError(String), 39 + /// An unexpected error occurred. 40 + /// The response from the `PocketBase` instance API was unexpected. 41 + /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues"). 42 + #[error("An unhandled status code was returned by the PocketBase API: {0}")] 43 + UnexpectedResponse(String), 44 + } 45 + 46 + // TODO: Include the actual record data based on Generic type parameter. 47 + // 48 + // pub struct CreateResponse<T> { 49 + // pub collection_name: String, 50 + // pub collection_id: String, 51 + // pub id: String, 52 + // pub updated: String, 53 + // pub created: String, 54 + // #[serde(flatten)] 55 + // pub record: T, // The actual record data 56 + // } 57 + 58 + /// Contains information about the successfully created Record 59 + #[derive(Deserialize, Clone, Debug)] 60 + #[serde(rename_all = "camelCase")] 61 + pub struct CreateResponse { 62 + pub collection_name: String, 63 + pub collection_id: String, 64 + pub id: String, 65 + pub updated: String, 66 + pub created: String, 67 + } 68 + 69 + impl Collection<'_> { 70 + /// Create a new record. 71 + /// 72 + /// For file uploads, use [`Collection::create_multipart()`]. 73 + /// 74 + /// # Example 75 + /// ```rust,ignore 76 + /// #[derive(Default, Serialize, Clone, Debug)] 77 + /// struct Article { 78 + /// name: String, 79 + /// content: String, 80 + /// } 81 + /// 82 + /// let article = pb 83 + /// .collection("articles") 84 + /// .create::<Article>(Article { 85 + /// name: "test".to_string(), 86 + /// content: "an interesting article content.".to_string(), 87 + /// }) 88 + /// .await?; 89 + /// ``` 90 + pub async fn create<T: Default + Serialize + Clone + Send>( 91 + self, 92 + record: T, 93 + ) -> Result<CreateResponse, CreateError> { 94 + let endpoint = format!( 95 + "{}/api/collections/{}/records", 96 + self.client.base_url, self.name 97 + ); 98 + 99 + let request = self 100 + .client 101 + .request_post_json(&endpoint, &record) 102 + .send() 103 + .await; 104 + 105 + create_processing(request).await 106 + } 107 + 108 + /// Create a new record with multipart form data (e.g., for file uploads). 109 + /// 110 + /// For simple JSON records without files, use [`Collection::create()`]. 111 + /// 112 + /// # Example 113 + /// ```rust,ignore 114 + /// use std::fs; 115 + /// use pocketbase_rs::{Form, Part}; 116 + /// 117 + /// let image = fs::read("./vulpes_vulpes.jpg")?; 118 + /// 119 + /// let image_part = Part::bytes(image) 120 + /// .file_name("vulpes_vulpes") 121 + /// .mime_str("image/jpeg")?; 122 + /// 123 + /// let form = Form::new() 124 + /// .text("name", "Red Fox") 125 + /// .part("illustration", image_part); 126 + /// 127 + /// let record = pb 128 + /// .collection("foxes") 129 + /// .create_multipart(form) 130 + /// .await?; 131 + /// ``` 132 + pub async fn create_multipart( 133 + self, 134 + form: reqwest::multipart::Form, 135 + ) -> Result<CreateResponse, CreateError> { 136 + let collection_name = self.name; 137 + 138 + let endpoint = format!( 139 + "{}/api/collections/{}/records", 140 + self.client.base_url, collection_name 141 + ); 142 + 143 + let request = self.client.request_post_form(&endpoint, form).send().await; 144 + 145 + create_processing(request).await 146 + } 147 + } 148 + 149 + async fn create_processing( 150 + request: Result<reqwest::Response, reqwest::Error>, 151 + ) -> Result<CreateResponse, CreateError> { 152 + match request { 153 + Ok(response) => match response.status() { 154 + reqwest::StatusCode::OK => { 155 + let data = response.json::<CreateResponse>().await; 156 + 157 + match data { 158 + Ok(data) => Ok(data), 159 + Err(error) => Err(CreateError::ParseError(error.to_string())), 160 + } 161 + } 162 + 163 + reqwest::StatusCode::BAD_REQUEST => { 164 + let data = response.json::<BadRequestResponse>().await; 165 + 166 + match data { 167 + Ok(bad_response) => { 168 + let mut errors: Vec<BadRequestError> = vec![]; 169 + 170 + for (error_name, error_data) in bad_response.data { 171 + errors.push(BadRequestError { 172 + name: error_name, 173 + code: error_data.code, 174 + message: error_data.message, 175 + }); 176 + } 177 + 178 + Err(CreateError::BadRequest(errors)) 179 + } 180 + Err(error) => Err(CreateError::ParseError(error.to_string())), 181 + } 182 + } 183 + 184 + reqwest::StatusCode::FORBIDDEN => Err(CreateError::Forbidden), 185 + reqwest::StatusCode::NOT_FOUND => Err(CreateError::NotFound), 186 + 187 + _ => Err(CreateError::UnexpectedResponse( 188 + response.status().to_string(), 189 + )), 190 + }, 191 + 192 + Err(error) => Err(CreateError::Unreachable(error.to_string())), 193 + } 194 + }
+86
src/records/crud/delete.rs
··· 1 + use crate::Collection; 2 + use thiserror::Error; 3 + 4 + #[derive(Error, Debug)] 5 + pub enum DeleteError { 6 + /// Communication with the `PocketBase` API was successful, 7 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 8 + /// 9 + /// Failed to delete record. Make sure that the record is not part of a required relation reference. `PocketBase`. 10 + #[error( 11 + "Failed to delete record. Make sure that the record is not part of a required relation reference." 12 + )] 13 + BadRequest, 14 + /// Communication with the `PocketBase` API was successful, 15 + /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response. 16 + /// 17 + /// You are not allowed to perform this request. 18 + #[error("You are not allowed to perform this request.")] 19 + Forbidden, 20 + /// Communication with the `PocketBase` API was successful, 21 + /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response. 22 + /// 23 + /// The requested resource wasn't found. 24 + #[error("The requested resource wasn't found.")] 25 + NotFound, 26 + /// Communication with the `PocketBase` API failed. 27 + /// 28 + /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK 29 + /// and similar errors. 30 + #[error("The communication with the PocketBase API failed: {0}")] 31 + Unreachable(String), 32 + /// An unexpected error occurred. 33 + /// The response from the `PocketBase` instance API was unexpected. 34 + /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues"). 35 + #[error("An unhandled status code was returned by the PocketBase API: {0}")] 36 + UnexpectedResponse(String), 37 + } 38 + 39 + impl<'a> Collection<'a> { 40 + /// Delete a single record. 41 + /// 42 + /// # Example 43 + /// ```rust,ignore 44 + /// pb.collection("articles") 45 + /// .delete("RECORD_ID") 46 + /// .await?; 47 + /// ``` 48 + pub async fn delete(&self, record_id: &'a str) -> Result<(), DeleteError> { 49 + // Validate record_id 50 + if record_id.is_empty() { 51 + return Err(DeleteError::BadRequest); 52 + } 53 + 54 + let endpoint = format!( 55 + "{}/api/collections/{}/records/{}", 56 + self.client.base_url, self.name, record_id 57 + ); 58 + let request = self.client.request_delete(&endpoint).send().await; 59 + 60 + match request { 61 + Ok(response) => match response.status() { 62 + reqwest::StatusCode::NO_CONTENT | reqwest::StatusCode::OK => Ok(()), 63 + reqwest::StatusCode::BAD_REQUEST => Err(DeleteError::BadRequest), 64 + reqwest::StatusCode::FORBIDDEN => Err(DeleteError::Forbidden), 65 + reqwest::StatusCode::NOT_FOUND => Err(DeleteError::NotFound), 66 + _ => Err(DeleteError::UnexpectedResponse(format!( 67 + "Status: {}, Collection: {}, Record: {}", 68 + response.status(), 69 + self.name, 70 + record_id 71 + ))), 72 + }, 73 + Err(e) => { 74 + if e.is_timeout() { 75 + Err(DeleteError::Unreachable("Request timed out".to_string())) 76 + } else if e.is_connect() { 77 + Err(DeleteError::Unreachable( 78 + "Failed to connect to server".to_string(), 79 + )) 80 + } else { 81 + Err(DeleteError::Unreachable(e.to_string())) 82 + } 83 + } 84 + } 85 + } 86 + }
+148
src/records/crud/get_first_list_item.rs
··· 1 + use serde::{Deserialize, de::DeserializeOwned}; 2 + 3 + use crate::PocketBase; 4 + use crate::error::RequestError; 5 + use crate::{Collection, RecordList}; 6 + 7 + pub struct CollectionGetFirstListItemBuilder<'a, T: Send + Deserialize<'a>> { 8 + client: &'a PocketBase, 9 + collection_name: &'a str, 10 + sort: Option<&'a str>, 11 + expand: Option<&'a str>, 12 + filter: Option<&'a str>, 13 + _marker: std::marker::PhantomData<T>, 14 + } 15 + 16 + impl<'a> Collection<'a> { 17 + /// Fetch the first record from the given collection. 18 + /// 19 + /// # Example 20 + /// ```rust,ignore 21 + /// #[derive(Default, Deserialize, Clone)] 22 + /// struct Article { 23 + /// id: String, 24 + /// title: String, 25 + /// content: String, 26 + /// } 27 + /// 28 + /// let article = pb 29 + /// .collection("articles") 30 + /// .get_first_list_item::<Article>() 31 + /// .sort("-created,id") 32 + /// .filter("language='en'") 33 + /// .call() 34 + /// .await?; 35 + /// ``` 36 + #[must_use] 37 + pub const fn get_first_list_item<T: Default + DeserializeOwned + Clone + Send>( 38 + self, 39 + ) -> CollectionGetFirstListItemBuilder<'a, T> { 40 + CollectionGetFirstListItemBuilder { 41 + client: self.client, 42 + collection_name: self.name, 43 + sort: None, 44 + expand: None, 45 + filter: None, 46 + _marker: std::marker::PhantomData, 47 + } 48 + } 49 + } 50 + 51 + impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetFirstListItemBuilder<'a, T> { 52 + /// Set the sort order. Prefix with `-` for DESC or `+` for ASC (default). 53 + /// 54 + /// # Example 55 + /// ```rust,ignore 56 + /// .sort("-created,id") // DESC by created, ASC by id 57 + /// ``` 58 + pub const fn sort(mut self, sort: &'a str) -> Self { 59 + self.sort = Some(sort); 60 + self 61 + } 62 + 63 + /// Filter the returned records. 64 + /// 65 + /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~` 66 + /// and their "any/at least one" variants with `?` prefix. 67 + /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping. 68 + /// 69 + /// # Example 70 + /// ```rust,ignore 71 + /// .filter("language='en' && created>'1970-01-01'") 72 + /// ``` 73 + pub const fn filter(mut self, filter: &'a str) -> Self { 74 + self.filter = Some(filter); 75 + self 76 + } 77 + 78 + /// Auto expand record relations (up to 6-levels deep). 79 + /// 80 + /// Expanded relations are appended under the `expand` property. 81 + /// Only relations the user has view permissions for will be expanded. 82 + /// 83 + /// # Example 84 + /// ```rust,ignore 85 + /// .expand("author") 86 + /// ``` 87 + pub const fn expand(mut self, expand: &'a str) -> Self { 88 + self.expand = Some(expand); 89 + self 90 + } 91 + 92 + /// Execute the request and return the first matching record. 93 + pub async fn call(self) -> Result<T, RequestError> { 94 + let url = format!( 95 + "{}/api/collections/{}/records", 96 + self.client.base_url, self.collection_name 97 + ); 98 + 99 + let mut query_parameters: Vec<(&str, &str)> = 100 + vec![("page", "1"), ("perPage", "1"), ("skipTotal", "true")]; 101 + 102 + if let Some(sort) = self.sort { 103 + query_parameters.push(("sort", sort)); 104 + } 105 + 106 + if let Some(filter) = self.filter { 107 + query_parameters.push(("filter", filter)); 108 + } 109 + 110 + if let Some(expand) = self.expand { 111 + query_parameters.push(("expand", expand)); 112 + } 113 + 114 + let request = self 115 + .client 116 + .request_get(&url, Some(query_parameters)) 117 + .send() 118 + .await; 119 + 120 + let response = match request { 121 + Ok(response) => response 122 + .error_for_status() 123 + .map_err(|err| match err.status() { 124 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 125 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 126 + _ => RequestError::Unhandled, 127 + })?, 128 + Err(error) => { 129 + return Err(match error.status() { 130 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 131 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 132 + _ => RequestError::Unhandled, 133 + }); 134 + } 135 + }; 136 + 137 + // Parse JSON response 138 + let records = response 139 + .json::<RecordList<T>>() 140 + .await 141 + .map_err(|error| RequestError::ParseError(error.to_string()))?; 142 + 143 + records.items.first().map_or_else( 144 + || Err(RequestError::ParseError("No record found.".to_owned())), 145 + |record| Ok(record.clone()), 146 + ) 147 + } 148 + }
+189
src/records/crud/get_full_list.rs
··· 1 + use serde::de::DeserializeOwned; 2 + 3 + use crate::error::RequestError; 4 + use crate::{Collection, RecordList}; 5 + 6 + /// Builder for fetching all records from a collection. 7 + pub struct CollectionGetFullListBuilder<'a, T: Send> { 8 + client: &'a crate::PocketBase, 9 + collection_name: &'a str, 10 + batch_size: u16, 11 + sort: Option<&'a str>, 12 + expand: Option<&'a str>, 13 + filter: Option<&'a str>, 14 + _marker: std::marker::PhantomData<T>, 15 + } 16 + 17 + impl<'a> Collection<'a> { 18 + /// Fetch all records from the collection. 19 + /// 20 + /// Automatically handles pagination by iterating through all pages. 21 + /// For performance, `skipTotal` is automatically set to `true`. 22 + /// 23 + /// # Example 24 + /// ```rust,ignore 25 + /// #[derive(Default, Deserialize, Clone)] 26 + /// struct Article { 27 + /// id: String, 28 + /// title: String, 29 + /// content: String, 30 + /// } 31 + /// 32 + /// let all_articles = pb 33 + /// .collection("articles") 34 + /// .get_full_list::<Article>() 35 + /// .sort("-created") 36 + /// .call() 37 + /// .await?; 38 + /// 39 + /// println!("Total articles: {}", all_articles.len()); 40 + /// ``` 41 + #[must_use] 42 + pub const fn get_full_list<T: Default + DeserializeOwned + Clone + Send>( 43 + self, 44 + ) -> CollectionGetFullListBuilder<'a, T> { 45 + CollectionGetFullListBuilder { 46 + client: self.client, 47 + collection_name: self.name, 48 + batch_size: 500, // Maximum allowed by PocketBase 49 + sort: None, 50 + expand: None, 51 + filter: None, 52 + _marker: std::marker::PhantomData, 53 + } 54 + } 55 + } 56 + 57 + impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetFullListBuilder<'a, T> { 58 + /// Set the batch size for pagination (default: 500, max: 500). 59 + /// 60 + /// Lower values reduce memory usage but increase request count. 61 + pub fn batch_size(mut self, size: u16) -> Self { 62 + self.batch_size = size.min(500); // Ensure we don't exceed PocketBase's limit 63 + self 64 + } 65 + 66 + /// Set the sort order. Prefix with `-` for DESC or `+` for ASC (default). 67 + /// 68 + /// # Example 69 + /// ```rust,ignore 70 + /// .sort("-created,id") // DESC by created, ASC by id 71 + /// ``` 72 + pub const fn sort(mut self, sort: &'a str) -> Self { 73 + self.sort = Some(sort); 74 + self 75 + } 76 + 77 + /// Filter the returned records. 78 + /// 79 + /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~` 80 + /// and their "any/at least one" variants with `?` prefix. 81 + /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping. 82 + /// 83 + /// # Example 84 + /// ```rust,ignore 85 + /// .filter("language='en' && created>'1970-01-01'") 86 + /// ``` 87 + pub const fn filter(mut self, filter: &'a str) -> Self { 88 + self.filter = Some(filter); 89 + self 90 + } 91 + 92 + /// Auto expand record relations (up to 6-levels deep). 93 + /// 94 + /// Expanded relations are appended under the `expand` property. 95 + /// Only relations the user has view permissions for will be expanded. 96 + /// 97 + /// # Example 98 + /// ```rust,ignore 99 + /// .expand("author") 100 + /// ``` 101 + pub const fn expand(mut self, expand: &'a str) -> Self { 102 + self.expand = Some(expand); 103 + self 104 + } 105 + 106 + /// Execute the request and return all matching records. 107 + /// 108 + /// Automatically handles pagination by making multiple requests if needed. 109 + pub async fn call(self) -> Result<Vec<T>, RequestError> { 110 + let mut all_records = Vec::new(); 111 + let mut page = 1u32; 112 + let batch_size_str = self.batch_size.to_string(); 113 + 114 + loop { 115 + let url = format!( 116 + "{}/api/collections/{}/records", 117 + self.client.base_url, self.collection_name 118 + ); 119 + 120 + let page_str = page.to_string(); 121 + let mut query_parameters: Vec<(&str, &str)> = vec![ 122 + ("page", &page_str), 123 + ("perPage", &batch_size_str), 124 + ("skipTotal", "true"), 125 + ]; 126 + 127 + if let Some(sort) = self.sort { 128 + query_parameters.push(("sort", sort)); 129 + } 130 + 131 + if let Some(filter) = self.filter { 132 + query_parameters.push(("filter", filter)); 133 + } 134 + 135 + if let Some(expand) = self.expand { 136 + query_parameters.push(("expand", expand)); 137 + } 138 + 139 + let request = self 140 + .client 141 + .request_get(&url, Some(query_parameters)) 142 + .send() 143 + .await; 144 + 145 + let response = match request { 146 + Ok(response) => response 147 + .error_for_status() 148 + .map_err(|err| match err.status() { 149 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 150 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 151 + Some(reqwest::StatusCode::UNAUTHORIZED) => RequestError::Unauthorized, 152 + _ => RequestError::Unhandled, 153 + })?, 154 + Err(error) => { 155 + return Err(if error.is_timeout() || error.is_connect() { 156 + RequestError::Unreachable 157 + } else { 158 + match error.status() { 159 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 160 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 161 + Some(reqwest::StatusCode::UNAUTHORIZED) => RequestError::Unauthorized, 162 + _ => RequestError::Unhandled, 163 + } 164 + }); 165 + } 166 + }; 167 + 168 + // Parse JSON response 169 + let records_page = response 170 + .json::<RecordList<T>>() 171 + .await 172 + .map_err(|error| RequestError::ParseError(error.to_string()))?; 173 + 174 + let items_count = records_page.items.len(); 175 + all_records.extend(records_page.items); 176 + 177 + // Check if we've fetched all records 178 + // Since we're using skipTotal=true, we can't rely on total_pages 179 + // Instead, we check if we got fewer items than requested 180 + if items_count < self.batch_size as usize { 181 + break; 182 + } 183 + 184 + page += 1; 185 + } 186 + 187 + Ok(all_records) 188 + } 189 + }
+185
src/records/crud/get_list.rs
··· 1 + use serde::{Deserialize, de::DeserializeOwned}; 2 + 3 + use crate::PocketBase; 4 + use crate::error::RequestError; 5 + use crate::{Collection, RecordList}; 6 + 7 + pub struct CollectionGetListBuilder<'a, T: Send + Deserialize<'a>> { 8 + client: &'a PocketBase, 9 + collection_name: &'a str, 10 + page: Option<String>, 11 + per_page: Option<String>, 12 + sort: Option<&'a str>, 13 + expand: Option<&'a str>, 14 + filter: Option<&'a str>, 15 + skip_total: bool, 16 + _marker: std::marker::PhantomData<T>, 17 + } 18 + 19 + impl<'a> Collection<'a> { 20 + /// Fetch a paginated records list from the given collection. 21 + /// 22 + /// # Example 23 + /// ```rust,ignore 24 + /// #[derive(Default, Deserialize, Clone)] 25 + /// struct Article { 26 + /// id: String, 27 + /// title: String, 28 + /// content: String, 29 + /// } 30 + /// 31 + /// let articles = pb 32 + /// .collection("articles") 33 + /// .get_list::<Article>() 34 + /// .sort("-created,id") 35 + /// .call() 36 + /// .await?; 37 + /// 38 + /// for article in articles.items { 39 + /// println!("{article:?}"); 40 + /// } 41 + /// ``` 42 + #[must_use] 43 + pub const fn get_list<T: Default + DeserializeOwned + Clone + Send>( 44 + self, 45 + ) -> CollectionGetListBuilder<'a, T> { 46 + CollectionGetListBuilder { 47 + client: self.client, 48 + collection_name: self.name, 49 + page: None, 50 + per_page: None, 51 + sort: None, 52 + expand: None, 53 + filter: None, 54 + skip_total: false, 55 + _marker: std::marker::PhantomData, 56 + } 57 + } 58 + } 59 + 60 + impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetListBuilder<'a, T> { 61 + /// The page (aka. offset) of the paginated list (default to 1). 62 + pub fn page(mut self, page: u16) -> Self { 63 + self.page = Some(page.to_string()); 64 + self 65 + } 66 + 67 + /// Set the max returned records per page (default: 30, max: 500). 68 + pub fn per_page(mut self, per_page: u16) -> Self { 69 + self.per_page = Some(per_page.to_string()); 70 + self 71 + } 72 + 73 + /// Specify the records order attribute(s). 74 + /// Add `-`/`+` (default) in front of the attribute for DESC / ASC order. 75 + /// 76 + /// # Example 77 + /// ```rust,ignore 78 + /// .sort("-created,id") // DESC by created, ASC by id 79 + /// ``` 80 + pub const fn sort(mut self, sort: &'a str) -> Self { 81 + self.sort = Some(sort); 82 + self 83 + } 84 + 85 + /// Filter the returned records. 86 + /// 87 + /// Supports operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `~`, `!~` 88 + /// and their "any/at least one" variants with `?` prefix. 89 + /// Combine with `&&` (AND), `||` (OR), and `(...)` for grouping. 90 + /// 91 + /// # Example 92 + /// ```rust,ignore 93 + /// .filter("language='en' && created>'1970-01-01'") 94 + /// ``` 95 + pub const fn filter(mut self, filter: &'a str) -> Self { 96 + self.filter = Some(filter); 97 + self 98 + } 99 + 100 + /// Auto expand record relations (up to 6-levels deep). 101 + /// 102 + /// Expanded relations are appended under the `expand` property. 103 + /// Only relations the user has view permissions for will be expanded. 104 + /// 105 + /// # Example 106 + /// ```rust,ignore 107 + /// .expand("author") 108 + /// ``` 109 + pub const fn expand(mut self, expand: &'a str) -> Self { 110 + self.expand = Some(expand); 111 + self 112 + } 113 + 114 + /// Skip total count query for better performance. 115 + /// 116 + /// When enabled, `totalItems` and `totalPages` will be `-1`. 117 + /// Useful for cursor pagination or when totals aren't needed. 118 + pub const fn skip_total(mut self, skip_total: bool) -> Self { 119 + self.skip_total = skip_total; 120 + self 121 + } 122 + 123 + /// Execute the request and return the paginated results. 124 + pub async fn call(self) -> Result<RecordList<T>, RequestError> { 125 + let url = format!( 126 + "{}/api/collections/{}/records", 127 + self.client.base_url, self.collection_name 128 + ); 129 + 130 + let mut query_parameters: Vec<(&str, &str)> = vec![]; 131 + 132 + if let Some(page) = self.page.as_deref() { 133 + query_parameters.push(("page", page)); 134 + } 135 + 136 + if let Some(per_page) = self.per_page.as_deref() { 137 + query_parameters.push(("perPage", per_page)); 138 + } 139 + 140 + if let Some(sort) = self.sort { 141 + query_parameters.push(("sort", sort)); 142 + } 143 + 144 + if let Some(filter) = self.filter { 145 + query_parameters.push(("filter", filter)); 146 + } 147 + 148 + if let Some(expand) = self.expand { 149 + query_parameters.push(("expand", expand)); 150 + } 151 + 152 + let request = self 153 + .client 154 + .request_get(&url, Some(query_parameters)) 155 + .send() 156 + .await; 157 + 158 + let response = match request { 159 + Ok(response) => response 160 + .error_for_status() 161 + .map_err(|err| match err.status() { 162 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 163 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 164 + Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests, 165 + _ => RequestError::Unhandled, 166 + })?, 167 + Err(error) => { 168 + return Err(match error.status() { 169 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 170 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 171 + Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests, 172 + _ => RequestError::Unhandled, 173 + }); 174 + } 175 + }; 176 + 177 + // Parse JSON response 178 + let records = response 179 + .json::<RecordList<T>>() 180 + .await 181 + .map_err(|error| RequestError::ParseError(error.to_string()))?; 182 + 183 + Ok(records) 184 + } 185 + }
+107
src/records/crud/get_one.rs
··· 1 + use serde::{Deserialize, de::DeserializeOwned}; 2 + 3 + use crate::error::RequestError; 4 + use crate::{Collection, PocketBase}; 5 + 6 + pub struct CollectionGetOneBuilder<'a, T: Send + Deserialize<'a>> { 7 + client: &'a PocketBase, 8 + collection_name: &'a str, 9 + record_id: &'a str, 10 + expand: Option<&'a str>, 11 + _marker: std::marker::PhantomData<T>, 12 + } 13 + 14 + impl<'a> Collection<'a> { 15 + /// Fetch a single record. 16 + /// 17 + /// # Example 18 + /// ```rust,ignore 19 + /// #[derive(Default, Deserialize, Clone)] 20 + /// struct Article { 21 + /// id: String, 22 + /// title: String, 23 + /// content: String, 24 + /// } 25 + /// 26 + /// let article = pb 27 + /// .collection("articles") 28 + /// .get_one::<Article>("record_id_123") 29 + /// .call() 30 + /// .await?; 31 + /// ``` 32 + #[must_use] 33 + pub const fn get_one<T: Default + DeserializeOwned + Clone + Send>( 34 + self, 35 + record_id: &'a str, 36 + ) -> CollectionGetOneBuilder<'a, T> { 37 + CollectionGetOneBuilder { 38 + client: self.client, 39 + collection_name: self.name, 40 + record_id, 41 + expand: None, 42 + _marker: std::marker::PhantomData, 43 + } 44 + } 45 + } 46 + 47 + impl<'a, T: Default + DeserializeOwned + Clone + Send> CollectionGetOneBuilder<'a, T> { 48 + /// Auto expand record relations (up to 6-levels deep). 49 + /// 50 + /// Expanded relations are appended under the `expand` property. 51 + /// Only relations the user has view permissions for will be expanded. 52 + /// 53 + /// # Example 54 + /// ```rust,ignore 55 + /// .expand("author") 56 + /// ``` 57 + pub const fn expand(mut self, expand: &'a str) -> Self { 58 + self.expand = Some(expand); 59 + self 60 + } 61 + 62 + /// Execute the request and return the record. 63 + pub async fn call(self) -> Result<T, RequestError> { 64 + let url = format!( 65 + "{}/api/collections/{}/records/{}", 66 + self.client.base_url, self.collection_name, self.record_id 67 + ); 68 + 69 + let request = self.expand.map_or_else( 70 + || self.client.request_get(&url, None), 71 + |expand_value| { 72 + let expand_params = vec![("expand", expand_value)]; 73 + 74 + self.client.request_get(&url, Some(expand_params)) 75 + }, 76 + ); 77 + 78 + let request = request.send().await; 79 + 80 + let response = match request { 81 + Ok(response) => response 82 + .error_for_status() 83 + .map_err(|err| match err.status() { 84 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 85 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 86 + Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests, 87 + _ => RequestError::Unhandled, 88 + })?, 89 + Err(error) => { 90 + return Err(match error.status() { 91 + Some(reqwest::StatusCode::FORBIDDEN) => RequestError::Forbidden, 92 + Some(reqwest::StatusCode::NOT_FOUND) => RequestError::NotFound, 93 + Some(reqwest::StatusCode::TOO_MANY_REQUESTS) => RequestError::TooManyRequests, 94 + _ => RequestError::Unhandled, 95 + }); 96 + } 97 + }; 98 + 99 + // Parse JSON response 100 + let record = response 101 + .json::<T>() 102 + .await 103 + .map_err(|error| RequestError::ParseError(error.to_string()))?; 104 + 105 + Ok(record) 106 + } 107 + }
+7
src/records/crud/mod.rs
··· 1 + pub mod create; 2 + pub mod delete; 3 + mod get_first_list_item; 4 + mod get_full_list; 5 + mod get_list; 6 + mod get_one; 7 + pub mod update;
+158
src/records/crud/update.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use thiserror::Error; 3 + 4 + use crate::error::{BadRequestError, BadRequestResponse}; 5 + use crate::{Collection, PocketBase}; 6 + 7 + /// Represents the various errors that can be obtained after a `update` request. 8 + #[derive(Error, Debug)] 9 + pub enum UpdateError { 10 + /// Communication with the `PocketBase` API was successful, 11 + /// but returned a [400 Bad Request]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400") HTTP error response. 12 + /// 13 + /// One or more fields were not validated `PocketBase`. 14 + #[error("One or more fields were not validated : {0:?}")] 15 + BadRequest(Vec<BadRequestError>), 16 + /// Communication with the `PocketBase` API was successful, 17 + /// but returned a [403 Forbidden]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403") HTTP error response. 18 + /// 19 + /// The authorized record is not allowed to perform this action. 20 + #[error("The authorized record is not allowed to perform this action.")] 21 + Forbidden, 22 + /// Communication with the `PocketBase` API was successful, 23 + /// but returned a [404 Not Found]("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404") HTTP error response. 24 + /// 25 + /// The requested resource wasn't found. Missing collection context. 26 + #[error("The requested resource wasn't found. Missing collection context.")] 27 + NotFound, 28 + /// Communication with the `PocketBase` API failed. 29 + /// 30 + /// This could be caused by an internet outage, an error in the link given to the `PocketBase` SDK 31 + /// and similar errors. 32 + #[error("The communication with the PocketBase API failed: {0}")] 33 + Unreachable(String), 34 + /// The response could not be parsed into the expected data structure. 35 + #[error( 36 + "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}" 37 + )] 38 + ParseError(String), 39 + /// The response from the `PocketBase` instance API was unexpected. 40 + /// If you think its an error, please [open an issue on GitHub]("https://github.com/fromhorizons/pocketbase-rs/issues"). 41 + #[error("An unhandled status code was returned by the PocketBase API: {0}")] 42 + UnexpectedResponse(String), 43 + } 44 + 45 + pub struct CollectionUpdateBuilder<'a, T: Send + Serialize + Deserialize<'a>> { 46 + client: &'a PocketBase, 47 + collection_name: &'a str, 48 + record_id: &'a str, 49 + data: T, 50 + _marker: std::marker::PhantomData<T>, 51 + } 52 + 53 + // TODO: Include the actual record data based on Generic type parameter. 54 + // 55 + // pub struct UpdateResponse<T> { 56 + // pub collection_name: String, 57 + // pub collection_id: String, 58 + // pub id: String, 59 + // pub updated: String, 60 + // pub created: String, 61 + // #[serde(flatten)] 62 + // pub record: T, // The actual record data 63 + // } 64 + 65 + /// Contains information about the successfully updated Record 66 + #[derive(Deserialize, Clone, Debug)] 67 + #[serde(rename_all = "camelCase")] 68 + pub struct UpdateResponse { 69 + pub collection_name: String, 70 + pub collection_id: String, 71 + pub id: String, 72 + pub updated: String, 73 + pub created: String, 74 + } 75 + 76 + impl<'a> Collection<'a> { 77 + /// Update a single record. 78 + /// 79 + /// # Example 80 + /// ```rust,ignore 81 + /// #[derive(Default, Serialize, Clone, Debug)] 82 + /// struct Article { 83 + /// name: String, 84 + /// content: String, 85 + /// } 86 + /// 87 + /// let updated_article = Article { 88 + /// name: String::from("Updated Article Title"), 89 + /// content: String::from("Updated article content"), 90 + /// }; 91 + /// 92 + /// let response = pb 93 + /// .collection("articles") 94 + /// .update::<Article>("record_id_123", updated_article) 95 + /// .await?; 96 + /// ``` 97 + pub async fn update<T: Default + Serialize + Clone + Send>( 98 + self, 99 + record_id: &'a str, 100 + record: T, 101 + ) -> Result<UpdateResponse, UpdateError> { 102 + let collection_name = self.name; 103 + 104 + let endpoint = format!( 105 + "{}/api/collections/{}/records/{}", 106 + self.client.base_url, collection_name, record_id 107 + ); 108 + 109 + let request = self 110 + .client 111 + .request_patch_json(&endpoint, &record) 112 + .send() 113 + .await; 114 + 115 + match request { 116 + Ok(response) => match response.status() { 117 + reqwest::StatusCode::OK => { 118 + let data = response.json::<UpdateResponse>().await; 119 + 120 + match data { 121 + Ok(data) => Ok(data), 122 + Err(error) => Err(UpdateError::ParseError(error.to_string())), 123 + } 124 + } 125 + 126 + reqwest::StatusCode::BAD_REQUEST => { 127 + let data = response.json::<BadRequestResponse>().await; 128 + 129 + match data { 130 + Ok(bad_response) => { 131 + let mut errors: Vec<BadRequestError> = vec![]; 132 + 133 + for (error_name, error_data) in bad_response.data { 134 + errors.push(BadRequestError { 135 + name: error_name, 136 + code: error_data.code, 137 + message: error_data.message, 138 + }); 139 + } 140 + 141 + Err(UpdateError::BadRequest(errors)) 142 + } 143 + Err(error) => Err(UpdateError::ParseError(error.to_string())), 144 + } 145 + } 146 + 147 + reqwest::StatusCode::FORBIDDEN => Err(UpdateError::Forbidden), 148 + reqwest::StatusCode::NOT_FOUND => Err(UpdateError::NotFound), 149 + 150 + _ => Err(UpdateError::UnexpectedResponse( 151 + response.status().to_string(), 152 + )), 153 + }, 154 + 155 + Err(error) => Err(UpdateError::Unreachable(error.to_string())), 156 + } 157 + } 158 + }
+2
src/records/mod.rs
··· 1 + pub mod auth; 2 + pub mod crud;