A wrapper around reqwest to make working with Pocketbase a breeze
1use base64::{Engine, prelude::BASE64_STANDARD_NO_PAD};
2use reqwest::{Method, header::HeaderMap};
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::{Map, Value, json};
5
6use crate::{
7 Error, JsonObject, PocketBase,
8 auth::{
9 AuthMethodList, AuthRefreshBuilder, AuthWithOtpBuilder, AuthWithPasswordBuilder,
10 ImpersonateBuilder,
11 },
12};
13
14const NO_PARAMS: &[&str] = &[];
15
16pub struct CreateRequestBuilder<'a, T: Serialize> {
17 record_service: RecordService<'a>,
18 body: T,
19}
20
21pub struct DeleteRequestBuilder<'a> {
22 record_service: RecordService<'a>,
23 id: &'a str,
24}
25
26#[derive(Clone, Debug)]
27pub struct ListRequestBuilder<'a> {
28 record_service: RecordService<'a>,
29 expand_fields: Option<&'a str>,
30 filter_fields: Option<&'a str>,
31 filter_records: Option<&'a str>,
32 page: usize,
33 per_page: usize,
34 skip_total: Option<bool>,
35 sort: Option<&'a str>,
36}
37
38#[derive(Clone, Debug)]
39pub struct FullListRequestBuilder<'a> {
40 record_service: RecordService<'a>,
41 batch: Option<usize>,
42 expand_fields: Option<&'a str>,
43 filter_fields: Option<&'a str>,
44 filter_records: Option<&'a str>,
45 skip_total: Option<bool>,
46 order_by: Option<&'a str>,
47}
48
49#[derive(Clone, Debug)]
50pub struct RecordService<'a> {
51 pub(super) client: &'a PocketBase,
52 pub(super) collection: &'a str,
53}
54
55#[derive(Clone, Debug, Default, Deserialize, Eq, Serialize, PartialEq)]
56#[serde(rename_all = "camelCase")]
57pub struct ResultList<T> {
58 items: Vec<T>,
59 page: usize,
60 per_page: usize,
61 total_items: isize,
62 total_pages: isize,
63}
64
65#[derive(Clone, Debug)]
66pub struct ViewRequestBuilder<'a> {
67 record_service: RecordService<'a>,
68 expand_fields: Option<&'a str>,
69 filter_fields: Option<&'a str>,
70 id: &'a str,
71}
72
73#[derive(Clone, Debug)]
74pub struct UpdateRequestBuilder<'a, T: Serialize> {
75 record_service: RecordService<'a>,
76 body: T,
77 expand_fields: Option<&'a str>,
78 filter_fields: Option<&'a str>,
79 id: &'a str,
80}
81
82impl<'a, T: Serialize + Clone> CreateRequestBuilder<'a, T> {
83 pub fn object<X: Serialize + Clone>(self, obj: X) -> CreateRequestBuilder<'a, X> {
84 CreateRequestBuilder {
85 record_service: self.record_service,
86 body: obj,
87 }
88 }
89
90 pub async fn send<R: DeserializeOwned>(self) -> Result<R, Error> {
91 let params: &[()] = &[];
92
93 self.record_service
94 .client
95 .send(
96 &format!(
97 "/api/collections/{}/records",
98 self.record_service.collection
99 ),
100 Method::POST,
101 HeaderMap::new(),
102 ¶ms,
103 Some(&self.body),
104 )
105 .await
106 }
107}
108
109impl<T: Extend<(String, Value)> + Serialize> CreateRequestBuilder<'_, T> {
110 pub fn fields<Iter: IntoIterator<Item = (String, Value)>>(mut self, patch: Iter) -> Self {
111 self.body.extend(patch);
112 self
113 }
114}
115
116impl DeleteRequestBuilder<'_> {
117 pub async fn send<T: DeserializeOwned>(self) -> Result<T, Error> {
118 let params: &[()] = &[];
119 self.record_service
120 .client
121 .send(
122 &format!(
123 "/api/collections/{}/records/{}",
124 self.record_service.collection, self.id
125 ),
126 Method::DELETE,
127 HeaderMap::new(),
128 ¶ms,
129 None::<&()>,
130 )
131 .await
132 // TODO: Dart SDK also implements the following behaviour.
133 //
134 // If the current [`AuthStore.record`] matches with the deleted id, then on
135 // success the client [`AuthStore`] will be also cleared.
136 }
137}
138
139impl<'a> FullListRequestBuilder<'a> {
140 pub fn batch_size(mut self, batch: usize) -> Self {
141 self.batch = Some(batch);
142 self
143 }
144
145 pub fn expand_fields(mut self, field_expression: &'a str) -> Self {
146 self.expand_fields = Some(field_expression);
147 self
148 }
149
150 pub fn filter_fields(mut self, field_expression: &'a str) -> Self {
151 self.filter_fields = Some(field_expression);
152 self
153 }
154
155 pub fn filter_records(mut self, filter_expression: &'a str) -> Self {
156 self.filter_records = Some(filter_expression);
157 self
158 }
159
160 pub fn skip_total(mut self, skip_total: bool) -> Self {
161 self.skip_total = Some(skip_total);
162 self
163 }
164
165 pub fn order_by(mut self, sort_expression: &'a str) -> Self {
166 self.order_by = Some(sort_expression);
167 self
168 }
169
170 pub async fn send<T: DeserializeOwned>(self) -> Result<Vec<T>, Error> {
171 let mut list = ListRequestBuilder {
172 record_service: self.record_service,
173 expand_fields: self.expand_fields,
174 filter_fields: self.filter_fields,
175 filter_records: self.filter_records,
176 page: 1,
177 per_page: self.batch.unwrap_or(500),
178 skip_total: Some(true),
179 sort: self.order_by,
180 };
181
182 let mut ret = Vec::new();
183 loop {
184 let page = list.clone().send().await?;
185 let persist = page.items.len() == list.per_page;
186 ret.extend(page.items);
187 if !persist {
188 break;
189 }
190 list.page += 1;
191 }
192 Ok(ret)
193 }
194}
195
196impl<'a> ListRequestBuilder<'a> {
197 pub fn expand_fields(mut self, field_expression: &'a str) -> Self {
198 self.expand_fields = Some(field_expression);
199 self
200 }
201
202 pub fn filter_fields(mut self, field_expression: &'a str) -> Self {
203 self.filter_fields = Some(field_expression);
204 self
205 }
206
207 pub fn filter(mut self, filter: &'a str) -> Self {
208 self.filter_records = Some(filter);
209 self
210 }
211
212 pub fn skip_total(mut self, skip_total: bool) -> Self {
213 self.skip_total = Some(skip_total);
214 self
215 }
216
217 pub fn sort(mut self, sort: &'a str) -> Self {
218 self.sort = Some(sort);
219 self
220 }
221
222 pub async fn send<T: DeserializeOwned>(self) -> Result<ResultList<T>, Error> {
223 let mut storage = vec![self.page.to_string(), self.per_page.to_string()];
224 if let Some(skip_total) = self.skip_total {
225 storage.push(skip_total.to_string());
226 }
227 let mut params: Vec<(&str, &str)> = vec![("page", &storage[0]), ("perPage", &storage[1])];
228 if let Some(filter) = self.filter_records {
229 params.push(("filter", filter));
230 }
231 if let Some(sort) = self.sort {
232 params.push(("sort", sort));
233 }
234 if let Some(expand) = self.expand_fields {
235 params.push(("expand", expand));
236 }
237 if let Some(fields) = self.filter_fields {
238 params.push(("fields", fields));
239 }
240 if self.skip_total.is_some() {
241 params.push(("skipTotal", &storage[2]));
242 }
243
244 self.record_service
245 .client
246 .send(
247 &format!(
248 "/api/collections/{}/records",
249 self.record_service.collection
250 ),
251 Method::GET,
252 HeaderMap::new(),
253 ¶ms,
254 None::<&()>,
255 )
256 .await
257 }
258}
259
260impl<'a> RecordService<'a> {
261 /// Refreshes the current authenticated auth record instance and returns a new
262 /// token and record data.
263 ///
264 /// On success this method automatically updates the client's AuthStore.
265 pub fn auth_refresh(&self) -> AuthRefreshBuilder<'a> {
266 AuthRefreshBuilder {
267 record_service: self.clone(),
268 expand_fields: None,
269 filter_fields: None,
270 }
271 }
272
273 /// Authenticate an auth record via OTP.
274 ///
275 /// On success this method automatically updates the client's [`AuthStore`].
276 pub fn auth_with_otp(&'a self, otp_id: &'a str, password: &'a str) -> AuthWithOtpBuilder<'a> {
277 AuthWithOtpBuilder {
278 record_service: self.clone(),
279 otp_id,
280 password,
281 expand_fields: None,
282 filter_fields: None,
283 }
284 }
285
286 /// Authenticate an auth record by its username/email and password and returns
287 /// a new auth token and record data.
288 ///
289 /// On success this method automatically updates the client's [`AuthStore`].
290 pub fn auth_with_password(
291 &self,
292 identity: &'a str,
293 password: &'a str,
294 ) -> AuthWithPasswordBuilder<'a> {
295 AuthWithPasswordBuilder {
296 record_service: self.clone(),
297 identity,
298 password,
299 expand_fields: None,
300 filter_fields: None,
301 }
302 }
303
304 /// Confirms auth record new email address.
305 ///
306 /// If the current [`AuthStore.record`] matches with the record from the
307 /// token, then on success the client [`AuthStore`] will be also cleared.
308 pub async fn confirm_email_change(&self, token: &str, password: &str) -> Result<(), Error> {
309 let () = self
310 .client
311 .send(
312 &format!("/api/collections/{}/confirm-email-change", self.collection),
313 Method::POST,
314 HeaderMap::new(),
315 NO_PARAMS,
316 Some(&json!({ "token": token, "password": password })),
317 )
318 .await?;
319
320 let parts: Vec<_> = token.split(".").collect();
321 if parts.len() != 3 {
322 return Ok(());
323 }
324 let Ok(payload_str) = String::from_utf8(BASE64_STANDARD_NO_PAD.decode(parts[1]).unwrap())
325 else {
326 return Ok(());
327 };
328 let Ok(payload) = serde_json::from_str::<JsonObject>(&payload_str) else {
329 return Ok(());
330 };
331
332 {
333 let inner = self.client.inner.read().unwrap();
334 if let Some(auth) = inner.auth_store.as_ref() {
335 if auth.record.data.get("id") == payload.get("id")
336 && auth.record.data.get("collectionId") == payload.get("collectionId")
337 {
338 self.client.auth_clear();
339 }
340 }
341 }
342 Ok(())
343 }
344
345 /// Confirms auth record password reset request.
346 pub async fn confirm_password_reset(
347 &self,
348 token: &str,
349 password: &str,
350 password_confirm: &str,
351 ) -> Result<(), Error> {
352 self.client
353 .send(
354 &format!(
355 "/api/collections/{}/confirm-password-reset",
356 self.collection
357 ),
358 Method::POST,
359 HeaderMap::new(),
360 NO_PARAMS,
361 Some(
362 &json!({ "token": token, "password": password, "passwordConfirm": password_confirm }),
363 ),
364 )
365 .await
366 }
367
368 /// Confirms auth record email verification request.
369 ///
370 /// On success this method automatically updates the client's [`AuthStore`].
371 pub async fn confirm_verification(&self, token: &str) -> Result<(), Error> {
372 let () = self
373 .client
374 .send(
375 &format!("/api/collections/{}/confirm-verification", self.collection),
376 Method::POST,
377 HeaderMap::new(),
378 NO_PARAMS,
379 Some(&json!({ "token": token })),
380 )
381 .await?;
382
383 let parts: Vec<_> = token.split(".").collect();
384 if parts.len() != 3 {
385 return Ok(());
386 }
387 let Ok(payload_str) = String::from_utf8(BASE64_STANDARD_NO_PAD.decode(parts[1]).unwrap())
388 else {
389 return Ok(());
390 };
391 let Ok(payload) = serde_json::from_str::<JsonObject>(&payload_str) else {
392 return Ok(());
393 };
394
395 {
396 let mut inner = self.client.inner.write().unwrap();
397 if let Some(auth) = inner.auth_store.as_mut() {
398 if !auth
399 .record
400 .data
401 .get("verified")
402 .map(|v| v.as_bool().unwrap_or(false))
403 .unwrap_or(false)
404 && auth.record.data.get("id") == payload.get("id")
405 && auth.record.data.get("collectionId") == payload.get("collectionId")
406 {
407 let _ = auth.record.data.insert("verified".to_string(), true.into());
408 }
409 }
410 }
411 Ok(())
412 }
413
414 /// Creates a new item.
415 pub fn create(&self) -> CreateRequestBuilder<'a, JsonObject> {
416 CreateRequestBuilder {
417 record_service: self.clone(),
418 body: Map::new(),
419 }
420 }
421
422 /// Deletes a single record model by its id.
423 pub fn delete(&self, id: &'a str) -> DeleteRequestBuilder<'a> {
424 DeleteRequestBuilder {
425 record_service: self.clone(),
426 id,
427 }
428 }
429
430 /// Returns a list with all items batch fetched at once.
431 pub fn get_full_list(&self) -> FullListRequestBuilder<'a> {
432 FullListRequestBuilder {
433 record_service: self.clone(),
434 batch: None,
435 filter_records: None,
436 order_by: None,
437 expand_fields: None,
438 filter_fields: None,
439 skip_total: None,
440 }
441 }
442
443 /// Returns paginated items list.
444 pub fn get_list(&self, page: usize, per_page: usize) -> ListRequestBuilder<'a> {
445 ListRequestBuilder {
446 record_service: self.clone(),
447 page,
448 per_page,
449 filter_records: None,
450 sort: None,
451 expand_fields: None,
452 filter_fields: None,
453 skip_total: None,
454 }
455 }
456
457 /// Returns single item by its id.
458 ///
459 /// Throws `404` [`Error`] in case an empty id is provided.
460 pub fn get_one(&'a self, id: &'a str) -> ViewRequestBuilder<'a> {
461 ViewRequestBuilder {
462 record_service: self.clone(),
463 id,
464 expand_fields: None,
465 filter_fields: None,
466 }
467 }
468
469 /// Authenticates with the specified recordId and returns a new client with
470 /// the received auth token in a memory store.
471 ///
472 /// This action currently requires superusers privileges.
473 pub fn impersonate(&'a self, id: &'a str) -> ImpersonateBuilder<'a> {
474 ImpersonateBuilder {
475 record_service: self.clone(),
476 id,
477 duration: None,
478 expand_fields: None,
479 filter_fields: None,
480 }
481 }
482
483 /// Returns all available application auth methods.
484 pub async fn list_auth_methods(&'a self) -> Result<AuthMethodList, Error> {
485 self.client
486 .send(
487 &format!("/api/collections/{}/auth-methods", self.collection),
488 Method::GET,
489 HeaderMap::new(),
490 NO_PARAMS,
491 None::<&()>,
492 )
493 .await
494 }
495
496 /// Sends auth record email change request to the provided email.
497 pub async fn request_email_change(&self, new_email: &str) -> Result<(), Error> {
498 self.client
499 .send(
500 &format!("/api/collections/{}/request-email-change", self.collection),
501 Method::POST,
502 HeaderMap::new(),
503 NO_PARAMS,
504 Some(&json!({ "newEmail": new_email })),
505 )
506 .await
507 }
508
509 /// Sends auth record OTP request to the provided email.
510 pub async fn request_otp(&self, email: &str) -> Result<String, Error> {
511 #[derive(Deserialize)]
512 #[serde(rename_all = "camelCase")]
513 struct OtpIdResponse {
514 otp_id: String,
515 }
516
517 let response: OtpIdResponse = self
518 .client
519 .send(
520 &format!("/api/collections/{}/request-otp", self.collection),
521 Method::POST,
522 HeaderMap::new(),
523 NO_PARAMS,
524 Some(&json!({ "email": email })),
525 )
526 .await?;
527
528 Ok(response.otp_id)
529 }
530
531 /// Sends auth record password reset request.
532 pub async fn request_password_reset(&self, email: &str) -> Result<(), Error> {
533 self.client
534 .send(
535 &format!(
536 "/api/collections/{}/request-password-reset",
537 self.collection
538 ),
539 Method::POST,
540 HeaderMap::new(),
541 NO_PARAMS,
542 Some(&json!({ "email": email })),
543 )
544 .await
545 }
546
547 /// Sends auth record verification email request.
548 pub async fn request_verification(&self, email: &str) -> Result<String, Error> {
549 self.client
550 .send(
551 &format!("/api/collections/{}/request-verification", self.collection),
552 Method::POST,
553 HeaderMap::new(),
554 NO_PARAMS,
555 Some(&json!({ "email": email })),
556 )
557 .await
558 }
559
560 /// Updates a single record model by its id.
561 pub fn update(&self, id: &'a str) -> UpdateRequestBuilder<'a, JsonObject> {
562 UpdateRequestBuilder {
563 record_service: self.clone(),
564 id,
565 expand_fields: None,
566 filter_fields: None,
567 body: Map::new(),
568 }
569 }
570}
571
572impl<T> ResultList<T> {
573 pub fn items(&self) -> &[T] {
574 &self.items
575 }
576
577 pub fn into_vec(self) -> Vec<T> {
578 self.items
579 }
580
581 pub fn page(&self) -> usize {
582 self.page
583 }
584
585 pub fn per_page(&self) -> usize {
586 self.per_page
587 }
588}
589
590impl<T> IntoIterator for ResultList<T> {
591 type Item = T;
592 type IntoIter = std::vec::IntoIter<T>;
593
594 fn into_iter(self) -> Self::IntoIter {
595 self.items.into_iter()
596 }
597}
598
599impl<'a, T> IntoIterator for &'a ResultList<T> {
600 type Item = &'a T;
601 type IntoIter = std::slice::Iter<'a, T>;
602
603 fn into_iter(self) -> Self::IntoIter {
604 self.items.iter()
605 }
606}
607
608impl<'a, T> IntoIterator for &'a mut ResultList<T> {
609 type Item = &'a mut T;
610 type IntoIter = std::slice::IterMut<'a, T>;
611
612 fn into_iter(self) -> Self::IntoIter {
613 self.items.iter_mut()
614 }
615}
616
617impl<'a> ViewRequestBuilder<'a> {
618 pub fn expand_fields(mut self, field_expression: &'a str) -> Self {
619 self.expand_fields = Some(field_expression);
620 self
621 }
622
623 pub fn filter_fields(mut self, field_expression: &'a str) -> Self {
624 self.filter_fields = Some(field_expression);
625 self
626 }
627
628 pub async fn send<T: DeserializeOwned>(self) -> Result<T, Error> {
629 let mut params = Vec::new();
630 if let Some(expand) = self.expand_fields {
631 params.push(("expand", expand));
632 }
633 if let Some(fields) = self.filter_fields {
634 params.push(("fields", fields));
635 }
636
637 self.record_service
638 .client
639 .send(
640 &format!(
641 "/api/collections/{}/records/{}",
642 self.record_service.collection, self.id
643 ),
644 Method::GET,
645 HeaderMap::new(),
646 ¶ms,
647 None::<&()>,
648 )
649 .await
650 }
651}
652
653impl<'a, T: Serialize> UpdateRequestBuilder<'a, T> {
654 pub fn expand_fields(mut self, field_expression: &'a str) -> Self {
655 self.expand_fields = Some(field_expression);
656 self
657 }
658
659 pub fn filter_fields(mut self, field_expression: &'a str) -> Self {
660 self.filter_fields = Some(field_expression);
661 self
662 }
663
664 pub fn object<X: Serialize>(self, obj: X) -> UpdateRequestBuilder<'a, X> {
665 UpdateRequestBuilder {
666 record_service: self.record_service,
667 body: obj,
668 expand_fields: self.expand_fields,
669 filter_fields: self.filter_fields,
670 id: self.id,
671 }
672 }
673
674 pub async fn send<R: DeserializeOwned>(self) -> Result<R, Error> {
675 let mut params = Vec::new();
676 if let Some(expand) = self.expand_fields {
677 params.push(("expand", expand));
678 }
679 if let Some(fields) = self.filter_fields {
680 params.push(("fields", fields));
681 }
682
683 self.record_service
684 .client
685 .send(
686 &format!(
687 "/api/collections/{}/records/{}",
688 self.record_service.collection, self.id
689 ),
690 Method::PATCH,
691 HeaderMap::new(),
692 ¶ms,
693 Some(&self.body),
694 )
695 .await
696 // TODO: The following behaviour is also implemented in the Dart SDK.
697 //
698 // If the current [`AuthStore.record`] matches with the updated id, then on
699 // success the client [`AuthStore`] will be updated with the result model.
700 }
701}
702
703impl<T: Extend<(String, Value)> + Serialize> UpdateRequestBuilder<'_, T> {
704 pub fn fields<Iter: IntoIterator<Item = (String, Value)>>(mut self, patch: Iter) -> Self {
705 self.body.extend(patch);
706 self
707 }
708}