1use async_trait::async_trait;
32use std::collections::HashMap;
33use std::marker::PhantomData;
34use std::sync::Arc;
35
36use backbone_messaging::crud_event::{CrudEvent, CrudEventPublisher, EventMetadata, NoOpCrudEventPublisher};
37
38use crate::persistence::traits::{CrudRepository, PersistentEntity, RepositoryError};
39
40#[derive(Debug, thiserror::Error)]
44pub enum ServiceError {
45 #[error("not found")]
46 NotFound,
47
48 #[error("already exists: {0}")]
49 AlreadyExists(String),
50
51 #[error("validation failed: {0}")]
52 Validation(String),
53
54 #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
58 Violations(Vec<crate::violation::Violation>),
59
60 #[error("repository error: {0}")]
61 Repository(#[from] RepositoryError),
62
63 #[error("internal error: {0}")]
64 Internal(String),
65}
66
67pub type ServiceResult<T> = Result<T, ServiceError>;
68
69pub trait FromCreateDto<DTO>: Sized {
76 fn from_create_dto(dto: DTO) -> ServiceResult<Self>;
77}
78
79pub trait ApplyUpdateDto<DTO> {
81 fn apply_update(self, dto: DTO) -> ServiceResult<Self>
82 where
83 Self: Sized;
84}
85
86#[async_trait]
92pub trait ServiceLifecycle<E: PersistentEntity>: Send + Sync {
93 async fn before_create(&self, entity: &mut E) -> ServiceResult<()> {
94 let _ = entity;
95 Ok(())
96 }
97
98 async fn after_create(&self, entity: &E) -> ServiceResult<()> {
99 let _ = entity;
100 Ok(())
101 }
102
103 async fn before_update(&self, entity: &mut E) -> ServiceResult<()> {
104 let _ = entity;
105 Ok(())
106 }
107
108 async fn after_update(&self, entity: &E) -> ServiceResult<()> {
109 let _ = entity;
110 Ok(())
111 }
112
113 async fn before_delete(&self, entity: &E) -> ServiceResult<()> {
114 let _ = entity;
115 Ok(())
116 }
117
118 async fn after_delete(&self, id: &str) -> ServiceResult<()> {
119 let _ = id;
120 Ok(())
121 }
122
123 async fn before_restore(&self, entity: &E) -> ServiceResult<()> {
125 let _ = entity;
126 Ok(())
127 }
128
129 async fn before_hard_delete(&self, entity: &E) -> ServiceResult<()> {
132 let _ = entity;
133 Ok(())
134 }
135
136 async fn before_restore_all(&self) -> ServiceResult<()> {
139 Ok(())
140 }
141
142 async fn before_empty_trash(&self) -> ServiceResult<()> {
145 Ok(())
146 }
147}
148
149pub struct NoOpLifecycle<E> {
151 _phantom: PhantomData<E>,
152}
153
154impl<E> NoOpLifecycle<E> {
155 pub fn new() -> Self {
156 Self {
157 _phantom: PhantomData,
158 }
159 }
160}
161
162impl<E> Default for NoOpLifecycle<E> {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168#[async_trait]
169impl<E: PersistentEntity> ServiceLifecycle<E> for NoOpLifecycle<E> {}
170
171pub struct GenericCrudService<E, C, U, R>
185where
186 E: PersistentEntity + Clone,
187 R: CrudRepository<E>,
188{
189 repository: Arc<R>,
190 lifecycle: Arc<dyn ServiceLifecycle<E>>,
191 event_publisher: Arc<dyn CrudEventPublisher<E>>,
192 guard: Arc<dyn crate::write_guard::WriteGuard<E>>,
193 _phantom: PhantomData<(C, U)>,
194}
195
196impl<E, C, U, R> GenericCrudService<E, C, U, R>
197where
198 E: PersistentEntity + Clone + FromCreateDto<C> + ApplyUpdateDto<U>,
199 C: Send + Sync + 'static,
200 U: Send + Sync + 'static,
201 R: CrudRepository<E>,
202{
203 pub fn new(
205 repository: Arc<R>,
206 lifecycle: Arc<dyn ServiceLifecycle<E>>,
207 event_publisher: Arc<dyn CrudEventPublisher<E>>,
208 ) -> Self {
209 Self {
210 repository,
211 lifecycle,
212 event_publisher,
213 guard: Arc::new(crate::write_guard::AllowAll),
214 _phantom: PhantomData,
215 }
216 }
217
218 pub fn with_guard(mut self, guard: Arc<dyn crate::write_guard::WriteGuard<E>>) -> Self {
221 self.guard = guard;
222 self
223 }
224
225 async fn consult(
227 &self,
228 kind: crate::write_guard::WriteKind,
229 before: Option<&E>,
230 after: Option<&E>,
231 ) -> ServiceResult<()> {
232 self.consult_as(kind, before, after, true).await
233 }
234
235 async fn consult_quietly(
239 &self,
240 kind: crate::write_guard::WriteKind,
241 before: Option<&E>,
242 after: Option<&E>,
243 ) -> ServiceResult<()> {
244 self.consult_as(kind, before, after, false).await
245 }
246
247 async fn consult_as(
248 &self,
249 kind: crate::write_guard::WriteKind,
250 before: Option<&E>,
251 after: Option<&E>,
252 log_shadow: bool,
253 ) -> ServiceResult<()> {
254 let ctx = crate::write_guard::WriteCtx::new(kind, before, after);
255 let outcome = self.guard.check(&ctx).await;
256 for v in outcome.shadow.iter().filter(|_| log_shadow) {
257 tracing::warn!(
258 target: "backbone::write_guard",
259 entity = std::any::type_name::<E>(),
260 kind = ?kind,
261 code = %v.code,
262 path = %v.path,
263 "a rule running in shadow would refuse this write: {}",
264 v.message
265 );
266 }
267 if outcome.refuse.is_empty() {
268 Ok(())
269 } else {
270 Err(ServiceError::Violations(outcome.refuse))
271 }
272 }
273
274 pub fn with_lifecycle(repository: Arc<R>, lifecycle: Arc<dyn ServiceLifecycle<E>>) -> Self {
276 Self::new(repository, lifecycle, NoOpCrudEventPublisher::arc())
277 }
278
279 pub fn with_repository(repository: Arc<R>) -> Self {
281 Self::new(
282 repository,
283 Arc::new(NoOpLifecycle::new()),
284 NoOpCrudEventPublisher::arc(),
285 )
286 }
287
288 pub fn with_event_publisher(mut self, publisher: Arc<dyn CrudEventPublisher<E>>) -> Self {
290 self.event_publisher = publisher;
291 self
292 }
293
294 pub fn repository(&self) -> &R {
296 &self.repository
297 }
298
299 pub async fn list(
302 &self,
303 page: u32,
304 limit: u32,
305 filters: HashMap<String, String>,
306 ) -> ServiceResult<(Vec<E>, u64)> {
307 self.repository
308 .list_filtered(page, limit, filters)
309 .await
310 .map_err(ServiceError::Repository)
311 }
312
313 pub async fn list_with_info(
316 &self,
317 page: u32,
318 limit: u32,
319 filters: HashMap<String, String>,
320 ) -> ServiceResult<(Vec<E>, backbone_orm::repository::PaginationInfo)> {
321 self.repository
322 .list_filtered_with_info(page, limit, filters)
323 .await
324 .map_err(ServiceError::Repository)
325 }
326
327 pub async fn aggregate(
329 &self,
330 spec: &backbone_orm::repository::AggregateSpec,
331 filters: HashMap<String, String>,
332 ) -> ServiceResult<backbone_orm::repository::AggregateResult> {
333 self.repository
334 .aggregate_filtered(spec, filters)
335 .await
336 .map_err(ServiceError::Repository)
337 }
338
339 pub async fn create(&self, dto: C) -> ServiceResult<E> {
340 let mut entity = E::from_create_dto(dto)?;
341 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity)).await?;
342 self.lifecycle.before_create(&mut entity).await?;
343 let saved = self
344 .repository
345 .create(entity)
346 .await
347 .map_err(ServiceError::Repository)?;
348 self.lifecycle.after_create(&saved).await?;
349
350 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
351 let _ = self
352 .event_publisher
353 .publish(CrudEvent::Created { entity: saved.clone(), metadata: meta })
354 .await;
355
356 Ok(saved)
357 }
358
359 pub async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
360 self.repository
361 .find_by_id(id)
362 .await
363 .map_err(ServiceError::Repository)
364 }
365
366 pub async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
367 let existing = self
368 .repository
369 .find_by_id(id)
370 .await
371 .map_err(ServiceError::Repository)?;
372 let Some(before) = existing else {
373 return Ok(None);
374 };
375 let mut updated = before.clone().apply_update(dto)?;
376 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
377 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
378 self.lifecycle.before_update(&mut updated).await?;
379 let Some(saved) = self
381 .repository
382 .update_changed(&before, updated)
383 .await
384 .map_err(ServiceError::Repository)?
385 else {
386 return Ok(None);
387 };
388 self.lifecycle.after_update(&saved).await?;
389
390 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
391 let _ = self
392 .event_publisher
393 .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
394 .await;
395
396 Ok(Some(saved))
397 }
398
399 pub async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
400 let existing = self
401 .repository
402 .find_by_id(id)
403 .await
404 .map_err(ServiceError::Repository)?;
405 let Some(entity) = existing else {
406 return Ok(false);
407 };
408 self.consult(crate::write_guard::WriteKind::Delete, Some(&entity), None).await?;
409 self.lifecycle.before_delete(&entity).await?;
410 let deleted = self
411 .repository
412 .soft_delete(id)
413 .await
414 .map_err(ServiceError::Repository)?;
415 self.lifecycle.after_delete(id).await?;
416
417 if deleted {
418 let meta = EventMetadata::new(id, std::any::type_name::<E>());
419 let _ = self
420 .event_publisher
421 .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
422 .await;
423 }
424
425 Ok(deleted)
426 }
427
428 pub async fn restore(&self, id: &str) -> ServiceResult<Option<E>> {
429 let Some(trashed) = self.get_deleted_by_id(id).await? else {
430 return Ok(None);
431 };
432 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
433 self.lifecycle.before_restore(&trashed).await?;
434 let restored = self
435 .repository
436 .restore(id)
437 .await
438 .map_err(ServiceError::Repository)?;
439
440 if let Some(ref entity) = restored {
441 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
442 let _ = self
443 .event_publisher
444 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
445 .await;
446 }
447
448 Ok(restored)
449 }
450
451 pub async fn hard_delete(&self, id: &str) -> ServiceResult<bool> {
452 let Some(entity) = self.get_deleted_by_id(id).await? else {
453 return Ok(false);
454 };
455 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&entity), None).await?;
456 self.lifecycle.before_hard_delete(&entity).await?;
457 let deleted = self
458 .repository
459 .hard_delete(id)
460 .await
461 .map_err(ServiceError::Repository)?;
462
463 if deleted {
464 let meta = EventMetadata::new(id, std::any::type_name::<E>());
465 let _ = self
466 .event_publisher
467 .publish(CrudEvent::HardDeleted {
468 entity_id: id.to_string(),
469 metadata: meta,
470 })
471 .await;
472 }
473
474 Ok(deleted)
475 }
476
477 pub async fn list_deleted(
478 &self,
479 page: u32,
480 limit: u32,
481 ) -> ServiceResult<(Vec<E>, u64)> {
482 self.repository
483 .list_deleted(page, limit)
484 .await
485 .map_err(ServiceError::Repository)
486 }
487
488 pub async fn empty_trash(&self) -> ServiceResult<u64> {
496 self.lifecycle.before_empty_trash().await?;
497 let trashed = self.whole_trash("emptied").await?;
498 if trashed.is_empty() {
499 return Ok(0);
500 }
501 for entity in &trashed {
502 self.consult(crate::write_guard::WriteKind::HardDelete, Some(entity), None).await?;
503 self.lifecycle.before_hard_delete(entity).await?;
504 }
505 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
506 let affected = self
507 .repository
508 .bulk_hard_delete(&ids)
509 .await
510 .map_err(ServiceError::Repository)?;
511 for id in &ids {
512 let meta = EventMetadata::new(id, std::any::type_name::<E>());
513 let _ = self
514 .event_publisher
515 .publish(CrudEvent::HardDeleted {
516 entity_id: id.to_string(),
517 metadata: meta,
518 })
519 .await;
520 }
521 Ok(affected)
522 }
523
524 async fn whole_trash(&self, verb: &str) -> ServiceResult<Vec<E>> {
526 let (rows, total) = self
527 .repository
528 .list_deleted(1, MAX_BATCH_SIZE as u32)
529 .await
530 .map_err(ServiceError::Repository)?;
531 if total > MAX_BATCH_SIZE as u64 || rows.len() > MAX_BATCH_SIZE {
532 return Err(ServiceError::Validation(format!(
533 "The trash holds {total} items, more than the {MAX_BATCH_SIZE} that can be {verb} \
534 at once. Select the items to act on instead."
535 )));
536 }
537 Ok(rows)
538 }
539
540 pub async fn count(&self) -> ServiceResult<u64> {
541 self.repository
542 .count()
543 .await
544 .map_err(ServiceError::Repository)
545 }
546
547 pub async fn count_active(&self) -> ServiceResult<u64> {
548 self.repository
549 .count()
550 .await
551 .map_err(ServiceError::Repository)
552 }
553
554 pub async fn find_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
556 self.get_by_id(id).await
557 }
558
559 pub async fn permanent_delete(&self, id: &str) -> ServiceResult<bool> {
561 self.hard_delete(id).await
562 }
563
564 pub async fn count_deleted(&self) -> ServiceResult<u64> {
566 self.repository
567 .count_deleted()
568 .await
569 .map_err(ServiceError::Repository)
570 }
571
572 pub async fn get_deleted_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
574 self.repository
575 .find_by_id_including_deleted(id)
576 .await
577 .map_err(ServiceError::Repository)
578 }
579
580 pub async fn list_deleted_filtered(
582 &self,
583 page: u32,
584 limit: u32,
585 filters: HashMap<String, String>,
586 ) -> ServiceResult<(Vec<E>, u64)> {
587 self.repository
588 .list_deleted_filtered(page, limit, filters)
589 .await
590 .map_err(ServiceError::Repository)
591 }
592
593 pub async fn upsert(&self, dto: C) -> ServiceResult<E> {
601 let _ = dto;
602 Err(ServiceError::Validation(UPSERT_UNSUPPORTED.to_string()))
603 }
604
605 pub async fn partial_update(
610 &self,
611 id: &str,
612 fields: HashMap<String, serde_json::Value>,
613 ) -> ServiceResult<Option<E>>
614 where
615 E: serde::Serialize + serde::de::DeserializeOwned,
616 {
617 let existing = self
618 .repository
619 .find_by_id(id)
620 .await
621 .map_err(ServiceError::Repository)?;
622 let Some(before) = existing else {
623 return Ok(None);
624 };
625 let mut patched = merge_patch(&before, fields)?;
626 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
627 self.lifecycle.before_update(&mut patched).await?;
628 let Some(saved) = self
631 .repository
632 .update_changed(&before, patched)
633 .await
634 .map_err(ServiceError::Repository)?
635 else {
636 return Ok(None);
637 };
638 self.lifecycle.after_update(&saved).await?;
639
640 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
641 let _ = self
642 .event_publisher
643 .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
644 .await;
645
646 Ok(Some(saved))
647 }
648
649 pub async fn bulk_create(&self, dtos: Vec<C>) -> ServiceResult<Vec<E>>
656 where
657 E: FromCreateDto<C>,
658 {
659 check_batch_size(dtos.len())?;
660 let mut prepared = Vec::with_capacity(dtos.len());
661 for (index, dto) in dtos.into_iter().enumerate() {
662 let at = |e: ServiceError| at_batch_index(e, index);
663 let mut entity = E::from_create_dto(dto).map_err(at)?;
664 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity))
665 .await
666 .map_err(at)?;
667 self.lifecycle.before_create(&mut entity).await.map_err(at)?;
668 prepared.push(entity);
669 }
670 let saved = self
671 .repository
672 .bulk_create(prepared)
673 .await
674 .map_err(ServiceError::Repository)?;
675 for entity in &saved {
676 self.lifecycle.after_create(entity).await?;
677 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
678 let _ = self
679 .event_publisher
680 .publish(CrudEvent::Created { entity: entity.clone(), metadata: meta })
681 .await;
682 }
683 Ok(saved)
684 }
685
686 pub async fn bulk_soft_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
695 check_batch_size(ids.len())?;
696 let ids = dedup_ids(ids);
697 let mut entities = Vec::with_capacity(ids.len());
702 for id in &ids {
703 match self
704 .repository
705 .find_by_id(id)
706 .await
707 .map_err(ServiceError::Repository)?
708 {
709 Some(e) => entities.push(e),
710 None => return Err(ServiceError::Validation(format!("id '{id}' not found"))),
711 }
712 }
713 for entity in &entities {
714 self.consult(crate::write_guard::WriteKind::Delete, Some(entity), None).await?;
715 self.lifecycle.before_delete(entity).await?;
716 }
717 let affected = self
718 .repository
719 .bulk_soft_delete(&ids)
720 .await
721 .map_err(ServiceError::Repository)?;
722 for (id, entity) in ids.iter().zip(entities.into_iter()) {
723 self.lifecycle.after_delete(id).await?;
724 let meta = EventMetadata::new(id, std::any::type_name::<E>());
725 let _ = self
726 .event_publisher
727 .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
728 .await;
729 }
730 Ok(affected)
731 }
732
733 pub async fn bulk_restore(&self, ids: Vec<String>) -> ServiceResult<Vec<E>> {
735 check_batch_size(ids.len())?;
736 let ids = dedup_ids(ids);
737 for id in &ids {
740 if let Some(trashed) = self.get_deleted_by_id(id).await? {
741 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
742 self.lifecycle.before_restore(&trashed).await?;
743 }
744 }
745 let restored = self
746 .repository
747 .bulk_restore(&ids)
748 .await
749 .map_err(ServiceError::Repository)?;
750 for entity in &restored {
751 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
752 let _ = self
753 .event_publisher
754 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
755 .await;
756 }
757 Ok(restored)
758 }
759
760 pub async fn restore_all(&self) -> ServiceResult<u64> {
767 self.lifecycle.before_restore_all().await?;
768 let trashed = self.whole_trash("restored").await?;
769 if trashed.is_empty() {
770 return Ok(0);
771 }
772 for entity in &trashed {
773 self.consult(crate::write_guard::WriteKind::Restore, Some(entity), None).await?;
774 self.lifecycle.before_restore(entity).await?;
775 }
776 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
777 let restored = self
778 .repository
779 .bulk_restore(&ids)
780 .await
781 .map_err(ServiceError::Repository)?;
782 for entity in &restored {
783 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
784 let _ = self
785 .event_publisher
786 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
787 .await;
788 }
789 Ok(restored.len() as u64)
790 }
791
792 pub async fn bulk_permanent_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
794 check_batch_size(ids.len())?;
795 let ids = dedup_ids(ids);
796 for id in &ids {
802 if let Some(trashed) = self.get_deleted_by_id(id).await? {
803 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&trashed), None).await?;
804 self.lifecycle.before_hard_delete(&trashed).await?;
805 }
806 }
807 let affected = self
808 .repository
809 .bulk_hard_delete(&ids)
810 .await
811 .map_err(ServiceError::Repository)?;
812 for id in &ids {
813 let meta = EventMetadata::new(id, std::any::type_name::<E>());
814 let _ = self
815 .event_publisher
816 .publish(CrudEvent::HardDeleted {
817 entity_id: id.to_string(),
818 metadata: meta,
819 })
820 .await;
821 }
822 Ok(affected)
823 }
824
825 pub async fn bulk_update(&self, items: Vec<(String, U)>) -> ServiceResult<Vec<E>> {
827 check_batch_size(items.len())?;
828 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
829 return Err(ServiceError::Validation(format!(
830 "duplicate id '{dup}' in bulk update"
831 )));
832 }
833 let mut befores = Vec::with_capacity(items.len());
834 let mut prepared = Vec::with_capacity(items.len());
835 for (id, dto) in items {
836 let Some(before) = self
837 .repository
838 .find_by_id(&id)
839 .await
840 .map_err(ServiceError::Repository)?
841 else {
842 return Err(ServiceError::Validation(format!("id '{id}' not found")));
843 };
844 let mut updated = before.clone().apply_update(dto)?;
845 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
846 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
847 self.lifecycle.before_update(&mut updated).await?;
848 prepared.push((before.clone(), updated));
849 befores.push(before);
850 }
851 let saved = self
854 .repository
855 .bulk_update_changed(prepared)
856 .await
857 .map_err(ServiceError::Repository)?;
858 self.publish_bulk_updates(befores, &saved).await?;
859 Ok(saved)
860 }
861
862 async fn publish_bulk_updates(&self, befores: Vec<E>, saved: &[E]) -> ServiceResult<()> {
866 for (before, after) in befores.into_iter().zip(saved.iter()) {
867 self.lifecycle.after_update(after).await?;
868 let meta = EventMetadata::new(after.entity_id(), std::any::type_name::<E>());
869 let _ = self
870 .event_publisher
871 .publish(CrudEvent::Updated {
872 before,
873 after: after.clone(),
874 metadata: meta,
875 })
876 .await;
877 }
878 Ok(())
879 }
880
881 pub async fn bulk_partial_update(
883 &self,
884 items: Vec<(String, HashMap<String, serde_json::Value>)>,
885 ) -> ServiceResult<Vec<E>>
886 where
887 E: serde::Serialize + serde::de::DeserializeOwned,
888 {
889 check_batch_size(items.len())?;
890 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
891 return Err(ServiceError::Validation(format!(
892 "duplicate id '{dup}' in bulk partial update"
893 )));
894 }
895 let mut befores = Vec::with_capacity(items.len());
896 let mut prepared = Vec::with_capacity(items.len());
897 for (id, fields) in items {
898 let Some(before) = self
899 .repository
900 .find_by_id(&id)
901 .await
902 .map_err(ServiceError::Repository)?
903 else {
904 return Err(ServiceError::Validation(format!("id '{id}' not found")));
905 };
906 let mut patched = merge_patch(&before, fields)?;
907 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
908 self.lifecycle.before_update(&mut patched).await?;
909 prepared.push((before.clone(), patched));
910 befores.push(before);
911 }
912 let saved = self
913 .repository
914 .bulk_update_changed(prepared)
915 .await
916 .map_err(ServiceError::Repository)?;
917 self.publish_bulk_updates(befores, &saved).await?;
918 Ok(saved)
919 }
920
921 pub async fn preview_bulk_partial_update(
930 &self,
931 items: Vec<(String, HashMap<String, serde_json::Value>)>,
932 ) -> ServiceResult<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>
933 where
934 E: serde::Serialize + serde::de::DeserializeOwned,
935 {
936 check_batch_size(items.len())?;
937 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
938 return Err(ServiceError::Validation(format!(
939 "duplicate id '{dup}' in bulk partial update"
940 )));
941 }
942 let mut rows = Vec::with_capacity(items.len());
943 for (id, fields) in items {
944 let outcome = self.preview_one(&id, fields).await;
945 rows.push(crate::http::BulkPreviewRow { id, outcome });
946 }
947 Ok(rows)
948 }
949
950 async fn preview_one(
951 &self,
952 id: &str,
953 fields: HashMap<String, serde_json::Value>,
954 ) -> ServiceResult<(E, E)>
955 where
956 E: serde::Serialize + serde::de::DeserializeOwned,
957 {
958 let Some(before) = self
959 .repository
960 .find_by_id(id)
961 .await
962 .map_err(ServiceError::Repository)?
963 else {
964 return Err(ServiceError::Validation(format!("id '{id}' not found")));
965 };
966 let mut patched = merge_patch(&before, fields)?;
967 self.consult_quietly(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched))
968 .await?;
969 self.lifecycle.before_update(&mut patched).await?;
970 Ok((before, patched))
971 }
972}
973
974pub const ALWAYS_WRITE_PROTECTED: &[&str] = &["id", "metadata"];
981
982fn as_object(entity: &impl serde::Serialize) -> ServiceResult<serde_json::Map<String, serde_json::Value>> {
983 match serde_json::to_value(entity).map_err(|e| ServiceError::Internal(e.to_string()))? {
984 serde_json::Value::Object(map) => Ok(map),
985 _ => Err(ServiceError::Internal("an entity must serialize to a JSON object".into())),
986 }
987}
988
989fn refuse_protected_changes<E: PersistentEntity>(
1000 before: &serde_json::Map<String, serde_json::Value>,
1001 after: &serde_json::Map<String, serde_json::Value>,
1002) -> ServiceResult<()> {
1003 let in_transition = crate::write_guard::current_transition().is_some();
1004 let refused: Vec<crate::violation::Violation> = ALWAYS_WRITE_PROTECTED
1005 .iter()
1006 .chain(E::write_protected_fields())
1007 .filter(|key| !(in_transition && E::state_fields().contains(*key)))
1008 .filter(|key| before.get(**key) != after.get(**key))
1009 .map(|key| {
1010 crate::violation::Violation::new(
1011 *key,
1012 "field_not_writable",
1013 format!(
1014 "field_not_writable: `{key}` cannot be changed by a generic write; \
1015 it changes only through the operation that owns it"
1016 ),
1017 )
1018 })
1019 .collect();
1020 if refused.is_empty() {
1021 Ok(())
1022 } else {
1023 Err(ServiceError::Violations(refused))
1024 }
1025}
1026
1027fn merge_patch<E: PersistentEntity>(
1035 before: &E,
1036 fields: HashMap<String, serde_json::Value>,
1037) -> ServiceResult<E> {
1038 let before_obj = as_object(before)?;
1039 let mut merged = before_obj.clone();
1040 let mut given = Vec::new();
1041 for (key, value) in fields {
1042 if !value.is_null() {
1043 given.push(key.clone());
1044 }
1045 merged.insert(key, value);
1046 }
1047 let patched: E = serde_json::from_value(serde_json::Value::Object(merged))
1048 .map_err(|e| ServiceError::Validation(e.to_string()))?;
1049 let after_obj = as_object(&patched)?;
1050
1051 let mut unknown: Vec<String> = given.into_iter().filter(|k| !after_obj.contains_key(k)).collect();
1052 if !unknown.is_empty() {
1053 unknown.sort();
1054 return Err(ServiceError::Violations(
1055 unknown
1056 .into_iter()
1057 .map(|k| {
1058 let message = format!("unknown_field: `{k}` is not a field of this record");
1059 crate::violation::Violation::new(k, "unknown_field", message)
1060 })
1061 .collect(),
1062 ));
1063 }
1064 refuse_protected_changes::<E>(&before_obj, &after_obj)?;
1065 Ok(patched)
1066}
1067
1068pub const MAX_BATCH_SIZE: usize = 1000;
1072
1073fn check_batch_size(count: usize) -> Result<(), ServiceError> {
1075 if count > MAX_BATCH_SIZE {
1076 return Err(ServiceError::Validation(format!(
1077 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1078 )));
1079 }
1080 Ok(())
1081}
1082
1083pub const UPSERT_UNSUPPORTED: &str =
1085 "upsert is not available for this resource: a create body names no record to match. \
1086 Create the record, or update it by id.";
1087
1088fn at_batch_index(err: ServiceError, index: usize) -> ServiceError {
1091 let item = |message: String| format!("item {index}: {message}");
1092 match err {
1093 ServiceError::Violations(violations) => ServiceError::Violations(
1094 violations
1095 .into_iter()
1096 .map(|mut v| {
1097 v.path = crate::violation::batch_item_path(index, &v.path);
1098 v
1099 })
1100 .collect(),
1101 ),
1102 ServiceError::Repository(e) => ServiceError::Repository(e.at_batch_index(index)),
1103 ServiceError::Validation(m) => ServiceError::Validation(item(m)),
1104 ServiceError::AlreadyExists(m) => ServiceError::AlreadyExists(item(m)),
1105 ServiceError::Internal(m) => ServiceError::Internal(item(m)),
1106 ServiceError::NotFound => ServiceError::Validation(item("not found".to_string())),
1107 }
1108}
1109
1110fn dedup_ids(ids: Vec<String>) -> Vec<String> {
1114 let mut seen = std::collections::HashSet::new();
1115 ids.into_iter().filter(|id| seen.insert(id.clone())).collect()
1116}
1117
1118fn first_duplicate_id(ids: impl IntoIterator<Item = String>) -> Option<String> {
1121 let mut seen = std::collections::HashSet::new();
1122 for id in ids {
1123 if !seen.insert(id.clone()) {
1124 return Some(id);
1125 }
1126 }
1127 None
1128}
1129
1130#[async_trait::async_trait]
1141impl<E, C, U, R> crate::http::CrudService<E, C, U> for GenericCrudService<E, C, U, R>
1142where
1143 E: PersistentEntity
1144 + Clone
1145 + serde::Serialize
1146 + serde::de::DeserializeOwned
1147 + FromCreateDto<C>
1148 + ApplyUpdateDto<U>
1149 + Send
1150 + Sync
1151 + 'static,
1152 C: Send + Sync + 'static,
1153 U: Send + Sync + 'static,
1154 R: CrudRepository<E> + Send + Sync + 'static,
1155{
1156 type Error = ServiceError;
1157
1158 fn violations_of(err: &ServiceError) -> Option<Vec<crate::violation::Violation>> {
1159 match err {
1160 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => Some(v.clone()),
1161 _ => None,
1162 }
1163 }
1164
1165 fn status_of(err: &ServiceError) -> Option<axum::http::StatusCode> {
1166 Some(crate::http::service_error_status(err))
1167 }
1168
1169 fn supports_upsert() -> bool {
1170 false
1171 }
1172
1173 fn entity_name() -> &'static str {
1174 std::any::type_name::<E>()
1175 }
1176
1177 async fn fetch_related_json(&self, table: &str, ids: &[String]) -> Vec<serde_json::Value> {
1178 self.repository().fetch_related_json(table, ids).await
1179 }
1180
1181 async fn list(
1182 &self,
1183 page: u32,
1184 limit: u32,
1185 filters: HashMap<String, String>,
1186 ) -> Result<(Vec<E>, u64), ServiceError> {
1187 self.list(page, limit, filters).await
1188 }
1189
1190 async fn list_with_info(
1191 &self,
1192 page: u32,
1193 limit: u32,
1194 filters: HashMap<String, String>,
1195 ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), ServiceError> {
1196 self.list_with_info(page, limit, filters).await
1197 }
1198
1199 async fn aggregate(
1200 &self,
1201 spec: &backbone_orm::repository::AggregateSpec,
1202 filters: HashMap<String, String>,
1203 ) -> Result<backbone_orm::repository::AggregateResult, ServiceError> {
1204 self.aggregate(spec, filters).await
1205 }
1206
1207 fn table_name(&self) -> Option<&str> {
1208 self.repository.table_name()
1209 }
1210
1211 async fn create(&self, dto: C) -> Result<E, ServiceError> {
1212 self.create(dto).await
1213 }
1214
1215 async fn get_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1216 self.get_by_id(id).await
1217 }
1218
1219 async fn update(&self, id: &str, dto: U) -> Result<Option<E>, ServiceError> {
1220 self.update(id, dto).await
1221 }
1222
1223 async fn partial_update(
1224 &self,
1225 id: &str,
1226 fields: HashMap<String, serde_json::Value>,
1227 ) -> Result<Option<E>, ServiceError> {
1228 self.partial_update(id, fields).await
1229 }
1230
1231 async fn soft_delete(&self, id: &str) -> Result<bool, ServiceError> {
1232 self.soft_delete(id).await
1233 }
1234
1235 async fn bulk_create(&self, items: Vec<C>) -> Result<Vec<E>, ServiceError> {
1236 self.bulk_create(items).await
1237 }
1238
1239 async fn upsert(&self, dto: C) -> Result<E, ServiceError> {
1240 self.upsert(dto).await
1241 }
1242
1243 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), ServiceError> {
1244 self.list_deleted(page, limit).await
1245 }
1246
1247 async fn restore(&self, id: &str) -> Result<Option<E>, ServiceError> {
1248 self.restore(id).await
1249 }
1250
1251 async fn empty_trash(&self) -> Result<u64, ServiceError> {
1252 self.empty_trash().await
1253 }
1254
1255 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1256 self.get_deleted_by_id(id).await
1257 }
1258
1259 async fn permanent_delete(&self, id: &str) -> Result<bool, ServiceError> {
1260 self.permanent_delete(id).await
1261 }
1262
1263 async fn list_deleted_filtered(
1264 &self,
1265 page: u32,
1266 limit: u32,
1267 filters: HashMap<String, String>,
1268 ) -> Result<(Vec<E>, u64), ServiceError> {
1269 self.list_deleted_filtered(page, limit, filters).await
1270 }
1271
1272 async fn count_active(&self) -> Result<u64, ServiceError> {
1273 self.count_active().await
1274 }
1275
1276 async fn count_deleted(&self) -> Result<u64, ServiceError> {
1277 self.count_deleted().await
1278 }
1279
1280 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1281 self.bulk_soft_delete(ids).await
1282 }
1283
1284 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<E>, ServiceError> {
1285 self.bulk_restore(ids).await
1286 }
1287
1288 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1289 self.bulk_permanent_delete(ids).await
1290 }
1291
1292 async fn restore_all(&self) -> Result<u64, ServiceError> {
1293 self.restore_all().await
1294 }
1295
1296 async fn bulk_update(&self, items: Vec<(String, U)>) -> Result<Vec<E>, ServiceError> {
1297 self.bulk_update(items).await
1298 }
1299
1300 async fn bulk_partial_update(
1301 &self,
1302 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1303 ) -> Result<Vec<E>, ServiceError> {
1304 self.bulk_partial_update(items).await
1305 }
1306
1307 async fn preview_bulk_partial_update(
1308 &self,
1309 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1310 ) -> Result<Option<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>, ServiceError> {
1311 self.preview_bulk_partial_update(items).await.map(Some)
1312 }
1313}
1314
1315impl<E, C, U, R> Clone for GenericCrudService<E, C, U, R>
1317where
1318 E: PersistentEntity + Clone,
1319 R: CrudRepository<E>,
1320{
1321 fn clone(&self) -> Self {
1322 Self {
1323 repository: self.repository.clone(),
1324 lifecycle: self.lifecycle.clone(),
1325 event_publisher: self.event_publisher.clone(),
1326 guard: self.guard.clone(),
1327 _phantom: PhantomData,
1328 }
1329 }
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335 use chrono::{DateTime, Utc};
1336 use serde::{Deserialize, Serialize};
1337
1338 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1341 struct Widget {
1342 id: String,
1343 name: String,
1344 #[serde(skip_serializing_if = "Option::is_none")]
1345 note: Option<String>,
1346 secret_hash: String,
1347 #[serde(default)]
1349 state: String,
1350 #[serde(skip_serializing_if = "Option::is_none")]
1351 deleted_at: Option<DateTime<Utc>>,
1352 created_at: DateTime<Utc>,
1353 updated_at: DateTime<Utc>,
1354 }
1355
1356 impl crate::persistence::traits::PersistentEntity for Widget {
1357 fn write_protected_fields() -> &'static [&'static str] {
1358 &["secret_hash", "state"]
1359 }
1360 fn state_fields() -> &'static [&'static str] {
1361 &["state"]
1362 }
1363 fn entity_id(&self) -> String {
1364 self.id.clone()
1365 }
1366 fn set_entity_id(&mut self, id: String) {
1367 self.id = id;
1368 }
1369 fn created_at(&self) -> Option<DateTime<Utc>> {
1370 Some(self.created_at)
1371 }
1372 fn set_created_at(&mut self, ts: DateTime<Utc>) {
1373 self.created_at = ts;
1374 }
1375 fn updated_at(&self) -> Option<DateTime<Utc>> {
1376 Some(self.updated_at)
1377 }
1378 fn set_updated_at(&mut self, ts: DateTime<Utc>) {
1379 self.updated_at = ts;
1380 }
1381 fn deleted_at(&self) -> Option<DateTime<Utc>> {
1382 self.deleted_at
1383 }
1384 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
1385 self.deleted_at = ts;
1386 }
1387 }
1388
1389 #[derive(Deserialize)]
1390 struct CreateWidgetDto {
1391 name: String,
1392 }
1393
1394 #[derive(Deserialize)]
1395 struct UpdateWidgetDto {
1396 name: String,
1397 }
1398
1399 impl backbone_orm::EntityRepoMeta for Widget {
1400 fn column_types() -> HashMap<String, String> {
1401 HashMap::new()
1402 }
1403 fn search_fields() -> &'static [&'static str] {
1404 &["name"]
1405 }
1406 }
1407
1408 const BROKEN_ID: &str = "00000000-0000-0000-0000-00000000dead";
1410
1411 impl FromCreateDto<CreateWidgetDto> for Widget {
1412 fn from_create_dto(dto: CreateWidgetDto) -> ServiceResult<Self> {
1413 Ok(Widget {
1414 id: uuid::Uuid::new_v4().to_string(),
1415 name: dto.name,
1416 note: None,
1417 secret_hash: "h0".into(),
1418 state: "draft".into(),
1419 deleted_at: None,
1420 created_at: Utc::now(),
1421 updated_at: Utc::now(),
1422 })
1423 }
1424 }
1425
1426 impl ApplyUpdateDto<UpdateWidgetDto> for Widget {
1427 fn apply_update(mut self, dto: UpdateWidgetDto) -> ServiceResult<Self> {
1428 self.name = dto.name;
1429 self.updated_at = Utc::now();
1430 Ok(self)
1431 }
1432 }
1433
1434 struct InMemoryWidgetRepo {
1436 data: tokio::sync::Mutex<Vec<Widget>>,
1437 changed_writes: std::sync::Mutex<Vec<(Widget, Widget)>>,
1439 full_writes: std::sync::atomic::AtomicUsize,
1441 vanish_on_write: std::sync::atomic::AtomicBool,
1443 bulk_creates: std::sync::atomic::AtomicUsize,
1445 }
1446
1447 impl InMemoryWidgetRepo {
1448 fn new() -> Self {
1449 Self {
1450 data: tokio::sync::Mutex::new(Vec::new()),
1451 changed_writes: std::sync::Mutex::new(Vec::new()),
1452 full_writes: std::sync::atomic::AtomicUsize::new(0),
1453 vanish_on_write: std::sync::atomic::AtomicBool::new(false),
1454 bulk_creates: std::sync::atomic::AtomicUsize::new(0),
1455 }
1456 }
1457 }
1458
1459 #[async_trait]
1460 impl crate::persistence::traits::CrudRepository<Widget> for InMemoryWidgetRepo {
1461 async fn create(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1462 self.data.lock().await.push(entity.clone());
1463 Ok(entity)
1464 }
1465
1466 async fn find_by_id(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1467 if id == BROKEN_ID {
1468 return Err(RepositoryError::DatabaseError(
1469 "relation \"secret_schema.widgets\" is broken".into(),
1470 ));
1471 }
1472 Ok(self
1473 .data
1474 .lock()
1475 .await
1476 .iter()
1477 .find(|w| w.id == id && w.deleted_at.is_none())
1478 .cloned())
1479 }
1480
1481 async fn update(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1482 self.full_writes
1483 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1484 let mut data = self.data.lock().await;
1485 data.retain(|w| w.id != entity.id);
1486 data.push(entity.clone());
1487 Ok(entity)
1488 }
1489
1490 async fn update_changed(
1491 &self,
1492 before: &Widget,
1493 after: Widget,
1494 ) -> Result<Option<Widget>, RepositoryError> {
1495 self.changed_writes
1496 .lock()
1497 .unwrap()
1498 .push((before.clone(), after.clone()));
1499 if self
1500 .vanish_on_write
1501 .load(std::sync::atomic::Ordering::SeqCst)
1502 {
1503 return Ok(None);
1504 }
1505 if after.name == "taken" {
1506 return Err(RepositoryError::AlreadyExists(
1507 "duplicate key value violates unique constraint \"widgets_name_key\"".into(),
1508 ));
1509 }
1510 let mut data = self.data.lock().await;
1511 data.retain(|w| w.id != after.id);
1512 data.push(after.clone());
1513 Ok(Some(after))
1514 }
1515
1516 async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1517 let mut data = self.data.lock().await;
1518 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1519 w.deleted_at = Some(Utc::now());
1520 return Ok(true);
1521 }
1522 Ok(false)
1523 }
1524
1525 async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1526 Ok(self.data.lock().await.iter().find(|w| w.id == id).cloned())
1527 }
1528
1529 async fn restore(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1530 let mut data = self.data.lock().await;
1531 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1532 w.deleted_at = None;
1533 return Ok(Some(w.clone()));
1534 }
1535 Ok(None)
1536 }
1537
1538 async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1539 let mut data = self.data.lock().await;
1540 let before = data.len();
1541 data.retain(|w| w.id != id);
1542 Ok(data.len() < before)
1543 }
1544
1545 async fn list(
1546 &self,
1547 page: u32,
1548 limit: u32,
1549 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1550 let data = self.data.lock().await;
1551 let active: Vec<_> = data.iter().filter(|w| w.deleted_at.is_none()).cloned().collect();
1552 let total = active.len() as u64;
1553 let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1554 let limit = limit as usize;
1555 let page = active.into_iter().skip(offset).take(limit).collect();
1556 Ok((page, total))
1557 }
1558
1559 async fn list_deleted(
1560 &self,
1561 page: u32,
1562 limit: u32,
1563 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1564 let data = self.data.lock().await;
1565 let deleted: Vec<_> = data.iter().filter(|w| w.deleted_at.is_some()).cloned().collect();
1566 let total = deleted.len() as u64;
1567 let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1568 let limit = limit as usize;
1569 let page = deleted.into_iter().skip(offset).take(limit).collect();
1570 Ok((page, total))
1571 }
1572
1573 async fn count(&self) -> Result<u64, RepositoryError> {
1574 let data = self.data.lock().await;
1575 Ok(data.iter().filter(|w| w.deleted_at.is_none()).count() as u64)
1576 }
1577
1578 async fn count_deleted(&self) -> Result<u64, RepositoryError> {
1579 let data = self.data.lock().await;
1580 Ok(data.iter().filter(|w| w.deleted_at.is_some()).count() as u64)
1581 }
1582
1583 async fn list_deleted_filtered(
1584 &self,
1585 page: u32,
1586 limit: u32,
1587 filters: HashMap<String, String>,
1588 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1589 let (rows, _) = self.list_deleted(page, limit).await?;
1590 let rows: Vec<Widget> = rows
1591 .into_iter()
1592 .filter(|w| filters.get("name").is_none_or(|n| *n == w.name))
1593 .collect();
1594 let total = rows.len() as u64;
1595 Ok((rows, total))
1596 }
1597
1598 async fn bulk_create(&self, entities: Vec<Widget>) -> Result<Vec<Widget>, RepositoryError> {
1599 self.bulk_creates
1600 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1601 let mut data = self.data.lock().await;
1602 data.extend(entities.clone());
1603 Ok(entities)
1604 }
1605
1606 async fn empty_trash(&self) -> Result<u64, RepositoryError> {
1607 let mut data = self.data.lock().await;
1608 let before = data.len();
1609 data.retain(|w| w.deleted_at.is_none());
1610 Ok((before - data.len()) as u64)
1611 }
1612 }
1613
1614 #[tokio::test]
1615 async fn create_and_get_roundtrip() {
1616 let repo = Arc::new(InMemoryWidgetRepo::new());
1617 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1618 GenericCrudService::with_repository(repo);
1619
1620 let widget = service
1621 .create(CreateWidgetDto {
1622 name: "sprocket".into(),
1623 })
1624 .await
1625 .unwrap();
1626
1627 let found = service.get_by_id(&widget.id).await.unwrap();
1628 assert!(found.is_some());
1629 assert_eq!(found.unwrap().name, "sprocket");
1630 }
1631
1632 #[tokio::test]
1633 async fn soft_delete_hides_from_list() {
1634 let repo = Arc::new(InMemoryWidgetRepo::new());
1635 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1636 GenericCrudService::with_repository(repo);
1637
1638 let w = service.create(CreateWidgetDto { name: "w".into() }).await.unwrap();
1639 service.soft_delete(&w.id).await.unwrap();
1640
1641 let (items, _) = service.list(1, 20, Default::default()).await.unwrap();
1642 assert!(items.is_empty());
1643
1644 let (deleted, _) = service.list_deleted(1, 20).await.unwrap();
1645 assert_eq!(deleted.len(), 1);
1646 }
1647
1648 #[tokio::test]
1649 async fn update_publishes_event_and_returns_new_entity() {
1650 let repo = Arc::new(InMemoryWidgetRepo::new());
1651 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1652 GenericCrudService::with_repository(repo);
1653
1654 let w = service.create(CreateWidgetDto { name: "old".into() }).await.unwrap();
1655 let updated = service
1656 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1657 .await
1658 .unwrap();
1659 assert_eq!(updated.unwrap().name, "new");
1660 }
1661
1662 #[tokio::test]
1665 async fn a_patch_writes_only_through_the_changed_columns_path() {
1666 let repo = Arc::new(InMemoryWidgetRepo::new());
1667 let service: GenericCrudService<
1668 Widget,
1669 CreateWidgetDto,
1670 UpdateWidgetDto,
1671 InMemoryWidgetRepo,
1672 > = GenericCrudService::with_repository(repo.clone());
1673 let w = service
1674 .create(CreateWidgetDto { name: "old".into() })
1675 .await
1676 .unwrap();
1677
1678 let mut fields = HashMap::new();
1679 fields.insert("note".to_string(), serde_json::json!("edited"));
1680 let saved = service
1681 .partial_update(&w.id, fields)
1682 .await
1683 .unwrap()
1684 .unwrap();
1685 assert_eq!(saved.note.as_deref(), Some("edited"));
1686
1687 let writes = repo.changed_writes.lock().unwrap().clone();
1688 assert_eq!(writes.len(), 1, "a PATCH must save through update_changed");
1689 assert_eq!(writes[0].0, w, "the write starts from the row as read");
1690 assert_eq!(
1691 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1692 0
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn a_put_writes_only_through_the_changed_columns_path() {
1698 let repo = Arc::new(InMemoryWidgetRepo::new());
1699 let service: GenericCrudService<
1700 Widget,
1701 CreateWidgetDto,
1702 UpdateWidgetDto,
1703 InMemoryWidgetRepo,
1704 > = GenericCrudService::with_repository(repo.clone());
1705 let w = service
1706 .create(CreateWidgetDto { name: "old".into() })
1707 .await
1708 .unwrap();
1709 service
1710 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1711 .await
1712 .unwrap()
1713 .unwrap();
1714 assert_eq!(repo.changed_writes.lock().unwrap().len(), 1);
1715 assert_eq!(
1716 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1717 0
1718 );
1719 }
1720
1721 #[tokio::test]
1722 async fn a_write_whose_row_vanished_meanwhile_answers_not_found() {
1723 let repo = Arc::new(InMemoryWidgetRepo::new());
1724 let service: GenericCrudService<
1725 Widget,
1726 CreateWidgetDto,
1727 UpdateWidgetDto,
1728 InMemoryWidgetRepo,
1729 > = GenericCrudService::with_repository(repo.clone());
1730 let w = service
1731 .create(CreateWidgetDto { name: "old".into() })
1732 .await
1733 .unwrap();
1734 repo.vanish_on_write
1735 .store(true, std::sync::atomic::Ordering::SeqCst);
1736
1737 let mut fields = HashMap::new();
1738 fields.insert("note".to_string(), serde_json::json!("edited"));
1739 assert!(service
1740 .partial_update(&w.id, fields)
1741 .await
1742 .unwrap()
1743 .is_none());
1744 assert!(service
1745 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1746 .await
1747 .unwrap()
1748 .is_none());
1749 }
1750
1751 #[tokio::test]
1754 async fn a_transition_moves_its_protected_state_field() {
1755 let service = svc();
1756 let w = service
1757 .create(CreateWidgetDto { name: "w".into() })
1758 .await
1759 .unwrap();
1760 let mut fields = HashMap::new();
1761 fields.insert("state".to_string(), serde_json::json!("active"));
1762 let saved =
1763 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1764 .await
1765 .expect("a verb moves its own state field")
1766 .unwrap();
1767 assert_eq!(saved.state, "active");
1768 }
1769
1770 #[tokio::test]
1771 async fn a_transition_still_cannot_move_another_protected_field() {
1772 let service = svc();
1773 let w = service
1774 .create(CreateWidgetDto { name: "w".into() })
1775 .await
1776 .unwrap();
1777 let mut fields = HashMap::new();
1778 fields.insert("state".to_string(), serde_json::json!("active"));
1779 fields.insert("secret_hash".to_string(), serde_json::json!("h1"));
1780 let err =
1781 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1782 .await
1783 .unwrap_err();
1784 match err {
1785 ServiceError::Violations(v) => {
1786 assert_eq!(v.len(), 1, "{v:?}");
1787 assert_eq!(
1788 (v[0].path.as_str(), v[0].code.as_str()),
1789 ("secret_hash", "field_not_writable")
1790 );
1791 }
1792 other => panic!("expected a violation, got {other:?}"),
1793 }
1794 }
1795
1796 #[tokio::test]
1797 async fn a_generic_write_still_cannot_move_the_state_field() {
1798 let service = svc();
1799 let w = service
1800 .create(CreateWidgetDto { name: "w".into() })
1801 .await
1802 .unwrap();
1803 let mut fields = HashMap::new();
1804 fields.insert("state".to_string(), serde_json::json!("active"));
1805 let err = service.partial_update(&w.id, fields).await.unwrap_err();
1806 assert!(
1807 matches!(err, ServiceError::Violations(ref v) if v[0].path == "state"),
1808 "{err:?}"
1809 );
1810 }
1811
1812 fn svc() -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
1815 GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()))
1816 }
1817
1818 #[tokio::test]
1819 async fn bulk_soft_delete_success() {
1820 let service = svc();
1821 let mut ids = Vec::new();
1822 for n in 0..3 {
1823 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1824 }
1825 let n = service.bulk_soft_delete(ids).await.unwrap();
1826 assert_eq!(n, 3);
1827 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1828 assert!(active.is_empty());
1829 }
1830
1831 #[tokio::test]
1832 async fn bulk_soft_delete_is_all_or_nothing_on_missing_id() {
1833 let service = svc();
1834 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1835 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1836
1837 let res = service
1839 .bulk_soft_delete(vec![a.id.clone(), b.id.clone(), "does-not-exist".into()])
1840 .await;
1841 assert!(res.is_err());
1842
1843 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1844 assert_eq!(active.len(), 2, "no rows should have been deleted");
1845 }
1846
1847 #[tokio::test]
1848 async fn bulk_restore_and_restore_all() {
1849 let service = svc();
1850 let mut ids = Vec::new();
1851 for n in 0..3 {
1852 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1853 }
1854 service.bulk_soft_delete(ids.clone()).await.unwrap();
1855
1856 let restored = service.bulk_restore(vec![ids[0].clone(), ids[1].clone()]).await.unwrap();
1858 assert_eq!(restored.len(), 2);
1859
1860 let n = service.restore_all().await.unwrap();
1862 assert_eq!(n, 1);
1863
1864 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1865 assert_eq!(active.len(), 3);
1866 }
1867
1868 #[tokio::test]
1869 async fn bulk_update_is_all_or_nothing_on_missing_id() {
1870 let service = svc();
1871 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1872
1873 let res = service
1874 .bulk_update(vec![
1875 (a.id.clone(), UpdateWidgetDto { name: "A2".into() }),
1876 ("missing".into(), UpdateWidgetDto { name: "X".into() }),
1877 ])
1878 .await;
1879 assert!(res.is_err());
1880
1881 let still = service.get_by_id(&a.id).await.unwrap().unwrap();
1883 assert_eq!(still.name, "a");
1884 }
1885
1886 #[tokio::test]
1887 async fn bulk_partial_update_success() {
1888 let service = svc();
1889 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1890 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1891
1892 let mut patch_a = std::collections::HashMap::new();
1893 patch_a.insert("name".to_string(), serde_json::json!("a2"));
1894 let mut patch_b = std::collections::HashMap::new();
1895 patch_b.insert("name".to_string(), serde_json::json!("b2"));
1896
1897 let saved = service
1898 .bulk_partial_update(vec![(a.id.clone(), patch_a), (b.id.clone(), patch_b)])
1899 .await
1900 .unwrap();
1901 assert_eq!(saved.len(), 2);
1902 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a2");
1903 assert_eq!(service.get_by_id(&b.id).await.unwrap().unwrap().name, "b2");
1904 }
1905
1906 #[tokio::test]
1907 async fn bulk_soft_delete_tolerates_duplicate_ids() {
1908 let service = svc();
1909 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1910 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1911
1912 let n = service
1914 .bulk_soft_delete(vec![a.id.clone(), a.id.clone(), b.id.clone()])
1915 .await
1916 .unwrap();
1917 assert_eq!(n, 2, "two distinct rows soft-deleted");
1918
1919 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1920 assert!(active.is_empty());
1921 }
1922
1923 #[tokio::test]
1924 async fn bulk_update_rejects_duplicate_ids() {
1925 let service = svc();
1926 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1927
1928 let res = service
1930 .bulk_update(vec![
1931 (a.id.clone(), UpdateWidgetDto { name: "first".into() }),
1932 (a.id.clone(), UpdateWidgetDto { name: "second".into() }),
1933 ])
1934 .await;
1935 assert!(res.is_err());
1936 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
1937 }
1938
1939 #[tokio::test]
1940 async fn bulk_soft_delete_rejects_oversized_batch() {
1941 let service = svc();
1942 let ids: Vec<String> = (0..MAX_BATCH_SIZE + 1).map(|n| format!("id-{n}")).collect();
1943 assert!(service.bulk_soft_delete(ids).await.is_err());
1945 }
1946
1947 struct RekeyWidgetDto {
1952 name: String,
1953 secret_hash: String,
1954 }
1955
1956 impl ApplyUpdateDto<RekeyWidgetDto> for Widget {
1957 fn apply_update(mut self, dto: RekeyWidgetDto) -> ServiceResult<Self> {
1958 self.name = dto.name;
1959 self.secret_hash = dto.secret_hash;
1960 Ok(self)
1961 }
1962 }
1963
1964 type RekeyService = GenericCrudService<Widget, CreateWidgetDto, RekeyWidgetDto, InMemoryWidgetRepo>;
1965
1966 fn patch(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1967 pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
1968 }
1969
1970 fn validation_message(res: ServiceResult<impl std::fmt::Debug>) -> String {
1973 match res {
1974 Err(ServiceError::Validation(m)) => m,
1975 Err(e @ ServiceError::Violations(_)) => e.to_string(),
1976 other => panic!("expected a validation error, got {other:?}"),
1977 }
1978 }
1979
1980 #[tokio::test]
1981 async fn patch_rejects_a_field_the_entity_does_not_have() {
1982 let service = svc();
1983 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1984
1985 let msg = validation_message(service.partial_update(&w.id, patch(&[("nmae", serde_json::json!("b"))])).await);
1986 assert!(msg.contains("unknown_field") && msg.contains("nmae"), "{msg}");
1987 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
1988 }
1989
1990 #[tokio::test]
1991 async fn patch_rejects_changing_the_id() {
1992 let service = svc();
1993 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1994
1995 let msg = validation_message(service.partial_update(&w.id, patch(&[("id", serde_json::json!("other"))])).await);
1996 assert!(msg.contains("field_not_writable") && msg.contains("id"), "{msg}");
1997 assert!(service.get_by_id(&w.id).await.unwrap().is_some());
1998 }
1999
2000 #[tokio::test]
2001 async fn patch_rejects_changing_a_field_the_entity_protects() {
2002 let service = svc();
2003 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2004
2005 let msg = validation_message(
2006 service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await,
2007 );
2008 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2009 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().secret_hash, "h0");
2010 }
2011
2012 #[tokio::test]
2013 async fn patch_accepts_a_protected_field_echoed_unchanged() {
2014 let service = svc();
2015 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2016
2017 let saved = service
2018 .partial_update(&w.id, patch(&[("name", serde_json::json!("b")), ("secret_hash", serde_json::json!("h0"))]))
2019 .await
2020 .unwrap()
2021 .unwrap();
2022 assert_eq!(saved.name, "b");
2023 }
2024
2025 #[tokio::test]
2026 async fn patch_sets_and_clears_an_optional_field_that_is_not_serialized_when_empty() {
2027 let service = svc();
2028 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2029
2030 let set = service.partial_update(&w.id, patch(&[("note", serde_json::json!("hi"))])).await.unwrap().unwrap();
2031 assert_eq!(set.note.as_deref(), Some("hi"));
2032 let cleared = service.partial_update(&w.id, patch(&[("note", serde_json::Value::Null)])).await.unwrap().unwrap();
2033 assert_eq!(cleared.note, None);
2034 }
2035
2036 #[tokio::test]
2037 async fn put_rejects_changing_a_field_the_entity_protects() {
2038 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2039 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2040
2041 let msg = validation_message(
2042 service.update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h1".into() }).await,
2043 );
2044 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2045
2046 let saved = service
2048 .update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h0".into() })
2049 .await
2050 .unwrap()
2051 .unwrap();
2052 assert_eq!(saved.name, "b");
2053 }
2054
2055 #[tokio::test]
2056 async fn bulk_update_rejects_a_protected_change_before_writing_any_row() {
2057 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2058 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2059 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2060
2061 let res = service
2062 .bulk_update(vec![
2063 (a.id.clone(), RekeyWidgetDto { name: "a2".into(), secret_hash: "h0".into() }),
2064 (b.id.clone(), RekeyWidgetDto { name: "b2".into(), secret_hash: "h1".into() }),
2065 ])
2066 .await;
2067 assert!(validation_message(res).contains("field_not_writable"));
2068 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2069 }
2070
2071 #[tokio::test]
2072 async fn bulk_partial_update_rejects_a_protected_change_before_writing_any_row() {
2073 let service = svc();
2074 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2075 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2076
2077 let res = service
2078 .bulk_partial_update(vec![
2079 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2080 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2081 ])
2082 .await;
2083 assert!(validation_message(res).contains("field_not_writable"));
2084 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2085 }
2086
2087 #[tokio::test]
2088 async fn a_bulk_preview_says_what_each_row_would_do_and_writes_nothing() {
2089 let service = svc();
2090 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2091 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2092
2093 let rows = service
2094 .preview_bulk_partial_update(vec![
2095 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2096 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2097 ("missing".into(), patch(&[("name", serde_json::json!("x"))])),
2098 ])
2099 .await
2100 .unwrap();
2101 assert_eq!(rows.len(), 3);
2102 let (before, after) = rows[0].outcome.as_ref().unwrap();
2103 assert_eq!((before.name.as_str(), after.name.as_str()), ("a", "a2"));
2104 let refused = rows[1].outcome.as_ref().err().unwrap().to_string();
2105 assert!(refused.contains("field_not_writable"), "{refused}");
2106 assert!(rows[2].outcome.is_err(), "a row that is not there is refused, not dropped");
2107 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2109 }
2110
2111 #[tokio::test]
2112 async fn a_bulk_preview_refuses_a_batch_the_write_would_refuse_whole() {
2113 let service = svc();
2114 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2115 let res = service
2116 .preview_bulk_partial_update(vec![
2117 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2118 (a.id.clone(), patch(&[("name", serde_json::json!("a3"))])),
2119 ])
2120 .await;
2121 assert!(validation_message(res.map(|r| r.len())).contains("duplicate id"));
2122 }
2123
2124 #[tokio::test]
2125 async fn bulk_partial_update_rejects_an_unknown_field() {
2126 let service = svc();
2127 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2128
2129 let res = service.bulk_partial_update(vec![(a.id.clone(), patch(&[("colour", serde_json::json!("red"))]))]).await;
2130 assert!(validation_message(res).contains("unknown_field"));
2131 }
2132
2133 #[derive(Default)]
2136 struct RecordingPublisher {
2137 kinds: tokio::sync::Mutex<Vec<&'static str>>,
2138 }
2139
2140 #[async_trait]
2141 impl CrudEventPublisher<Widget> for RecordingPublisher {
2142 async fn publish(&self, event: CrudEvent<Widget>) -> Result<(), backbone_messaging::EventError> {
2143 let kind = match event {
2144 CrudEvent::Updated { .. } => "updated",
2145 CrudEvent::Patched { .. } => "patched",
2146 _ => "other",
2147 };
2148 self.kinds.lock().await.push(kind);
2149 Ok(())
2150 }
2151 }
2152
2153 #[tokio::test]
2154 async fn patch_publishes_an_updated_event() {
2155 let publisher = Arc::new(RecordingPublisher::default());
2156 let service = svc().with_event_publisher(publisher.clone());
2157 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2158 publisher.kinds.lock().await.clear();
2159
2160 service.partial_update(&w.id, patch(&[("name", serde_json::json!("b"))])).await.unwrap();
2161 assert_eq!(*publisher.kinds.lock().await, vec!["updated"]);
2162 }
2163
2164 struct RefuseTrash;
2167
2168 #[async_trait]
2169 impl ServiceLifecycle<Widget> for RefuseTrash {
2170 async fn before_restore(&self, _entity: &Widget) -> ServiceResult<()> {
2171 Err(ServiceError::Validation("restore refused".into()))
2172 }
2173 async fn before_hard_delete(&self, _entity: &Widget) -> ServiceResult<()> {
2174 Err(ServiceError::Validation("hard delete refused".into()))
2175 }
2176 async fn before_restore_all(&self) -> ServiceResult<()> {
2177 Err(ServiceError::Validation("restore all refused".into()))
2178 }
2179 async fn before_empty_trash(&self) -> ServiceResult<()> {
2180 Err(ServiceError::Validation("empty trash refused".into()))
2181 }
2182 }
2183
2184 #[tokio::test]
2185 async fn every_trash_operation_consults_the_lifecycle() {
2186 let repo = Arc::new(InMemoryWidgetRepo::new());
2187 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2188 GenericCrudService::with_lifecycle(repo, Arc::new(RefuseTrash));
2189 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2190 service.soft_delete(&w.id).await.unwrap();
2191
2192 assert!(service.restore(&w.id).await.is_err(), "restore");
2193 assert!(service.bulk_restore(vec![w.id.clone()]).await.is_err(), "bulk restore");
2194 assert!(service.restore_all().await.is_err(), "restore all");
2195 assert!(service.hard_delete(&w.id).await.is_err(), "hard delete");
2196 assert!(service.bulk_permanent_delete(vec![w.id.clone()]).await.is_err(), "bulk permanent delete");
2197 assert!(service.empty_trash().await.is_err(), "empty trash");
2198
2199 let still = service.get_deleted_by_id(&w.id).await.unwrap().unwrap();
2201 assert!(still.deleted_at.is_some());
2202 }
2203
2204 #[tokio::test]
2207 async fn a_refused_field_is_a_violation_naming_its_path_and_code() {
2208 let service = svc();
2209 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2210
2211 let err = service
2212 .partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1")), ("colour", serde_json::json!("red"))]))
2213 .await
2214 .unwrap_err();
2215 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2216 let pairs: Vec<(&str, &str)> = v.iter().map(|x| (x.path.as_str(), x.code.as_str())).collect();
2217 assert_eq!(pairs, vec![("colour", "unknown_field")]);
2218 assert!(err.to_string().starts_with("validation failed: unknown_field: `colour`"), "{err}");
2220
2221 let err = service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await.unwrap_err();
2222 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2223 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("secret_hash", "field_not_writable"));
2224 }
2225
2226 #[derive(Default)]
2229 struct NameGuard {
2230 seen: std::sync::Mutex<Vec<crate::write_guard::WriteKind>>,
2231 }
2232
2233 #[async_trait]
2234 impl crate::write_guard::WriteGuard<Widget> for NameGuard {
2235 async fn check(&self, ctx: &crate::write_guard::WriteCtx<'_, Widget>) -> crate::write_guard::GuardOutcome {
2236 self.seen.lock().unwrap().push(ctx.kind);
2237 let row = ctx.after.or(ctx.before).expect("a write concerns a row");
2238 let mut out = crate::write_guard::GuardOutcome::default();
2239 match row.name.as_str() {
2240 "forbidden" => out.refuse.push(crate::violation::Violation::new("name", "name_forbidden", "this name is refused")),
2241 "watched" => out.shadow.push(crate::violation::Violation::new("name", "name_watched", "would be refused")),
2242 _ => {}
2243 }
2244 out
2245 }
2246 }
2247
2248 fn guarded(guard: Arc<NameGuard>) -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2249 svc().with_guard(guard)
2250 }
2251
2252 #[tokio::test]
2253 async fn a_guard_refusal_stops_create_update_and_patch_with_nothing_written() {
2254 let guard = Arc::new(NameGuard::default());
2255 let service = guarded(guard.clone());
2256
2257 let err = service.create(CreateWidgetDto { name: "forbidden".into() }).await.unwrap_err();
2258 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2259 assert!(service.list(1, 20, Default::default()).await.unwrap().0.is_empty(), "nothing created");
2260
2261 let w = service.create(CreateWidgetDto { name: "ok".into() }).await.unwrap();
2262 assert!(service.update(&w.id, UpdateWidgetDto { name: "forbidden".into() }).await.is_err());
2263 assert!(service.partial_update(&w.id, patch(&[("name", serde_json::json!("forbidden"))])).await.is_err());
2264 assert!(service
2265 .bulk_partial_update(vec![(w.id.clone(), patch(&[("name", serde_json::json!("forbidden"))]))])
2266 .await
2267 .is_err());
2268 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "ok");
2269 }
2270
2271 #[tokio::test]
2272 async fn a_shadowed_rule_lets_the_write_through() {
2273 let service = guarded(Arc::new(NameGuard::default()));
2274 let w = service.create(CreateWidgetDto { name: "watched".into() }).await.unwrap();
2275 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "watched");
2276 }
2277
2278 #[tokio::test]
2279 async fn the_guard_is_asked_about_every_write_kind() {
2280 use crate::write_guard::WriteKind::*;
2281 let guard = Arc::new(NameGuard::default());
2282 let service = guarded(guard.clone());
2283 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2284 service.update(&w.id, UpdateWidgetDto { name: "b".into() }).await.unwrap();
2285 service.partial_update(&w.id, patch(&[("name", serde_json::json!("c"))])).await.unwrap();
2286 service.soft_delete(&w.id).await.unwrap();
2287 service.restore(&w.id).await.unwrap();
2288 service.soft_delete(&w.id).await.unwrap();
2289 service.hard_delete(&w.id).await.unwrap();
2290 assert_eq!(*guard.seen.lock().unwrap(), vec![Create, Update, Update, Delete, Restore, Delete, HardDelete]);
2291 }
2292
2293 #[test]
2294 fn the_generic_service_hands_its_violations_to_the_http_layer() {
2295 use crate::http::CrudService;
2296 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2297 let v = vec![crate::violation::Violation::new("name", "x", "y")];
2298 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::Violations(v.clone())), Some(v.clone()));
2299 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::NotFound), None);
2300 let db = ServiceError::Repository(RepositoryError::Violations(v.clone()));
2302 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&db), Some(v));
2303 }
2304
2305 async fn trash_with_a_refused_row(
2309 service: &GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>,
2310 ) -> (String, String) {
2311 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2312 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2313 service.bulk_soft_delete(vec![a.clone(), b.clone()]).await.unwrap();
2314 let mut data = service.repository().data.lock().await;
2315 data.iter_mut().find(|w| w.id == b).unwrap().name = "forbidden".into();
2316 (a, b)
2317 }
2318
2319 #[tokio::test]
2320 async fn restore_all_asks_the_guard_about_every_row_and_restores_none_when_one_is_refused() {
2321 let guard = Arc::new(NameGuard::default());
2322 let service = guarded(guard.clone());
2323 trash_with_a_refused_row(&service).await;
2324
2325 let err = service.restore_all().await.unwrap_err();
2326 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2327 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing restored");
2328 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::Restore));
2329 }
2330
2331 #[tokio::test]
2332 async fn empty_trash_asks_the_guard_about_every_row_and_deletes_none_when_one_is_refused() {
2333 let guard = Arc::new(NameGuard::default());
2334 let service = guarded(guard.clone());
2335 trash_with_a_refused_row(&service).await;
2336
2337 let err = service.empty_trash().await.unwrap_err();
2338 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2339 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing deleted");
2340 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::HardDelete));
2341
2342 let service = svc();
2344 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2345 service.soft_delete(&a).await.unwrap();
2346 assert_eq!(service.empty_trash().await.unwrap(), 1);
2347 assert_eq!(service.count_deleted().await.unwrap(), 0);
2348 }
2349
2350 #[tokio::test]
2353 async fn upsert_refuses_rather_than_creating_a_duplicate() {
2354 use crate::http::CrudService;
2355 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2356 let service = svc();
2357 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2358 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2359 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2360 assert!(!<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::supports_upsert());
2361 }
2362
2363 #[tokio::test]
2366 async fn bulk_create_is_one_repository_write_and_names_the_refused_item() {
2367 let service = guarded(Arc::new(NameGuard::default()));
2368 let names = ["a", "forbidden", "c"];
2369 let err = service
2370 .bulk_create(names.iter().map(|n| CreateWidgetDto { name: n.to_string() }).collect())
2371 .await
2372 .unwrap_err();
2373 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2374 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("1/name", "name_forbidden"));
2375 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2376
2377 let created = service
2378 .bulk_create(vec![CreateWidgetDto { name: "a".into() }, CreateWidgetDto { name: "b".into() }])
2379 .await
2380 .unwrap();
2381 assert_eq!(created.len(), 2);
2382 assert_eq!(
2383 service.repository().bulk_creates.load(std::sync::atomic::Ordering::SeqCst),
2384 1,
2385 "the batch is written by one all-or-nothing repository call"
2386 );
2387 }
2388
2389 #[tokio::test]
2390 async fn bulk_create_refuses_an_oversized_batch() {
2391 let service = svc();
2392 let dtos = (0..=MAX_BATCH_SIZE).map(|n| CreateWidgetDto { name: format!("w{n}") }).collect();
2393 assert!(matches!(service.bulk_create(dtos).await, Err(ServiceError::Validation(_))));
2394 assert_eq!(service.count().await.unwrap(), 0);
2395 }
2396
2397 #[tokio::test]
2400 async fn bulk_updates_write_through_the_changed_columns_path() {
2401 let service = svc();
2402 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2403 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2404 service
2405 .bulk_update(vec![
2406 (a.clone(), UpdateWidgetDto { name: "a2".into() }),
2407 (b.clone(), UpdateWidgetDto { name: "b2".into() }),
2408 ])
2409 .await
2410 .unwrap();
2411 service
2412 .bulk_partial_update(vec![(a.clone(), patch(&[("name", serde_json::json!("a3"))]))])
2413 .await
2414 .unwrap();
2415 let repo = service.repository();
2416 assert_eq!(repo.changed_writes.lock().unwrap().len(), 3);
2417 assert_eq!(repo.full_writes.load(std::sync::atomic::Ordering::SeqCst), 0, "no full-row write");
2418 assert_eq!(service.get_by_id(&a).await.unwrap().unwrap().name, "a3");
2419 }
2420
2421 #[tokio::test]
2424 async fn the_trash_list_applies_filters() {
2425 let service = svc();
2426 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2427 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2428 service.bulk_soft_delete(vec![a, b]).await.unwrap();
2429 let filters = HashMap::from([("name".to_string(), "b".to_string())]);
2430 let (rows, total) = service.list_deleted_filtered(1, 20, filters).await.unwrap();
2431 assert_eq!((rows.len(), total), (1, 1));
2432 assert_eq!(rows[0].name, "b");
2433 }
2434
2435 mod http_surface {
2438 use super::*;
2439 use axum::body::Body;
2440 use axum::http::{Request, StatusCode};
2441 use tower::ServiceExt;
2442
2443 type Svc = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2444
2445 fn app() -> (axum::Router, Arc<Svc>) {
2446 let service = Arc::new(svc());
2447 let router = crate::http::BackboneCrudHandler::<Svc, Widget, CreateWidgetDto, UpdateWidgetDto, Widget>::routes(
2448 service.clone(),
2449 "/widgets",
2450 );
2451 (router, service)
2452 }
2453
2454 async fn call(app: &axum::Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, serde_json::Value) {
2455 let mut req = Request::builder().method(method).uri(uri);
2456 if body.is_some() {
2457 req = req.header("content-type", "application/json");
2458 }
2459 let req = req.body(Body::from(body.unwrap_or("").to_string())).unwrap();
2460 let res = app.clone().oneshot(req).await.unwrap();
2461 let status = res.status();
2462 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
2463 let json = if bytes.is_empty() {
2464 serde_json::Value::Null
2465 } else {
2466 serde_json::from_slice(&bytes).unwrap_or_else(|_| {
2467 panic!("not a JSON body: {}", String::from_utf8_lossy(&bytes))
2468 })
2469 };
2470 (status, json)
2471 }
2472
2473 #[tokio::test]
2474 async fn a_malformed_path_id_is_a_400_naming_the_id() {
2475 let (app, _) = app();
2476 for (method, uri) in [("GET", "/widgets/not-a-uuid"), ("DELETE", "/widgets/not-a-uuid")] {
2477 let (status, body) = call(&app, method, uri, None).await;
2478 assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {uri}");
2479 assert_eq!(body["violations"][0]["code"], "invalid_id");
2480 assert_eq!(body["violations"][0]["path"], "id");
2481 }
2482 }
2483
2484 #[tokio::test]
2485 async fn a_server_fault_answers_generically_and_keeps_the_detail_out() {
2486 let (app, _) = app();
2487 let (status, body) = call(&app, "GET", &format!("/widgets/{BROKEN_ID}"), None).await;
2488 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
2489 assert_eq!(body["error"], crate::http::INTERNAL_ERROR_MESSAGE);
2490 assert!(!body.to_string().contains("secret_schema"), "{body}");
2491 }
2492
2493 #[tokio::test]
2494 async fn a_duplicate_on_put_and_patch_is_a_409_like_on_create() {
2495 let (app, service) = app();
2496 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2497 let uri = format!("/widgets/{}", w.id);
2498 let (status, _) = call(&app, "PUT", &uri, Some(r#"{"name":"taken"}"#)).await;
2499 assert_eq!(status, StatusCode::CONFLICT);
2500 let (status, _) = call(&app, "PATCH", &uri, Some(r#"{"name":"taken"}"#)).await;
2501 assert_eq!(status, StatusCode::CONFLICT);
2502 }
2503
2504 #[tokio::test]
2505 async fn an_unknown_key_is_refused_on_create_put_and_bulk() {
2506 let (app, service) = app();
2507 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","colour":"red"}"#)).await;
2508 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2509 assert_eq!(body["violations"][0]["code"], "unknown_field");
2510 assert_eq!(body["violations"][0]["path"], "colour");
2511 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2512
2513 let (status, _) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","id":"x","metadata":{}}"#)).await;
2515 assert_eq!(status, StatusCode::CREATED);
2516
2517 let w = service.list(1, 1, HashMap::new()).await.unwrap().0.remove(0);
2518 let (status, body) = call(&app, "PUT", &format!("/widgets/{}", w.id), Some(r#"{"name":"b","colour":"red"}"#)).await;
2519 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2520 assert_eq!(body["violations"][0]["path"], "colour");
2521
2522 let (status, body) = call(&app, "POST", "/widgets/bulk", Some(r#"[{"name":"x"},{"name":"y","colour":"red"}]"#)).await;
2523 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2524 assert_eq!(body["violations"][0]["path"], "1/colour");
2525
2526 let bulk = format!(r#"[{{"id":"{}","name":"z","colour":"red"}}]"#, w.id);
2527 let (status, body) = call(&app, "PUT", "/widgets/bulk", Some(&bulk)).await;
2528 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2529 assert_eq!(body["violations"][0]["path"], "0/colour");
2530 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a", "nothing written");
2531 }
2532
2533 #[tokio::test]
2534 async fn a_body_that_cannot_be_read_answers_in_the_json_envelope() {
2535 let (app, _) = app();
2536 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": "#)).await;
2537 assert_eq!(status, StatusCode::BAD_REQUEST);
2538 assert_eq!(body["success"], false);
2539 assert_eq!(body["violations"][0]["code"], "malformed_body");
2540
2541 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": 5}"#)).await;
2542 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2543 assert_eq!(body["violations"][0]["path"], "name");
2544 }
2545
2546 #[tokio::test]
2547 async fn the_generic_upsert_route_answers_501() {
2548 let (app, service) = app();
2549 let (status, _) = call(&app, "POST", "/widgets/upsert", Some(r#"{"name":"a"}"#)).await;
2550 assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
2551 assert_eq!(service.count().await.unwrap(), 0);
2552 }
2553
2554 #[tokio::test]
2555 async fn the_trash_route_reads_the_filters() {
2556 let (app, service) = app();
2557 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2558 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2559 service.bulk_soft_delete(vec![a, b]).await.unwrap();
2560 let (status, body) = call(&app, "GET", "/widgets/trash?name=b", None).await;
2561 assert_eq!(status, StatusCode::OK);
2562 assert_eq!(body["data"].as_array().unwrap().len(), 1);
2563 assert_eq!(body["data"][0]["name"], "b");
2564 }
2565 }
2566}