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};
39use crate::write_hooks::{Change, NoWriteHooks, WriteCtx, WriteHooks};
40
41#[derive(Debug, thiserror::Error)]
45pub enum ServiceError {
46 #[error("not found")]
47 NotFound,
48
49 #[error("already exists: {0}")]
50 AlreadyExists(String),
51
52 #[error("validation failed: {0}")]
53 Validation(String),
54
55 #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
59 Violations(Vec<crate::violation::Violation>),
60
61 #[error("repository error: {0}")]
62 Repository(#[from] RepositoryError),
63
64 #[error("internal error: {0}")]
65 Internal(String),
66}
67
68pub type ServiceResult<T> = Result<T, ServiceError>;
69
70pub trait FromCreateDto<DTO>: Sized {
77 fn from_create_dto(dto: DTO) -> ServiceResult<Self>;
78}
79
80pub trait ApplyUpdateDto<DTO> {
82 fn apply_update(self, dto: DTO) -> ServiceResult<Self>
83 where
84 Self: Sized;
85}
86
87#[async_trait]
93pub trait ServiceLifecycle<E: PersistentEntity>: Send + Sync {
94 async fn before_create(&self, entity: &mut E) -> ServiceResult<()> {
95 let _ = entity;
96 Ok(())
97 }
98
99 async fn after_create(&self, entity: &E) -> ServiceResult<()> {
100 let _ = entity;
101 Ok(())
102 }
103
104 async fn before_update(&self, entity: &mut E) -> ServiceResult<()> {
105 let _ = entity;
106 Ok(())
107 }
108
109 async fn after_update(&self, entity: &E) -> ServiceResult<()> {
110 let _ = entity;
111 Ok(())
112 }
113
114 async fn before_delete(&self, entity: &E) -> ServiceResult<()> {
115 let _ = entity;
116 Ok(())
117 }
118
119 async fn after_delete(&self, id: &str) -> ServiceResult<()> {
120 let _ = id;
121 Ok(())
122 }
123
124 async fn before_restore(&self, entity: &E) -> ServiceResult<()> {
126 let _ = entity;
127 Ok(())
128 }
129
130 async fn before_hard_delete(&self, entity: &E) -> ServiceResult<()> {
133 let _ = entity;
134 Ok(())
135 }
136
137 async fn before_restore_all(&self) -> ServiceResult<()> {
140 Ok(())
141 }
142
143 async fn before_empty_trash(&self) -> ServiceResult<()> {
146 Ok(())
147 }
148}
149
150pub struct NoOpLifecycle<E> {
152 _phantom: PhantomData<E>,
153}
154
155impl<E> NoOpLifecycle<E> {
156 pub fn new() -> Self {
157 Self {
158 _phantom: PhantomData,
159 }
160 }
161}
162
163impl<E> Default for NoOpLifecycle<E> {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169#[async_trait]
170impl<E: PersistentEntity> ServiceLifecycle<E> for NoOpLifecycle<E> {}
171
172pub struct GenericCrudService<E, C, U, R>
186where
187 E: PersistentEntity + Clone,
188 R: CrudRepository<E>,
189{
190 repository: Arc<R>,
191 lifecycle: Arc<dyn ServiceLifecycle<E>>,
192 event_publisher: Arc<dyn CrudEventPublisher<E>>,
193 guard: Arc<dyn crate::write_guard::WriteGuard<E>>,
194 hooks: Arc<dyn WriteHooks<E>>,
195 _phantom: PhantomData<(C, U)>,
196}
197
198impl<E, C, U, R> GenericCrudService<E, C, U, R>
199where
200 E: PersistentEntity + Clone + FromCreateDto<C> + ApplyUpdateDto<U>,
201 C: Send + Sync + 'static,
202 U: Send + Sync + 'static,
203 R: CrudRepository<E>,
204{
205 pub fn new(
207 repository: Arc<R>,
208 lifecycle: Arc<dyn ServiceLifecycle<E>>,
209 event_publisher: Arc<dyn CrudEventPublisher<E>>,
210 ) -> Self {
211 Self {
212 repository,
213 lifecycle,
214 event_publisher,
215 guard: Arc::new(crate::write_guard::AllowAll),
216 hooks: Arc::new(NoWriteHooks),
217 _phantom: PhantomData,
218 }
219 }
220
221 pub fn with_guard(mut self, guard: Arc<dyn crate::write_guard::WriteGuard<E>>) -> Self {
224 self.guard = guard;
225 self
226 }
227
228 pub fn with_write_hooks(mut self, hooks: Arc<dyn WriteHooks<E>>) -> Self {
232 self.hooks = hooks;
233 self
234 }
235
236 fn write_ctx(&self) -> WriteCtx {
238 WriteCtx::current(self.hooks.outbox_schema())
239 }
240
241 async fn atomically<T>(
249 &self,
250 work: impl std::future::Future<Output = ServiceResult<T>>,
251 ) -> ServiceResult<T> {
252 let Some(pool) = self.repository.write_pool() else {
253 return work.await;
254 };
255 match backbone_orm::in_write_tx_result(pool, work).await {
256 Ok(value) => Ok(value),
257 Err(backbone_orm::WriteTxFailure::Inner(e)) => Err(e),
258 Err(backbone_orm::WriteTxFailure::Db(e)) => {
259 Err(ServiceError::Repository(RepositoryError::from_source(e)))
260 }
261 }
262 }
263
264 async fn publish_all(&self, events: Vec<CrudEvent<E>>) {
266 for event in events {
267 let _ = self.event_publisher.publish(event).await;
268 }
269 }
270
271 async fn consult(
273 &self,
274 kind: crate::write_guard::WriteKind,
275 before: Option<&E>,
276 after: Option<&E>,
277 ) -> ServiceResult<()> {
278 self.consult_as(kind, before, after, true).await
279 }
280
281 async fn consult_quietly(
285 &self,
286 kind: crate::write_guard::WriteKind,
287 before: Option<&E>,
288 after: Option<&E>,
289 ) -> ServiceResult<()> {
290 self.consult_as(kind, before, after, false).await
291 }
292
293 async fn consult_as(
294 &self,
295 kind: crate::write_guard::WriteKind,
296 before: Option<&E>,
297 after: Option<&E>,
298 log_shadow: bool,
299 ) -> ServiceResult<()> {
300 let ctx = crate::write_guard::WriteCtx::new(kind, before, after);
301 let outcome = self.guard.check(&ctx).await;
302 for v in outcome.shadow.iter().filter(|_| log_shadow) {
303 tracing::warn!(
304 target: "backbone::write_guard",
305 entity = std::any::type_name::<E>(),
306 kind = ?kind,
307 code = %v.code,
308 path = %v.path,
309 "a rule running in shadow would refuse this write: {}",
310 v.message
311 );
312 }
313 if outcome.refuse.is_empty() {
314 Ok(())
315 } else {
316 Err(ServiceError::Violations(outcome.refuse))
317 }
318 }
319
320 async fn check_references(&self, before: Option<&E>, after: &E) -> ServiceResult<()> {
331 let references = E::references();
332 if references.is_empty() {
333 return Ok(());
334 }
335 let after_obj = as_object(after)?;
336 let before_obj = before.map(as_object).transpose()?;
337 let mut refused = Vec::new();
338 for reference in references {
339 let Some(value) = after_obj.get(reference.field).filter(|v| !v.is_null()) else {
340 continue;
341 };
342 if before_obj.as_ref().is_some_and(|b| b.get(reference.field) == Some(value)) {
343 continue;
344 }
345 let visible = self
346 .repository
347 .reference_visible(reference.table, reference.column, value)
348 .await
349 .map_err(ServiceError::Repository)?;
350 if visible == Some(false) {
351 refused.push(crate::violation::Violation::new(
352 reference.field,
353 "reference_missing",
354 format!("{} refers to a record that does not exist", reference.field),
355 ));
356 }
357 }
358 if refused.is_empty() {
359 Ok(())
360 } else {
361 Err(ServiceError::Violations(refused))
362 }
363 }
364
365 pub fn with_lifecycle(repository: Arc<R>, lifecycle: Arc<dyn ServiceLifecycle<E>>) -> Self {
367 Self::new(repository, lifecycle, NoOpCrudEventPublisher::arc())
368 }
369
370 pub fn with_repository(repository: Arc<R>) -> Self {
372 Self::new(
373 repository,
374 Arc::new(NoOpLifecycle::new()),
375 NoOpCrudEventPublisher::arc(),
376 )
377 }
378
379 pub fn with_event_publisher(mut self, publisher: Arc<dyn CrudEventPublisher<E>>) -> Self {
381 self.event_publisher = publisher;
382 self
383 }
384
385 pub fn repository(&self) -> &R {
387 &self.repository
388 }
389
390 pub async fn list(
393 &self,
394 page: u32,
395 limit: u32,
396 filters: HashMap<String, String>,
397 ) -> ServiceResult<(Vec<E>, u64)> {
398 self.repository
399 .list_filtered(page, limit, filters)
400 .await
401 .map_err(ServiceError::Repository)
402 }
403
404 pub async fn list_with_info(
407 &self,
408 page: u32,
409 limit: u32,
410 filters: HashMap<String, String>,
411 ) -> ServiceResult<(Vec<E>, backbone_orm::repository::PaginationInfo)> {
412 self.repository
413 .list_filtered_with_info(page, limit, filters)
414 .await
415 .map_err(ServiceError::Repository)
416 }
417
418 pub async fn aggregate(
420 &self,
421 spec: &backbone_orm::repository::AggregateSpec,
422 filters: HashMap<String, String>,
423 ) -> ServiceResult<backbone_orm::repository::AggregateResult> {
424 self.repository
425 .aggregate_filtered(spec, filters)
426 .await
427 .map_err(ServiceError::Repository)
428 }
429
430 pub async fn create(&self, dto: C) -> ServiceResult<E> {
431 let (saved, events) = self
432 .atomically(async {
433 let ctx = self.write_ctx();
434 let mut entity = E::from_create_dto(dto)?;
435 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity)).await?;
436 self.lifecycle.before_create(&mut entity).await?;
437 self.hooks.before(&ctx, &mut Change::Created { after: &mut entity }).await?;
438 self.check_references(None, &entity).await?;
439 let mut saved = self
440 .repository
441 .create(entity)
442 .await
443 .map_err(ServiceError::Repository)?;
444 self.lifecycle.after_create(&saved).await?;
445 self.hooks.after(&ctx, &Change::Created { after: &mut saved }).await?;
446
447 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
448 let event = CrudEvent::Created { entity: saved.clone(), metadata: meta };
449 Ok((saved, vec![event]))
450 })
451 .await?;
452 self.publish_all(events).await;
453 Ok(saved)
454 }
455
456 pub async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
457 self.repository
458 .find_by_id(id)
459 .await
460 .map_err(ServiceError::Repository)
461 }
462
463 pub async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
464 let written = self
465 .atomically(async {
466 let ctx = self.write_ctx();
467 let existing = self
468 .repository
469 .find_by_id(id)
470 .await
471 .map_err(ServiceError::Repository)?;
472 let Some(before) = existing else {
473 return Ok(None);
474 };
475 let mut updated = before.clone().apply_update(dto)?;
476 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
477 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
478 self.lifecycle.before_update(&mut updated).await?;
479 self.hooks.before(&ctx, &mut Change::update(&before, &mut updated)).await?;
480 self.check_references(Some(&before), &updated).await?;
481 let Some(mut saved) = self
483 .repository
484 .update_changed(&before, updated)
485 .await
486 .map_err(ServiceError::Repository)?
487 else {
488 return Ok(None);
489 };
490 self.lifecycle.after_update(&saved).await?;
491 self.hooks.after(&ctx, &Change::update(&before, &mut saved)).await?;
492
493 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
494 let event = CrudEvent::Updated { before, after: saved.clone(), metadata: meta };
495 Ok(Some((saved, vec![event])))
496 })
497 .await?;
498 let Some((saved, events)) = written else {
499 return Ok(None);
500 };
501 self.publish_all(events).await;
502 Ok(Some(saved))
503 }
504
505 pub async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
506 let (deleted, events) = self
507 .atomically(async {
508 let ctx = self.write_ctx();
509 let existing = self
510 .repository
511 .find_by_id(id)
512 .await
513 .map_err(ServiceError::Repository)?;
514 let Some(entity) = existing else {
515 return Ok((false, Vec::new()));
516 };
517 self.consult(crate::write_guard::WriteKind::Delete, Some(&entity), None).await?;
518 self.lifecycle.before_delete(&entity).await?;
519 self.hooks
520 .before(&ctx, &mut Change::Deleted { before: &entity, hard: false })
521 .await?;
522 let deleted = self
523 .repository
524 .soft_delete(id)
525 .await
526 .map_err(ServiceError::Repository)?;
527 self.lifecycle.after_delete(id).await?;
528 if !deleted {
529 return Ok((false, Vec::new()));
530 }
531 self.hooks
532 .after(&ctx, &Change::Deleted { before: &entity, hard: false })
533 .await?;
534
535 let meta = EventMetadata::new(id, std::any::type_name::<E>());
536 Ok((true, vec![CrudEvent::SoftDeleted { entity, metadata: meta }]))
537 })
538 .await?;
539 self.publish_all(events).await;
540 Ok(deleted)
541 }
542
543 pub async fn restore(&self, id: &str) -> ServiceResult<Option<E>> {
544 let (restored, events) = self
545 .atomically(async {
546 let ctx = self.write_ctx();
547 let Some(trashed) = self.get_deleted_by_id(id).await? else {
548 return Ok((None, Vec::new()));
549 };
550 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
551 self.lifecycle.before_restore(&trashed).await?;
552 self.hooks.before(&ctx, &mut Change::Restored { entity: &trashed }).await?;
553 let restored = self
554 .repository
555 .restore(id)
556 .await
557 .map_err(ServiceError::Repository)?;
558
559 let mut events = Vec::new();
560 if let Some(ref entity) = restored {
561 self.hooks.after(&ctx, &Change::Restored { entity }).await?;
562 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
563 events.push(CrudEvent::Restored { entity: entity.clone(), metadata: meta });
564 }
565 Ok((restored, events))
566 })
567 .await?;
568 self.publish_all(events).await;
569 Ok(restored)
570 }
571
572 pub async fn hard_delete(&self, id: &str) -> ServiceResult<bool> {
573 let (deleted, events) = self
574 .atomically(async {
575 let ctx = self.write_ctx();
576 let Some(entity) = self.get_deleted_by_id(id).await? else {
577 return Ok((false, Vec::new()));
578 };
579 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&entity), None).await?;
580 self.lifecycle.before_hard_delete(&entity).await?;
581 self.hooks
582 .before(&ctx, &mut Change::Deleted { before: &entity, hard: true })
583 .await?;
584 let deleted = self
585 .repository
586 .hard_delete(id)
587 .await
588 .map_err(ServiceError::Repository)?;
589 if !deleted {
590 return Ok((false, Vec::new()));
591 }
592 self.hooks
593 .after(&ctx, &Change::Deleted { before: &entity, hard: true })
594 .await?;
595
596 let meta = EventMetadata::new(id, std::any::type_name::<E>());
597 let event = CrudEvent::HardDeleted { entity_id: id.to_string(), metadata: meta };
598 Ok((true, vec![event]))
599 })
600 .await?;
601 self.publish_all(events).await;
602 Ok(deleted)
603 }
604
605 pub async fn list_deleted(
606 &self,
607 page: u32,
608 limit: u32,
609 ) -> ServiceResult<(Vec<E>, u64)> {
610 self.repository
611 .list_deleted(page, limit)
612 .await
613 .map_err(ServiceError::Repository)
614 }
615
616 pub async fn empty_trash(&self) -> ServiceResult<u64> {
624 let (affected, events) = self
625 .atomically(async {
626 let ctx = self.write_ctx();
627 self.lifecycle.before_empty_trash().await?;
628 let trashed = self.whole_trash("emptied").await?;
629 if trashed.is_empty() {
630 return Ok((0, Vec::new()));
631 }
632 for entity in &trashed {
633 self.consult(crate::write_guard::WriteKind::HardDelete, Some(entity), None).await?;
634 self.lifecycle.before_hard_delete(entity).await?;
635 self.hooks
636 .before(&ctx, &mut Change::Deleted { before: entity, hard: true })
637 .await?;
638 }
639 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
640 let affected = self
641 .repository
642 .bulk_hard_delete(&ids)
643 .await
644 .map_err(ServiceError::Repository)?;
645 for entity in &trashed {
646 self.hooks
647 .after(&ctx, &Change::Deleted { before: entity, hard: true })
648 .await?;
649 }
650 Ok((affected, hard_deleted_events::<E>(&ids)))
651 })
652 .await?;
653 self.publish_all(events).await;
654 Ok(affected)
655 }
656
657 async fn whole_trash(&self, verb: &str) -> ServiceResult<Vec<E>> {
663 let too_many = |total: u64| {
664 ServiceError::Validation(format!(
665 "The trash holds {total} items, more than the {MAX_BATCH_SIZE} that can be {verb} \
666 at once. Select the items to act on instead."
667 ))
668 };
669 let per_page = backbone_orm::repository::PaginationParams::MAX_PER_PAGE;
670 let mut rows: Vec<E> = Vec::new();
671 let mut page = 1u32;
672 loop {
673 let (batch, total) = self
674 .repository
675 .list_deleted(page, per_page)
676 .await
677 .map_err(ServiceError::Repository)?;
678 if total > MAX_BATCH_SIZE as u64 {
679 return Err(too_many(total));
680 }
681 let fetched = batch.len();
682 rows.extend(batch);
683 if rows.len() > MAX_BATCH_SIZE {
684 return Err(too_many(rows.len() as u64));
685 }
686 if fetched == 0 || rows.len() as u64 >= total || fetched < per_page as usize {
687 break;
688 }
689 page += 1;
690 }
691 Ok(rows)
692 }
693
694 pub async fn count(&self) -> ServiceResult<u64> {
695 self.repository
696 .count()
697 .await
698 .map_err(ServiceError::Repository)
699 }
700
701 pub async fn count_active(&self) -> ServiceResult<u64> {
702 self.repository
703 .count()
704 .await
705 .map_err(ServiceError::Repository)
706 }
707
708 pub async fn find_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
710 self.get_by_id(id).await
711 }
712
713 pub async fn permanent_delete(&self, id: &str) -> ServiceResult<bool> {
715 self.hard_delete(id).await
716 }
717
718 pub async fn count_deleted(&self) -> ServiceResult<u64> {
720 self.repository
721 .count_deleted()
722 .await
723 .map_err(ServiceError::Repository)
724 }
725
726 pub async fn get_deleted_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
728 self.repository
729 .find_by_id_including_deleted(id)
730 .await
731 .map_err(ServiceError::Repository)
732 }
733
734 pub async fn list_deleted_filtered(
736 &self,
737 page: u32,
738 limit: u32,
739 filters: HashMap<String, String>,
740 ) -> ServiceResult<(Vec<E>, u64)> {
741 self.repository
742 .list_deleted_filtered(page, limit, filters)
743 .await
744 .map_err(ServiceError::Repository)
745 }
746
747 pub async fn upsert(&self, dto: C) -> ServiceResult<E> {
755 let _ = dto;
756 Err(ServiceError::Validation(UPSERT_UNSUPPORTED.to_string()))
757 }
758
759 pub async fn partial_update(
764 &self,
765 id: &str,
766 fields: HashMap<String, serde_json::Value>,
767 ) -> ServiceResult<Option<E>>
768 where
769 E: serde::Serialize + serde::de::DeserializeOwned,
770 {
771 let written = self
772 .atomically(async {
773 let ctx = self.write_ctx();
774 let existing = self
775 .repository
776 .find_by_id(id)
777 .await
778 .map_err(ServiceError::Repository)?;
779 let Some(before) = existing else {
780 return Ok(None);
781 };
782 let mut patched = merge_patch(&before, fields)?;
783 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
784 self.lifecycle.before_update(&mut patched).await?;
785 self.hooks.before(&ctx, &mut Change::update(&before, &mut patched)).await?;
786 self.check_references(Some(&before), &patched).await?;
787 let Some(mut saved) = self
791 .repository
792 .update_changed(&before, patched)
793 .await
794 .map_err(ServiceError::Repository)?
795 else {
796 return Ok(None);
797 };
798 self.lifecycle.after_update(&saved).await?;
799 self.hooks.after(&ctx, &Change::update(&before, &mut saved)).await?;
800
801 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
802 let event = CrudEvent::Updated { before, after: saved.clone(), metadata: meta };
803 Ok(Some((saved, vec![event])))
804 })
805 .await?;
806 let Some((saved, events)) = written else {
807 return Ok(None);
808 };
809 self.publish_all(events).await;
810 Ok(Some(saved))
811 }
812
813 pub async fn bulk_create(&self, dtos: Vec<C>) -> ServiceResult<Vec<E>>
820 where
821 E: FromCreateDto<C>,
822 {
823 check_batch_size(dtos.len())?;
824 let (saved, events) = self
825 .atomically(async {
826 let ctx = self.write_ctx();
827 let mut prepared = Vec::with_capacity(dtos.len());
828 for (index, dto) in dtos.into_iter().enumerate() {
829 let at = |e: ServiceError| at_batch_index(e, index);
830 let mut entity = E::from_create_dto(dto).map_err(at)?;
831 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity))
832 .await
833 .map_err(at)?;
834 self.lifecycle.before_create(&mut entity).await.map_err(at)?;
835 self.hooks
836 .before(&ctx, &mut Change::Created { after: &mut entity })
837 .await
838 .map_err(at)?;
839 self.check_references(None, &entity).await.map_err(at)?;
840 prepared.push(entity);
841 }
842 let mut saved = self
843 .repository
844 .bulk_create(prepared)
845 .await
846 .map_err(ServiceError::Repository)?;
847 let mut events = Vec::with_capacity(saved.len());
848 for entity in saved.iter_mut() {
849 self.lifecycle.after_create(entity).await?;
850 self.hooks.after(&ctx, &Change::Created { after: entity }).await?;
851 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
852 events.push(CrudEvent::Created { entity: entity.clone(), metadata: meta });
853 }
854 Ok((saved, events))
855 })
856 .await?;
857 self.publish_all(events).await;
858 Ok(saved)
859 }
860
861 pub async fn bulk_soft_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
870 check_batch_size(ids.len())?;
871 let ids = dedup_ids(ids);
872 let (affected, events) = self
873 .atomically(async {
874 let ctx = self.write_ctx();
875 let mut entities = Vec::with_capacity(ids.len());
880 for id in &ids {
881 match self
882 .repository
883 .find_by_id(id)
884 .await
885 .map_err(ServiceError::Repository)?
886 {
887 Some(e) => entities.push(e),
888 None => return Err(ServiceError::Validation(format!("id '{id}' not found"))),
889 }
890 }
891 for entity in &entities {
892 self.consult(crate::write_guard::WriteKind::Delete, Some(entity), None).await?;
893 self.lifecycle.before_delete(entity).await?;
894 self.hooks
895 .before(&ctx, &mut Change::Deleted { before: entity, hard: false })
896 .await?;
897 }
898 let affected = self
899 .repository
900 .bulk_soft_delete(&ids)
901 .await
902 .map_err(ServiceError::Repository)?;
903 let mut events = Vec::with_capacity(entities.len());
904 for (id, entity) in ids.iter().zip(entities.into_iter()) {
905 self.lifecycle.after_delete(id).await?;
906 self.hooks
907 .after(&ctx, &Change::Deleted { before: &entity, hard: false })
908 .await?;
909 let meta = EventMetadata::new(id, std::any::type_name::<E>());
910 events.push(CrudEvent::SoftDeleted { entity, metadata: meta });
911 }
912 Ok((affected, events))
913 })
914 .await?;
915 self.publish_all(events).await;
916 Ok(affected)
917 }
918
919 pub async fn bulk_restore(&self, ids: Vec<String>) -> ServiceResult<Vec<E>> {
921 check_batch_size(ids.len())?;
922 let ids = dedup_ids(ids);
923 let (restored, events) = self
924 .atomically(async {
925 let ctx = self.write_ctx();
926 for id in &ids {
929 if let Some(trashed) = self.get_deleted_by_id(id).await? {
930 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
931 self.lifecycle.before_restore(&trashed).await?;
932 self.hooks.before(&ctx, &mut Change::Restored { entity: &trashed }).await?;
933 }
934 }
935 let restored = self
936 .repository
937 .bulk_restore(&ids)
938 .await
939 .map_err(ServiceError::Repository)?;
940 let events = self.restored_events(&ctx, &restored).await?;
941 Ok((restored, events))
942 })
943 .await?;
944 self.publish_all(events).await;
945 Ok(restored)
946 }
947
948 async fn restored_events(&self, ctx: &WriteCtx, restored: &[E]) -> ServiceResult<Vec<CrudEvent<E>>> {
950 let mut events = Vec::with_capacity(restored.len());
951 for entity in restored {
952 self.hooks.after(ctx, &Change::Restored { entity }).await?;
953 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
954 events.push(CrudEvent::Restored { entity: entity.clone(), metadata: meta });
955 }
956 Ok(events)
957 }
958
959 pub async fn restore_all(&self) -> ServiceResult<u64> {
966 let (count, events) = self
967 .atomically(async {
968 let ctx = self.write_ctx();
969 self.lifecycle.before_restore_all().await?;
970 let trashed = self.whole_trash("restored").await?;
971 if trashed.is_empty() {
972 return Ok((0, Vec::new()));
973 }
974 for entity in &trashed {
975 self.consult(crate::write_guard::WriteKind::Restore, Some(entity), None).await?;
976 self.lifecycle.before_restore(entity).await?;
977 self.hooks.before(&ctx, &mut Change::Restored { entity }).await?;
978 }
979 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
980 let restored = self
981 .repository
982 .bulk_restore(&ids)
983 .await
984 .map_err(ServiceError::Repository)?;
985 let events = self.restored_events(&ctx, &restored).await?;
986 Ok((restored.len() as u64, events))
987 })
988 .await?;
989 self.publish_all(events).await;
990 Ok(count)
991 }
992
993 pub async fn bulk_permanent_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
995 check_batch_size(ids.len())?;
996 let ids = dedup_ids(ids);
997 let (affected, events) = self
998 .atomically(async {
999 let ctx = self.write_ctx();
1000 let mut seen = Vec::new();
1006 for id in &ids {
1007 if let Some(trashed) = self.get_deleted_by_id(id).await? {
1008 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&trashed), None).await?;
1009 self.lifecycle.before_hard_delete(&trashed).await?;
1010 self.hooks
1011 .before(&ctx, &mut Change::Deleted { before: &trashed, hard: true })
1012 .await?;
1013 seen.push(trashed);
1014 }
1015 }
1016 let affected = self
1017 .repository
1018 .bulk_hard_delete(&ids)
1019 .await
1020 .map_err(ServiceError::Repository)?;
1021 for trashed in &seen {
1022 self.hooks
1023 .after(&ctx, &Change::Deleted { before: trashed, hard: true })
1024 .await?;
1025 }
1026 Ok((affected, hard_deleted_events::<E>(&ids)))
1027 })
1028 .await?;
1029 self.publish_all(events).await;
1030 Ok(affected)
1031 }
1032
1033 pub async fn bulk_update(&self, items: Vec<(String, U)>) -> ServiceResult<Vec<E>> {
1035 check_batch_size(items.len())?;
1036 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
1037 return Err(ServiceError::Validation(format!(
1038 "duplicate id '{dup}' in bulk update"
1039 )));
1040 }
1041 let (saved, events) = self
1042 .atomically(async {
1043 let ctx = self.write_ctx();
1044 let mut befores = Vec::with_capacity(items.len());
1045 let mut prepared = Vec::with_capacity(items.len());
1046 for (index, (id, dto)) in items.into_iter().enumerate() {
1047 let Some(before) = self
1048 .repository
1049 .find_by_id(&id)
1050 .await
1051 .map_err(ServiceError::Repository)?
1052 else {
1053 return Err(ServiceError::Validation(format!("id '{id}' not found")));
1054 };
1055 let mut updated = before.clone().apply_update(dto)?;
1056 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
1057 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
1058 self.lifecycle.before_update(&mut updated).await?;
1059 self.hooks.before(&ctx, &mut Change::update(&before, &mut updated)).await?;
1060 self.check_references(Some(&before), &updated)
1061 .await
1062 .map_err(|e| at_batch_index(e, index))?;
1063 prepared.push((before.clone(), updated));
1064 befores.push(before);
1065 }
1066 let mut saved = self
1069 .repository
1070 .bulk_update_changed(prepared)
1071 .await
1072 .map_err(ServiceError::Repository)?;
1073 let events = self.after_bulk_updates(&ctx, befores, &mut saved).await?;
1074 Ok((saved, events))
1075 })
1076 .await?;
1077 self.publish_all(events).await;
1078 Ok(saved)
1079 }
1080
1081 async fn after_bulk_updates(
1085 &self,
1086 ctx: &WriteCtx,
1087 befores: Vec<E>,
1088 saved: &mut [E],
1089 ) -> ServiceResult<Vec<CrudEvent<E>>> {
1090 let mut events = Vec::with_capacity(saved.len());
1091 for (before, after) in befores.into_iter().zip(saved.iter_mut()) {
1092 self.lifecycle.after_update(after).await?;
1093 self.hooks.after(ctx, &Change::update(&before, after)).await?;
1094 let meta = EventMetadata::new(after.entity_id(), std::any::type_name::<E>());
1095 events.push(CrudEvent::Updated { before, after: after.clone(), metadata: meta });
1096 }
1097 Ok(events)
1098 }
1099
1100 pub async fn bulk_partial_update(
1102 &self,
1103 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1104 ) -> ServiceResult<Vec<E>>
1105 where
1106 E: serde::Serialize + serde::de::DeserializeOwned,
1107 {
1108 check_batch_size(items.len())?;
1109 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
1110 return Err(ServiceError::Validation(format!(
1111 "duplicate id '{dup}' in bulk partial update"
1112 )));
1113 }
1114 let (saved, events) = self
1115 .atomically(async {
1116 let ctx = self.write_ctx();
1117 let mut befores = Vec::with_capacity(items.len());
1118 let mut prepared = Vec::with_capacity(items.len());
1119 for (index, (id, fields)) in items.into_iter().enumerate() {
1120 let Some(before) = self
1121 .repository
1122 .find_by_id(&id)
1123 .await
1124 .map_err(ServiceError::Repository)?
1125 else {
1126 return Err(ServiceError::Validation(format!("id '{id}' not found")));
1127 };
1128 let mut patched = merge_patch(&before, fields)?;
1129 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
1130 self.lifecycle.before_update(&mut patched).await?;
1131 self.hooks.before(&ctx, &mut Change::update(&before, &mut patched)).await?;
1132 self.check_references(Some(&before), &patched)
1133 .await
1134 .map_err(|e| at_batch_index(e, index))?;
1135 prepared.push((before.clone(), patched));
1136 befores.push(before);
1137 }
1138 let mut saved = self
1139 .repository
1140 .bulk_update_changed(prepared)
1141 .await
1142 .map_err(ServiceError::Repository)?;
1143 let events = self.after_bulk_updates(&ctx, befores, &mut saved).await?;
1144 Ok((saved, events))
1145 })
1146 .await?;
1147 self.publish_all(events).await;
1148 Ok(saved)
1149 }
1150
1151 pub async fn preview_bulk_partial_update(
1160 &self,
1161 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1162 ) -> ServiceResult<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>
1163 where
1164 E: serde::Serialize + serde::de::DeserializeOwned,
1165 {
1166 check_batch_size(items.len())?;
1167 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
1168 return Err(ServiceError::Validation(format!(
1169 "duplicate id '{dup}' in bulk partial update"
1170 )));
1171 }
1172 let mut rows = Vec::with_capacity(items.len());
1173 for (id, fields) in items {
1174 let outcome = self.preview_one(&id, fields).await;
1175 rows.push(crate::http::BulkPreviewRow { id, outcome });
1176 }
1177 Ok(rows)
1178 }
1179
1180 async fn preview_one(
1181 &self,
1182 id: &str,
1183 fields: HashMap<String, serde_json::Value>,
1184 ) -> ServiceResult<(E, E)>
1185 where
1186 E: serde::Serialize + serde::de::DeserializeOwned,
1187 {
1188 let Some(before) = self
1189 .repository
1190 .find_by_id(id)
1191 .await
1192 .map_err(ServiceError::Repository)?
1193 else {
1194 return Err(ServiceError::Validation(format!("id '{id}' not found")));
1195 };
1196 let mut patched = merge_patch(&before, fields)?;
1197 self.consult_quietly(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched))
1198 .await?;
1199 self.lifecycle.before_update(&mut patched).await?;
1200 self.check_references(Some(&before), &patched).await?;
1201 Ok((before, patched))
1202 }
1203}
1204
1205pub const ALWAYS_WRITE_PROTECTED: &[&str] = &["id", "metadata"];
1212
1213fn as_object(entity: &impl serde::Serialize) -> ServiceResult<serde_json::Map<String, serde_json::Value>> {
1214 match serde_json::to_value(entity).map_err(|e| ServiceError::Internal(e.to_string()))? {
1215 serde_json::Value::Object(map) => Ok(map),
1216 _ => Err(ServiceError::Internal("an entity must serialize to a JSON object".into())),
1217 }
1218}
1219
1220fn refuse_protected_changes<E: PersistentEntity>(
1231 before: &serde_json::Map<String, serde_json::Value>,
1232 after: &serde_json::Map<String, serde_json::Value>,
1233) -> ServiceResult<()> {
1234 let in_transition = crate::write_guard::current_transition().is_some();
1235 let refused: Vec<crate::violation::Violation> = ALWAYS_WRITE_PROTECTED
1236 .iter()
1237 .chain(E::write_protected_fields())
1238 .filter(|key| !(in_transition && E::state_fields().contains(*key)))
1239 .filter(|key| before.get(**key) != after.get(**key))
1240 .map(|key| {
1241 crate::violation::Violation::new(
1242 *key,
1243 "field_not_writable",
1244 format!(
1245 "field_not_writable: `{key}` cannot be changed by a generic write; \
1246 it changes only through the operation that owns it"
1247 ),
1248 )
1249 })
1250 .collect();
1251 if refused.is_empty() {
1252 Ok(())
1253 } else {
1254 Err(ServiceError::Violations(refused))
1255 }
1256}
1257
1258fn merge_patch<E: PersistentEntity>(
1266 before: &E,
1267 fields: HashMap<String, serde_json::Value>,
1268) -> ServiceResult<E> {
1269 let before_obj = as_object(before)?;
1270 let mut merged = before_obj.clone();
1271 let mut given = Vec::new();
1272 for (key, value) in fields {
1273 if !value.is_null() {
1274 given.push(key.clone());
1275 }
1276 merged.insert(key, value);
1277 }
1278 let patched: E = serde_path_to_error::deserialize(serde_json::Value::Object(merged))
1279 .map_err(|e| patch_type_refusal(&e))?;
1280 let after_obj = as_object(&patched)?;
1281
1282 let mut unknown: Vec<String> = given.into_iter().filter(|k| !after_obj.contains_key(k)).collect();
1283 if !unknown.is_empty() {
1284 unknown.sort();
1285 return Err(ServiceError::Violations(
1286 unknown
1287 .into_iter()
1288 .map(|k| {
1289 let message = format!("unknown_field: `{k}` is not a field of this record");
1290 crate::violation::Violation::new(k, "unknown_field", message)
1291 })
1292 .collect(),
1293 ));
1294 }
1295 refuse_protected_changes::<E>(&before_obj, &after_obj)?;
1296 Ok(patched)
1297}
1298
1299fn patch_type_refusal(err: &serde_path_to_error::Error<serde_json::Error>) -> ServiceError {
1303 let path = err
1304 .path()
1305 .iter()
1306 .filter_map(|segment| match segment {
1307 serde_path_to_error::Segment::Seq { index } => Some(index.to_string()),
1308 serde_path_to_error::Segment::Map { key } => Some(key.clone()),
1309 serde_path_to_error::Segment::Enum { variant } => Some(variant.clone()),
1310 serde_path_to_error::Segment::Unknown => None,
1311 })
1312 .collect::<Vec<_>>()
1313 .join("/");
1314 let message = err.inner().to_string();
1315 let message = match message.rfind(" at line ") {
1316 Some(at) => message[..at].to_string(),
1317 None => message,
1318 };
1319 let violation = if let Some(field) = message
1320 .strip_prefix("missing field `")
1321 .and_then(|rest| rest.split('`').next())
1322 {
1323 let field_path = if path.is_empty() { field.to_string() } else { format!("{path}/{field}") };
1324 crate::violation::Violation::new(field_path.clone(), "required", format!("{field_path} is required"))
1325 } else if message.starts_with("invalid type: null") {
1326 let subject = if path.is_empty() { "A value".to_string() } else { path.clone() };
1327 crate::violation::Violation::new(path, "required", format!("{subject} is required"))
1328 } else {
1329 let text = if path.is_empty() { message } else { format!("{path}: {message}") };
1330 crate::violation::Violation::new(path, "invalid_value", text)
1331 };
1332 ServiceError::Violations(vec![violation])
1333}
1334
1335pub const MAX_BATCH_SIZE: usize = 1000;
1339
1340fn check_batch_size(count: usize) -> Result<(), ServiceError> {
1342 if count > MAX_BATCH_SIZE {
1343 return Err(ServiceError::Validation(format!(
1344 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1345 )));
1346 }
1347 Ok(())
1348}
1349
1350pub const UPSERT_UNSUPPORTED: &str =
1352 "upsert is not available for this resource: a create body names no record to match. \
1353 Create the record, or update it by id.";
1354
1355fn at_batch_index(err: ServiceError, index: usize) -> ServiceError {
1358 let item = |message: String| format!("item {index}: {message}");
1359 match err {
1360 ServiceError::Violations(violations) => ServiceError::Violations(
1361 violations
1362 .into_iter()
1363 .map(|mut v| {
1364 v.path = crate::violation::batch_item_path(index, &v.path);
1365 v
1366 })
1367 .collect(),
1368 ),
1369 ServiceError::Repository(e) => ServiceError::Repository(e.at_batch_index(index)),
1370 ServiceError::Validation(m) => ServiceError::Validation(item(m)),
1371 ServiceError::AlreadyExists(m) => ServiceError::AlreadyExists(item(m)),
1372 ServiceError::Internal(m) => ServiceError::Internal(item(m)),
1373 ServiceError::NotFound => ServiceError::Validation(item("not found".to_string())),
1374 }
1375}
1376
1377fn hard_deleted_events<E: PersistentEntity>(ids: &[String]) -> Vec<CrudEvent<E>> {
1379 ids.iter()
1380 .map(|id| CrudEvent::HardDeleted {
1381 entity_id: id.clone(),
1382 metadata: EventMetadata::new(id, std::any::type_name::<E>()),
1383 })
1384 .collect()
1385}
1386
1387fn dedup_ids(ids: Vec<String>) -> Vec<String> {
1391 let mut seen = std::collections::HashSet::new();
1392 ids.into_iter().filter(|id| seen.insert(id.clone())).collect()
1393}
1394
1395fn first_duplicate_id(ids: impl IntoIterator<Item = String>) -> Option<String> {
1398 let mut seen = std::collections::HashSet::new();
1399 for id in ids {
1400 if !seen.insert(id.clone()) {
1401 return Some(id);
1402 }
1403 }
1404 None
1405}
1406
1407#[async_trait::async_trait]
1418impl<E, C, U, R> crate::http::CrudService<E, C, U> for GenericCrudService<E, C, U, R>
1419where
1420 E: PersistentEntity
1421 + Clone
1422 + serde::Serialize
1423 + serde::de::DeserializeOwned
1424 + FromCreateDto<C>
1425 + ApplyUpdateDto<U>
1426 + Send
1427 + Sync
1428 + 'static,
1429 C: Send + Sync + 'static,
1430 U: Send + Sync + 'static,
1431 R: CrudRepository<E> + Send + Sync + 'static,
1432{
1433 type Error = ServiceError;
1434
1435 fn violations_of(err: &ServiceError) -> Option<Vec<crate::violation::Violation>> {
1436 match err {
1437 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => Some(v.clone()),
1438 _ => None,
1439 }
1440 }
1441
1442 fn status_of(err: &ServiceError) -> Option<axum::http::StatusCode> {
1443 Some(crate::http::service_error_status(err))
1444 }
1445
1446 fn supports_upsert() -> bool {
1447 false
1448 }
1449
1450 fn entity_name() -> &'static str {
1451 let full = std::any::type_name::<E>();
1454 full.rsplit("::").next().unwrap_or(full)
1455 }
1456
1457 async fn fetch_related_json(&self, table: &str, ids: &[String]) -> Vec<serde_json::Value> {
1458 self.repository().fetch_related_json(table, ids).await
1459 }
1460
1461 async fn list(
1462 &self,
1463 page: u32,
1464 limit: u32,
1465 filters: HashMap<String, String>,
1466 ) -> Result<(Vec<E>, u64), ServiceError> {
1467 self.list(page, limit, filters).await
1468 }
1469
1470 async fn list_with_info(
1471 &self,
1472 page: u32,
1473 limit: u32,
1474 filters: HashMap<String, String>,
1475 ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), ServiceError> {
1476 self.list_with_info(page, limit, filters).await
1477 }
1478
1479 async fn aggregate(
1480 &self,
1481 spec: &backbone_orm::repository::AggregateSpec,
1482 filters: HashMap<String, String>,
1483 ) -> Result<backbone_orm::repository::AggregateResult, ServiceError> {
1484 self.aggregate(spec, filters).await
1485 }
1486
1487 fn table_name(&self) -> Option<&str> {
1488 self.repository.table_name()
1489 }
1490
1491 async fn create(&self, dto: C) -> Result<E, ServiceError> {
1492 self.create(dto).await
1493 }
1494
1495 async fn get_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1496 self.get_by_id(id).await
1497 }
1498
1499 async fn update(&self, id: &str, dto: U) -> Result<Option<E>, ServiceError> {
1500 self.update(id, dto).await
1501 }
1502
1503 async fn partial_update(
1504 &self,
1505 id: &str,
1506 fields: HashMap<String, serde_json::Value>,
1507 ) -> Result<Option<E>, ServiceError> {
1508 self.partial_update(id, fields).await
1509 }
1510
1511 async fn soft_delete(&self, id: &str) -> Result<bool, ServiceError> {
1512 self.soft_delete(id).await
1513 }
1514
1515 async fn bulk_create(&self, items: Vec<C>) -> Result<Vec<E>, ServiceError> {
1516 self.bulk_create(items).await
1517 }
1518
1519 async fn upsert(&self, dto: C) -> Result<E, ServiceError> {
1520 self.upsert(dto).await
1521 }
1522
1523 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), ServiceError> {
1524 self.list_deleted(page, limit).await
1525 }
1526
1527 async fn restore(&self, id: &str) -> Result<Option<E>, ServiceError> {
1528 self.restore(id).await
1529 }
1530
1531 async fn empty_trash(&self) -> Result<u64, ServiceError> {
1532 self.empty_trash().await
1533 }
1534
1535 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1536 self.get_deleted_by_id(id).await
1537 }
1538
1539 async fn permanent_delete(&self, id: &str) -> Result<bool, ServiceError> {
1540 self.permanent_delete(id).await
1541 }
1542
1543 async fn list_deleted_filtered(
1544 &self,
1545 page: u32,
1546 limit: u32,
1547 filters: HashMap<String, String>,
1548 ) -> Result<(Vec<E>, u64), ServiceError> {
1549 self.list_deleted_filtered(page, limit, filters).await
1550 }
1551
1552 async fn count_active(&self) -> Result<u64, ServiceError> {
1553 self.count_active().await
1554 }
1555
1556 async fn count_deleted(&self) -> Result<u64, ServiceError> {
1557 self.count_deleted().await
1558 }
1559
1560 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1561 self.bulk_soft_delete(ids).await
1562 }
1563
1564 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<E>, ServiceError> {
1565 self.bulk_restore(ids).await
1566 }
1567
1568 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1569 self.bulk_permanent_delete(ids).await
1570 }
1571
1572 async fn restore_all(&self) -> Result<u64, ServiceError> {
1573 self.restore_all().await
1574 }
1575
1576 async fn bulk_update(&self, items: Vec<(String, U)>) -> Result<Vec<E>, ServiceError> {
1577 self.bulk_update(items).await
1578 }
1579
1580 async fn bulk_partial_update(
1581 &self,
1582 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1583 ) -> Result<Vec<E>, ServiceError> {
1584 self.bulk_partial_update(items).await
1585 }
1586
1587 async fn preview_bulk_partial_update(
1588 &self,
1589 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1590 ) -> Result<Option<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>, ServiceError> {
1591 self.preview_bulk_partial_update(items).await.map(Some)
1592 }
1593}
1594
1595impl<E, C, U, R> Clone for GenericCrudService<E, C, U, R>
1597where
1598 E: PersistentEntity + Clone,
1599 R: CrudRepository<E>,
1600{
1601 fn clone(&self) -> Self {
1602 Self {
1603 repository: self.repository.clone(),
1604 lifecycle: self.lifecycle.clone(),
1605 event_publisher: self.event_publisher.clone(),
1606 guard: self.guard.clone(),
1607 hooks: self.hooks.clone(),
1608 _phantom: PhantomData,
1609 }
1610 }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use super::*;
1616 use chrono::{DateTime, Utc};
1617 use serde::{Deserialize, Serialize};
1618
1619 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1622 struct Widget {
1623 id: String,
1624 name: String,
1625 #[serde(skip_serializing_if = "Option::is_none")]
1626 note: Option<String>,
1627 secret_hash: String,
1628 #[serde(default)]
1630 state: String,
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1633 owner_id: Option<String>,
1634 #[serde(skip_serializing_if = "Option::is_none")]
1635 deleted_at: Option<DateTime<Utc>>,
1636 created_at: DateTime<Utc>,
1637 updated_at: DateTime<Utc>,
1638 }
1639
1640 impl crate::persistence::traits::PersistentEntity for Widget {
1641 fn write_protected_fields() -> &'static [&'static str] {
1642 &["secret_hash", "state"]
1643 }
1644 fn state_fields() -> &'static [&'static str] {
1645 &["state"]
1646 }
1647 fn references() -> &'static [crate::persistence::traits::Reference] {
1648 const REFERENCES: &[crate::persistence::traits::Reference] =
1651 &[crate::persistence::traits::Reference::new("owner_id", "owners", "id")];
1652 REFERENCES
1653 }
1654 fn entity_id(&self) -> String {
1655 self.id.clone()
1656 }
1657 fn set_entity_id(&mut self, id: String) {
1658 self.id = id;
1659 }
1660 fn created_at(&self) -> Option<DateTime<Utc>> {
1661 Some(self.created_at)
1662 }
1663 fn set_created_at(&mut self, ts: DateTime<Utc>) {
1664 self.created_at = ts;
1665 }
1666 fn updated_at(&self) -> Option<DateTime<Utc>> {
1667 Some(self.updated_at)
1668 }
1669 fn set_updated_at(&mut self, ts: DateTime<Utc>) {
1670 self.updated_at = ts;
1671 }
1672 fn deleted_at(&self) -> Option<DateTime<Utc>> {
1673 self.deleted_at
1674 }
1675 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
1676 self.deleted_at = ts;
1677 }
1678 }
1679
1680 #[derive(Deserialize)]
1681 struct CreateWidgetDto {
1682 name: String,
1683 }
1684
1685 #[derive(Deserialize)]
1686 struct UpdateWidgetDto {
1687 name: String,
1688 }
1689
1690 impl backbone_orm::EntityRepoMeta for Widget {
1691 fn column_types() -> HashMap<String, String> {
1692 HashMap::new()
1693 }
1694 fn search_fields() -> &'static [&'static str] {
1695 &["name"]
1696 }
1697 }
1698
1699 const BROKEN_ID: &str = "00000000-0000-0000-0000-00000000dead";
1701
1702 impl FromCreateDto<CreateWidgetDto> for Widget {
1703 fn from_create_dto(dto: CreateWidgetDto) -> ServiceResult<Self> {
1704 let owner_id = dto.name.strip_prefix("owned-by:").map(str::to_string);
1706 Ok(Widget {
1707 id: uuid::Uuid::new_v4().to_string(),
1708 name: dto.name,
1709 note: None,
1710 secret_hash: "h0".into(),
1711 state: "draft".into(),
1712 owner_id,
1713 deleted_at: None,
1714 created_at: Utc::now(),
1715 updated_at: Utc::now(),
1716 })
1717 }
1718 }
1719
1720 impl ApplyUpdateDto<UpdateWidgetDto> for Widget {
1721 fn apply_update(mut self, dto: UpdateWidgetDto) -> ServiceResult<Self> {
1722 self.name = dto.name;
1723 self.updated_at = Utc::now();
1724 Ok(self)
1725 }
1726 }
1727
1728 struct InMemoryWidgetRepo {
1730 data: tokio::sync::Mutex<Vec<Widget>>,
1731 changed_writes: std::sync::Mutex<Vec<(Widget, Widget)>>,
1733 full_writes: std::sync::atomic::AtomicUsize,
1735 vanish_on_write: std::sync::atomic::AtomicBool,
1737 bulk_creates: std::sync::atomic::AtomicUsize,
1739 visible_owners: std::sync::Mutex<Vec<String>>,
1741 }
1742
1743 impl InMemoryWidgetRepo {
1744 fn new() -> Self {
1745 Self {
1746 data: tokio::sync::Mutex::new(Vec::new()),
1747 changed_writes: std::sync::Mutex::new(Vec::new()),
1748 full_writes: std::sync::atomic::AtomicUsize::new(0),
1749 vanish_on_write: std::sync::atomic::AtomicBool::new(false),
1750 bulk_creates: std::sync::atomic::AtomicUsize::new(0),
1751 visible_owners: std::sync::Mutex::new(vec!["mine".to_string()]),
1752 }
1753 }
1754 }
1755
1756 #[async_trait]
1757 impl crate::persistence::traits::CrudRepository<Widget> for InMemoryWidgetRepo {
1758 async fn reference_visible(
1759 &self,
1760 table: &str,
1761 column: &str,
1762 value: &serde_json::Value,
1763 ) -> Result<Option<bool>, RepositoryError> {
1764 assert_eq!((table, column), ("owners", "id"));
1765 let visible = self.visible_owners.lock().unwrap();
1766 Ok(Some(value.as_str().is_some_and(|v| visible.iter().any(|o| o == v))))
1767 }
1768
1769 async fn create(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1770 self.data.lock().await.push(entity.clone());
1771 Ok(entity)
1772 }
1773
1774 async fn find_by_id(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1775 if id == BROKEN_ID {
1776 return Err(RepositoryError::DatabaseError(
1777 "relation \"secret_schema.widgets\" is broken".into(),
1778 ));
1779 }
1780 Ok(self
1781 .data
1782 .lock()
1783 .await
1784 .iter()
1785 .find(|w| w.id == id && w.deleted_at.is_none())
1786 .cloned())
1787 }
1788
1789 async fn update(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1790 self.full_writes
1791 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1792 let mut data = self.data.lock().await;
1793 data.retain(|w| w.id != entity.id);
1794 data.push(entity.clone());
1795 Ok(entity)
1796 }
1797
1798 async fn update_changed(
1799 &self,
1800 before: &Widget,
1801 after: Widget,
1802 ) -> Result<Option<Widget>, RepositoryError> {
1803 self.changed_writes
1804 .lock()
1805 .unwrap()
1806 .push((before.clone(), after.clone()));
1807 if self
1808 .vanish_on_write
1809 .load(std::sync::atomic::Ordering::SeqCst)
1810 {
1811 return Ok(None);
1812 }
1813 if after.name == "taken" {
1814 return Err(RepositoryError::AlreadyExists(
1815 "duplicate key value violates unique constraint \"widgets_name_key\"".into(),
1816 ));
1817 }
1818 let mut data = self.data.lock().await;
1819 data.retain(|w| w.id != after.id);
1820 data.push(after.clone());
1821 Ok(Some(after))
1822 }
1823
1824 async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1825 let mut data = self.data.lock().await;
1826 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1827 w.deleted_at = Some(Utc::now());
1828 return Ok(true);
1829 }
1830 Ok(false)
1831 }
1832
1833 async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1834 Ok(self.data.lock().await.iter().find(|w| w.id == id).cloned())
1835 }
1836
1837 async fn restore(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1838 let mut data = self.data.lock().await;
1839 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1840 w.deleted_at = None;
1841 return Ok(Some(w.clone()));
1842 }
1843 Ok(None)
1844 }
1845
1846 async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1847 let mut data = self.data.lock().await;
1848 let before = data.len();
1849 data.retain(|w| w.id != id);
1850 Ok(data.len() < before)
1851 }
1852
1853 async fn list(
1854 &self,
1855 page: u32,
1856 limit: u32,
1857 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1858 let data = self.data.lock().await;
1859 let active: Vec<_> = data.iter().filter(|w| w.deleted_at.is_none()).cloned().collect();
1860 let total = active.len() as u64;
1861 let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1862 let limit = limit as usize;
1863 let page = active.into_iter().skip(offset).take(limit).collect();
1864 Ok((page, total))
1865 }
1866
1867 async fn list_deleted(
1868 &self,
1869 page: u32,
1870 limit: u32,
1871 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1872 let data = self.data.lock().await;
1873 let deleted: Vec<_> = data.iter().filter(|w| w.deleted_at.is_some()).cloned().collect();
1874 let total = deleted.len() as u64;
1875 let limit = (limit as usize).min(100);
1877 let offset = (page.saturating_sub(1) as usize) * limit;
1878 let page = deleted.into_iter().skip(offset).take(limit).collect();
1879 Ok((page, total))
1880 }
1881
1882 async fn count(&self) -> Result<u64, RepositoryError> {
1883 let data = self.data.lock().await;
1884 Ok(data.iter().filter(|w| w.deleted_at.is_none()).count() as u64)
1885 }
1886
1887 async fn count_deleted(&self) -> Result<u64, RepositoryError> {
1888 let data = self.data.lock().await;
1889 Ok(data.iter().filter(|w| w.deleted_at.is_some()).count() as u64)
1890 }
1891
1892 async fn list_deleted_filtered(
1893 &self,
1894 page: u32,
1895 limit: u32,
1896 filters: HashMap<String, String>,
1897 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1898 let (rows, _) = self.list_deleted(page, limit).await?;
1899 let rows: Vec<Widget> = rows
1900 .into_iter()
1901 .filter(|w| filters.get("name").is_none_or(|n| *n == w.name))
1902 .collect();
1903 let total = rows.len() as u64;
1904 Ok((rows, total))
1905 }
1906
1907 async fn bulk_create(&self, entities: Vec<Widget>) -> Result<Vec<Widget>, RepositoryError> {
1908 self.bulk_creates
1909 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1910 let mut data = self.data.lock().await;
1911 data.extend(entities.clone());
1912 Ok(entities)
1913 }
1914
1915 async fn empty_trash(&self) -> Result<u64, RepositoryError> {
1916 let mut data = self.data.lock().await;
1917 let before = data.len();
1918 data.retain(|w| w.deleted_at.is_none());
1919 Ok((before - data.len()) as u64)
1920 }
1921 }
1922
1923 #[tokio::test]
1924 async fn create_and_get_roundtrip() {
1925 let repo = Arc::new(InMemoryWidgetRepo::new());
1926 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1927 GenericCrudService::with_repository(repo);
1928
1929 let widget = service
1930 .create(CreateWidgetDto {
1931 name: "sprocket".into(),
1932 })
1933 .await
1934 .unwrap();
1935
1936 let found = service.get_by_id(&widget.id).await.unwrap();
1937 assert!(found.is_some());
1938 assert_eq!(found.unwrap().name, "sprocket");
1939 }
1940
1941 struct RecordingHooks(std::sync::Mutex<Vec<String>>);
1943
1944 #[async_trait]
1945 impl WriteHooks<Widget> for RecordingHooks {
1946 async fn before(&self, _ctx: &WriteCtx, change: &mut Change<'_, Widget>) -> ServiceResult<()> {
1947 self.0.lock().unwrap().push(format!("before {:?}", change.kind()));
1948 match change {
1949 Change::Created { after } if after.name == "refuse-before" => {
1950 Err(ServiceError::Validation("refused by a hook".into()))
1951 }
1952 Change::Created { after } => {
1954 after.note = Some("filled".into());
1955 Ok(())
1956 }
1957 _ => Ok(()),
1958 }
1959 }
1960
1961 async fn after(&self, _ctx: &WriteCtx, change: &Change<'_, Widget>) -> ServiceResult<()> {
1962 self.0.lock().unwrap().push(format!("after {:?}", change.kind()));
1963 Ok(())
1964 }
1965 }
1966
1967 #[tokio::test]
1968 async fn write_hooks_run_around_each_write_and_may_refuse_it() {
1969 let repo = Arc::new(InMemoryWidgetRepo::new());
1970 let hooks = Arc::new(RecordingHooks(Default::default()));
1971 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1972 GenericCrudService::with_repository(repo.clone()).with_write_hooks(hooks.clone());
1973
1974 let widget = service.create(CreateWidgetDto { name: "sprocket".into() }).await.unwrap();
1975 assert_eq!(widget.note.as_deref(), Some("filled"), "a before-hook fills the row that is stored");
1976 service.update(&widget.id, UpdateWidgetDto { name: "cog".into() }).await.unwrap();
1977 service.soft_delete(&widget.id).await.unwrap();
1978 service.restore(&widget.id).await.unwrap();
1979 assert_eq!(
1980 *hooks.0.lock().unwrap(),
1981 vec![
1982 "before Created",
1983 "after Created",
1984 "before Updated",
1985 "after Updated",
1986 "before Deleted",
1987 "after Deleted",
1988 "before Restored",
1989 "after Restored",
1990 ]
1991 );
1992
1993 let refused = service.create(CreateWidgetDto { name: "refuse-before".into() }).await;
1994 assert!(matches!(refused, Err(ServiceError::Validation(_))));
1995 assert_eq!(repo.data.lock().await.len(), 1, "a refused write never reaches the repository");
1996 }
1997
1998 #[tokio::test]
1999 async fn a_service_without_hooks_behaves_as_before() {
2000 let repo = Arc::new(InMemoryWidgetRepo::new());
2001 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2002 GenericCrudService::with_repository(repo);
2003 let created = service.bulk_create(vec![CreateWidgetDto { name: "a".into() }]).await.unwrap();
2004 assert_eq!(created.len(), 1);
2005 assert_eq!(created[0].note, None);
2006 }
2007
2008 #[tokio::test]
2009 async fn soft_delete_hides_from_list() {
2010 let repo = Arc::new(InMemoryWidgetRepo::new());
2011 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2012 GenericCrudService::with_repository(repo);
2013
2014 let w = service.create(CreateWidgetDto { name: "w".into() }).await.unwrap();
2015 service.soft_delete(&w.id).await.unwrap();
2016
2017 let (items, _) = service.list(1, 20, Default::default()).await.unwrap();
2018 assert!(items.is_empty());
2019
2020 let (deleted, _) = service.list_deleted(1, 20).await.unwrap();
2021 assert_eq!(deleted.len(), 1);
2022 }
2023
2024 #[tokio::test]
2025 async fn update_publishes_event_and_returns_new_entity() {
2026 let repo = Arc::new(InMemoryWidgetRepo::new());
2027 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2028 GenericCrudService::with_repository(repo);
2029
2030 let w = service.create(CreateWidgetDto { name: "old".into() }).await.unwrap();
2031 let updated = service
2032 .update(&w.id, UpdateWidgetDto { name: "new".into() })
2033 .await
2034 .unwrap();
2035 assert_eq!(updated.unwrap().name, "new");
2036 }
2037
2038 #[tokio::test]
2041 async fn a_patch_writes_only_through_the_changed_columns_path() {
2042 let repo = Arc::new(InMemoryWidgetRepo::new());
2043 let service: GenericCrudService<
2044 Widget,
2045 CreateWidgetDto,
2046 UpdateWidgetDto,
2047 InMemoryWidgetRepo,
2048 > = GenericCrudService::with_repository(repo.clone());
2049 let w = service
2050 .create(CreateWidgetDto { name: "old".into() })
2051 .await
2052 .unwrap();
2053
2054 let mut fields = HashMap::new();
2055 fields.insert("note".to_string(), serde_json::json!("edited"));
2056 let saved = service
2057 .partial_update(&w.id, fields)
2058 .await
2059 .unwrap()
2060 .unwrap();
2061 assert_eq!(saved.note.as_deref(), Some("edited"));
2062
2063 let writes = repo.changed_writes.lock().unwrap().clone();
2064 assert_eq!(writes.len(), 1, "a PATCH must save through update_changed");
2065 assert_eq!(writes[0].0, w, "the write starts from the row as read");
2066 assert_eq!(
2067 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
2068 0
2069 );
2070 }
2071
2072 #[tokio::test]
2073 async fn a_put_writes_only_through_the_changed_columns_path() {
2074 let repo = Arc::new(InMemoryWidgetRepo::new());
2075 let service: GenericCrudService<
2076 Widget,
2077 CreateWidgetDto,
2078 UpdateWidgetDto,
2079 InMemoryWidgetRepo,
2080 > = GenericCrudService::with_repository(repo.clone());
2081 let w = service
2082 .create(CreateWidgetDto { name: "old".into() })
2083 .await
2084 .unwrap();
2085 service
2086 .update(&w.id, UpdateWidgetDto { name: "new".into() })
2087 .await
2088 .unwrap()
2089 .unwrap();
2090 assert_eq!(repo.changed_writes.lock().unwrap().len(), 1);
2091 assert_eq!(
2092 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
2093 0
2094 );
2095 }
2096
2097 #[tokio::test]
2098 async fn a_write_whose_row_vanished_meanwhile_answers_not_found() {
2099 let repo = Arc::new(InMemoryWidgetRepo::new());
2100 let service: GenericCrudService<
2101 Widget,
2102 CreateWidgetDto,
2103 UpdateWidgetDto,
2104 InMemoryWidgetRepo,
2105 > = GenericCrudService::with_repository(repo.clone());
2106 let w = service
2107 .create(CreateWidgetDto { name: "old".into() })
2108 .await
2109 .unwrap();
2110 repo.vanish_on_write
2111 .store(true, std::sync::atomic::Ordering::SeqCst);
2112
2113 let mut fields = HashMap::new();
2114 fields.insert("note".to_string(), serde_json::json!("edited"));
2115 assert!(service
2116 .partial_update(&w.id, fields)
2117 .await
2118 .unwrap()
2119 .is_none());
2120 assert!(service
2121 .update(&w.id, UpdateWidgetDto { name: "new".into() })
2122 .await
2123 .unwrap()
2124 .is_none());
2125 }
2126
2127 #[tokio::test]
2130 async fn a_transition_moves_its_protected_state_field() {
2131 let service = svc();
2132 let w = service
2133 .create(CreateWidgetDto { name: "w".into() })
2134 .await
2135 .unwrap();
2136 let mut fields = HashMap::new();
2137 fields.insert("state".to_string(), serde_json::json!("active"));
2138 let saved =
2139 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
2140 .await
2141 .expect("a verb moves its own state field")
2142 .unwrap();
2143 assert_eq!(saved.state, "active");
2144 }
2145
2146 #[tokio::test]
2147 async fn a_transition_still_cannot_move_another_protected_field() {
2148 let service = svc();
2149 let w = service
2150 .create(CreateWidgetDto { name: "w".into() })
2151 .await
2152 .unwrap();
2153 let mut fields = HashMap::new();
2154 fields.insert("state".to_string(), serde_json::json!("active"));
2155 fields.insert("secret_hash".to_string(), serde_json::json!("h1"));
2156 let err =
2157 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
2158 .await
2159 .unwrap_err();
2160 match err {
2161 ServiceError::Violations(v) => {
2162 assert_eq!(v.len(), 1, "{v:?}");
2163 assert_eq!(
2164 (v[0].path.as_str(), v[0].code.as_str()),
2165 ("secret_hash", "field_not_writable")
2166 );
2167 }
2168 other => panic!("expected a violation, got {other:?}"),
2169 }
2170 }
2171
2172 #[tokio::test]
2173 async fn a_generic_write_still_cannot_move_the_state_field() {
2174 let service = svc();
2175 let w = service
2176 .create(CreateWidgetDto { name: "w".into() })
2177 .await
2178 .unwrap();
2179 let mut fields = HashMap::new();
2180 fields.insert("state".to_string(), serde_json::json!("active"));
2181 let err = service.partial_update(&w.id, fields).await.unwrap_err();
2182 assert!(
2183 matches!(err, ServiceError::Violations(ref v) if v[0].path == "state"),
2184 "{err:?}"
2185 );
2186 }
2187
2188 fn svc() -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2191 GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()))
2192 }
2193
2194 #[tokio::test]
2195 async fn bulk_soft_delete_success() {
2196 let service = svc();
2197 let mut ids = Vec::new();
2198 for n in 0..3 {
2199 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
2200 }
2201 let n = service.bulk_soft_delete(ids).await.unwrap();
2202 assert_eq!(n, 3);
2203 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2204 assert!(active.is_empty());
2205 }
2206
2207 #[tokio::test]
2208 async fn bulk_soft_delete_is_all_or_nothing_on_missing_id() {
2209 let service = svc();
2210 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2211 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2212
2213 let res = service
2215 .bulk_soft_delete(vec![a.id.clone(), b.id.clone(), "does-not-exist".into()])
2216 .await;
2217 assert!(res.is_err());
2218
2219 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2220 assert_eq!(active.len(), 2, "no rows should have been deleted");
2221 }
2222
2223 #[tokio::test]
2224 async fn bulk_restore_and_restore_all() {
2225 let service = svc();
2226 let mut ids = Vec::new();
2227 for n in 0..3 {
2228 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
2229 }
2230 service.bulk_soft_delete(ids.clone()).await.unwrap();
2231
2232 let restored = service.bulk_restore(vec![ids[0].clone(), ids[1].clone()]).await.unwrap();
2234 assert_eq!(restored.len(), 2);
2235
2236 let n = service.restore_all().await.unwrap();
2238 assert_eq!(n, 1);
2239
2240 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2241 assert_eq!(active.len(), 3);
2242 }
2243
2244 #[tokio::test]
2245 async fn bulk_update_is_all_or_nothing_on_missing_id() {
2246 let service = svc();
2247 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2248
2249 let res = service
2250 .bulk_update(vec![
2251 (a.id.clone(), UpdateWidgetDto { name: "A2".into() }),
2252 ("missing".into(), UpdateWidgetDto { name: "X".into() }),
2253 ])
2254 .await;
2255 assert!(res.is_err());
2256
2257 let still = service.get_by_id(&a.id).await.unwrap().unwrap();
2259 assert_eq!(still.name, "a");
2260 }
2261
2262 #[tokio::test]
2263 async fn bulk_partial_update_success() {
2264 let service = svc();
2265 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2266 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2267
2268 let mut patch_a = std::collections::HashMap::new();
2269 patch_a.insert("name".to_string(), serde_json::json!("a2"));
2270 let mut patch_b = std::collections::HashMap::new();
2271 patch_b.insert("name".to_string(), serde_json::json!("b2"));
2272
2273 let saved = service
2274 .bulk_partial_update(vec![(a.id.clone(), patch_a), (b.id.clone(), patch_b)])
2275 .await
2276 .unwrap();
2277 assert_eq!(saved.len(), 2);
2278 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a2");
2279 assert_eq!(service.get_by_id(&b.id).await.unwrap().unwrap().name, "b2");
2280 }
2281
2282 #[tokio::test]
2283 async fn bulk_soft_delete_tolerates_duplicate_ids() {
2284 let service = svc();
2285 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2286 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2287
2288 let n = service
2290 .bulk_soft_delete(vec![a.id.clone(), a.id.clone(), b.id.clone()])
2291 .await
2292 .unwrap();
2293 assert_eq!(n, 2, "two distinct rows soft-deleted");
2294
2295 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2296 assert!(active.is_empty());
2297 }
2298
2299 #[tokio::test]
2300 async fn bulk_update_rejects_duplicate_ids() {
2301 let service = svc();
2302 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2303
2304 let res = service
2306 .bulk_update(vec![
2307 (a.id.clone(), UpdateWidgetDto { name: "first".into() }),
2308 (a.id.clone(), UpdateWidgetDto { name: "second".into() }),
2309 ])
2310 .await;
2311 assert!(res.is_err());
2312 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2313 }
2314
2315 #[tokio::test]
2316 async fn bulk_soft_delete_rejects_oversized_batch() {
2317 let service = svc();
2318 let ids: Vec<String> = (0..MAX_BATCH_SIZE + 1).map(|n| format!("id-{n}")).collect();
2319 assert!(service.bulk_soft_delete(ids).await.is_err());
2321 }
2322
2323 struct RekeyWidgetDto {
2328 name: String,
2329 secret_hash: String,
2330 }
2331
2332 impl ApplyUpdateDto<RekeyWidgetDto> for Widget {
2333 fn apply_update(mut self, dto: RekeyWidgetDto) -> ServiceResult<Self> {
2334 self.name = dto.name;
2335 self.secret_hash = dto.secret_hash;
2336 Ok(self)
2337 }
2338 }
2339
2340 type RekeyService = GenericCrudService<Widget, CreateWidgetDto, RekeyWidgetDto, InMemoryWidgetRepo>;
2341
2342 fn patch(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
2343 pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
2344 }
2345
2346 fn validation_message(res: ServiceResult<impl std::fmt::Debug>) -> String {
2349 match res {
2350 Err(ServiceError::Validation(m)) => m,
2351 Err(e @ ServiceError::Violations(_)) => e.to_string(),
2352 other => panic!("expected a validation error, got {other:?}"),
2353 }
2354 }
2355
2356 #[tokio::test]
2357 async fn patch_rejects_a_field_the_entity_does_not_have() {
2358 let service = svc();
2359 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2360
2361 let msg = validation_message(service.partial_update(&w.id, patch(&[("nmae", serde_json::json!("b"))])).await);
2362 assert!(msg.contains("unknown_field") && msg.contains("nmae"), "{msg}");
2363 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2364 }
2365
2366 #[tokio::test]
2367 async fn patch_rejects_changing_the_id() {
2368 let service = svc();
2369 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2370
2371 let msg = validation_message(service.partial_update(&w.id, patch(&[("id", serde_json::json!("other"))])).await);
2372 assert!(msg.contains("field_not_writable") && msg.contains("id"), "{msg}");
2373 assert!(service.get_by_id(&w.id).await.unwrap().is_some());
2374 }
2375
2376 #[tokio::test]
2377 async fn patch_rejects_changing_a_field_the_entity_protects() {
2378 let service = svc();
2379 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2380
2381 let msg = validation_message(
2382 service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await,
2383 );
2384 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2385 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().secret_hash, "h0");
2386 }
2387
2388 #[tokio::test]
2389 async fn patch_accepts_a_protected_field_echoed_unchanged() {
2390 let service = svc();
2391 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2392
2393 let saved = service
2394 .partial_update(&w.id, patch(&[("name", serde_json::json!("b")), ("secret_hash", serde_json::json!("h0"))]))
2395 .await
2396 .unwrap()
2397 .unwrap();
2398 assert_eq!(saved.name, "b");
2399 }
2400
2401 #[tokio::test]
2402 async fn patch_sets_and_clears_an_optional_field_that_is_not_serialized_when_empty() {
2403 let service = svc();
2404 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2405
2406 let set = service.partial_update(&w.id, patch(&[("note", serde_json::json!("hi"))])).await.unwrap().unwrap();
2407 assert_eq!(set.note.as_deref(), Some("hi"));
2408 let cleared = service.partial_update(&w.id, patch(&[("note", serde_json::Value::Null)])).await.unwrap().unwrap();
2409 assert_eq!(cleared.note, None);
2410 }
2411
2412 #[tokio::test]
2413 async fn put_rejects_changing_a_field_the_entity_protects() {
2414 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2415 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2416
2417 let msg = validation_message(
2418 service.update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h1".into() }).await,
2419 );
2420 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2421
2422 let saved = service
2424 .update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h0".into() })
2425 .await
2426 .unwrap()
2427 .unwrap();
2428 assert_eq!(saved.name, "b");
2429 }
2430
2431 #[tokio::test]
2432 async fn bulk_update_rejects_a_protected_change_before_writing_any_row() {
2433 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2434 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2435 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2436
2437 let res = service
2438 .bulk_update(vec![
2439 (a.id.clone(), RekeyWidgetDto { name: "a2".into(), secret_hash: "h0".into() }),
2440 (b.id.clone(), RekeyWidgetDto { name: "b2".into(), secret_hash: "h1".into() }),
2441 ])
2442 .await;
2443 assert!(validation_message(res).contains("field_not_writable"));
2444 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2445 }
2446
2447 #[tokio::test]
2448 async fn bulk_partial_update_rejects_a_protected_change_before_writing_any_row() {
2449 let service = svc();
2450 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2451 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2452
2453 let res = service
2454 .bulk_partial_update(vec![
2455 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2456 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2457 ])
2458 .await;
2459 assert!(validation_message(res).contains("field_not_writable"));
2460 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2461 }
2462
2463 #[tokio::test]
2464 async fn a_bulk_preview_says_what_each_row_would_do_and_writes_nothing() {
2465 let service = svc();
2466 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2467 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2468
2469 let rows = service
2470 .preview_bulk_partial_update(vec![
2471 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2472 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2473 ("missing".into(), patch(&[("name", serde_json::json!("x"))])),
2474 ])
2475 .await
2476 .unwrap();
2477 assert_eq!(rows.len(), 3);
2478 let (before, after) = rows[0].outcome.as_ref().unwrap();
2479 assert_eq!((before.name.as_str(), after.name.as_str()), ("a", "a2"));
2480 let refused = rows[1].outcome.as_ref().err().unwrap().to_string();
2481 assert!(refused.contains("field_not_writable"), "{refused}");
2482 assert!(rows[2].outcome.is_err(), "a row that is not there is refused, not dropped");
2483 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2485 }
2486
2487 #[tokio::test]
2488 async fn a_bulk_preview_refuses_a_batch_the_write_would_refuse_whole() {
2489 let service = svc();
2490 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2491 let res = service
2492 .preview_bulk_partial_update(vec![
2493 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2494 (a.id.clone(), patch(&[("name", serde_json::json!("a3"))])),
2495 ])
2496 .await;
2497 assert!(validation_message(res.map(|r| r.len())).contains("duplicate id"));
2498 }
2499
2500 #[tokio::test]
2501 async fn bulk_partial_update_rejects_an_unknown_field() {
2502 let service = svc();
2503 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2504
2505 let res = service.bulk_partial_update(vec![(a.id.clone(), patch(&[("colour", serde_json::json!("red"))]))]).await;
2506 assert!(validation_message(res).contains("unknown_field"));
2507 }
2508
2509 #[derive(Default)]
2512 struct RecordingPublisher {
2513 kinds: tokio::sync::Mutex<Vec<&'static str>>,
2514 }
2515
2516 #[async_trait]
2517 impl CrudEventPublisher<Widget> for RecordingPublisher {
2518 async fn publish(&self, event: CrudEvent<Widget>) -> Result<(), backbone_messaging::EventError> {
2519 let kind = match event {
2520 CrudEvent::Updated { .. } => "updated",
2521 CrudEvent::Patched { .. } => "patched",
2522 _ => "other",
2523 };
2524 self.kinds.lock().await.push(kind);
2525 Ok(())
2526 }
2527 }
2528
2529 #[tokio::test]
2530 async fn patch_publishes_an_updated_event() {
2531 let publisher = Arc::new(RecordingPublisher::default());
2532 let service = svc().with_event_publisher(publisher.clone());
2533 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2534 publisher.kinds.lock().await.clear();
2535
2536 service.partial_update(&w.id, patch(&[("name", serde_json::json!("b"))])).await.unwrap();
2537 assert_eq!(*publisher.kinds.lock().await, vec!["updated"]);
2538 }
2539
2540 struct RefuseTrash;
2543
2544 #[async_trait]
2545 impl ServiceLifecycle<Widget> for RefuseTrash {
2546 async fn before_restore(&self, _entity: &Widget) -> ServiceResult<()> {
2547 Err(ServiceError::Validation("restore refused".into()))
2548 }
2549 async fn before_hard_delete(&self, _entity: &Widget) -> ServiceResult<()> {
2550 Err(ServiceError::Validation("hard delete refused".into()))
2551 }
2552 async fn before_restore_all(&self) -> ServiceResult<()> {
2553 Err(ServiceError::Validation("restore all refused".into()))
2554 }
2555 async fn before_empty_trash(&self) -> ServiceResult<()> {
2556 Err(ServiceError::Validation("empty trash refused".into()))
2557 }
2558 }
2559
2560 #[tokio::test]
2561 async fn every_trash_operation_consults_the_lifecycle() {
2562 let repo = Arc::new(InMemoryWidgetRepo::new());
2563 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2564 GenericCrudService::with_lifecycle(repo, Arc::new(RefuseTrash));
2565 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2566 service.soft_delete(&w.id).await.unwrap();
2567
2568 assert!(service.restore(&w.id).await.is_err(), "restore");
2569 assert!(service.bulk_restore(vec![w.id.clone()]).await.is_err(), "bulk restore");
2570 assert!(service.restore_all().await.is_err(), "restore all");
2571 assert!(service.hard_delete(&w.id).await.is_err(), "hard delete");
2572 assert!(service.bulk_permanent_delete(vec![w.id.clone()]).await.is_err(), "bulk permanent delete");
2573 assert!(service.empty_trash().await.is_err(), "empty trash");
2574
2575 let still = service.get_deleted_by_id(&w.id).await.unwrap().unwrap();
2577 assert!(still.deleted_at.is_some());
2578 }
2579
2580 #[tokio::test]
2583 async fn a_refused_field_is_a_violation_naming_its_path_and_code() {
2584 let service = svc();
2585 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2586
2587 let err = service
2588 .partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1")), ("colour", serde_json::json!("red"))]))
2589 .await
2590 .unwrap_err();
2591 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2592 let pairs: Vec<(&str, &str)> = v.iter().map(|x| (x.path.as_str(), x.code.as_str())).collect();
2593 assert_eq!(pairs, vec![("colour", "unknown_field")]);
2594 assert!(err.to_string().starts_with("validation failed: unknown_field: `colour`"), "{err}");
2596
2597 let err = service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await.unwrap_err();
2598 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2599 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("secret_hash", "field_not_writable"));
2600 }
2601
2602 #[derive(Default)]
2605 struct NameGuard {
2606 seen: std::sync::Mutex<Vec<crate::write_guard::WriteKind>>,
2607 }
2608
2609 #[async_trait]
2610 impl crate::write_guard::WriteGuard<Widget> for NameGuard {
2611 async fn check(&self, ctx: &crate::write_guard::WriteCtx<'_, Widget>) -> crate::write_guard::GuardOutcome {
2612 self.seen.lock().unwrap().push(ctx.kind);
2613 let row = ctx.after.or(ctx.before).expect("a write concerns a row");
2614 let mut out = crate::write_guard::GuardOutcome::default();
2615 match row.name.as_str() {
2616 "forbidden" => out.refuse.push(crate::violation::Violation::new("name", "name_forbidden", "this name is refused")),
2617 "watched" => out.shadow.push(crate::violation::Violation::new("name", "name_watched", "would be refused")),
2618 _ => {}
2619 }
2620 out
2621 }
2622 }
2623
2624 fn guarded(guard: Arc<NameGuard>) -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2625 svc().with_guard(guard)
2626 }
2627
2628 #[tokio::test]
2629 async fn a_guard_refusal_stops_create_update_and_patch_with_nothing_written() {
2630 let guard = Arc::new(NameGuard::default());
2631 let service = guarded(guard.clone());
2632
2633 let err = service.create(CreateWidgetDto { name: "forbidden".into() }).await.unwrap_err();
2634 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2635 assert!(service.list(1, 20, Default::default()).await.unwrap().0.is_empty(), "nothing created");
2636
2637 let w = service.create(CreateWidgetDto { name: "ok".into() }).await.unwrap();
2638 assert!(service.update(&w.id, UpdateWidgetDto { name: "forbidden".into() }).await.is_err());
2639 assert!(service.partial_update(&w.id, patch(&[("name", serde_json::json!("forbidden"))])).await.is_err());
2640 assert!(service
2641 .bulk_partial_update(vec![(w.id.clone(), patch(&[("name", serde_json::json!("forbidden"))]))])
2642 .await
2643 .is_err());
2644 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "ok");
2645 }
2646
2647 #[tokio::test]
2648 async fn a_shadowed_rule_lets_the_write_through() {
2649 let service = guarded(Arc::new(NameGuard::default()));
2650 let w = service.create(CreateWidgetDto { name: "watched".into() }).await.unwrap();
2651 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "watched");
2652 }
2653
2654 #[tokio::test]
2655 async fn the_guard_is_asked_about_every_write_kind() {
2656 use crate::write_guard::WriteKind::*;
2657 let guard = Arc::new(NameGuard::default());
2658 let service = guarded(guard.clone());
2659 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2660 service.update(&w.id, UpdateWidgetDto { name: "b".into() }).await.unwrap();
2661 service.partial_update(&w.id, patch(&[("name", serde_json::json!("c"))])).await.unwrap();
2662 service.soft_delete(&w.id).await.unwrap();
2663 service.restore(&w.id).await.unwrap();
2664 service.soft_delete(&w.id).await.unwrap();
2665 service.hard_delete(&w.id).await.unwrap();
2666 assert_eq!(*guard.seen.lock().unwrap(), vec![Create, Update, Update, Delete, Restore, Delete, HardDelete]);
2667 }
2668
2669 #[test]
2670 fn the_generic_service_hands_its_violations_to_the_http_layer() {
2671 use crate::http::CrudService;
2672 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2673 let v = vec![crate::violation::Violation::new("name", "x", "y")];
2674 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::Violations(v.clone())), Some(v.clone()));
2675 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::NotFound), None);
2676 let db = ServiceError::Repository(RepositoryError::Violations(v.clone()));
2678 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&db), Some(v));
2679 }
2680
2681 fn only_violation(res: ServiceResult<impl std::fmt::Debug>) -> crate::violation::Violation {
2684 match res {
2685 Err(ServiceError::Violations(mut v)) if v.len() == 1 => v.remove(0),
2686 other => panic!("expected one violation, got {other:?}"),
2687 }
2688 }
2689
2690 #[tokio::test]
2691 async fn a_reference_to_a_hidden_row_is_refused_on_every_write_kind() {
2692 let service = svc();
2693 let v = only_violation(service.create(CreateWidgetDto { name: "owned-by:theirs".into() }).await);
2695 assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2696 assert!(!v.message.contains("owners"), "{}", v.message);
2697 assert_eq!(service.count().await.unwrap(), 0);
2698 let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2700 service.create(CreateWidgetDto { name: "plain".into() }).await.unwrap();
2701
2702 let v = only_violation(service.partial_update(&w.id, patch(&[("owner_id", serde_json::json!("theirs"))])).await);
2704 assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2705 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().owner_id.as_deref(), Some("mine"));
2706 let v = only_violation(
2708 service
2709 .bulk_create(vec![CreateWidgetDto { name: "x".into() }, CreateWidgetDto { name: "owned-by:theirs".into() }])
2710 .await,
2711 );
2712 assert_eq!((v.path.as_str(), v.code.as_str()), ("1/owner_id", "reference_missing"));
2713 let v = only_violation(
2714 service.bulk_partial_update(vec![(w.id.clone(), patch(&[("owner_id", serde_json::json!("theirs"))]))]).await,
2715 );
2716 assert_eq!(v.path, "0/owner_id");
2717 }
2718
2719 #[tokio::test]
2720 async fn an_unchanged_reference_is_not_checked_again() {
2721 let service = svc();
2722 let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2723 service.repository().visible_owners.lock().unwrap().clear();
2725 let saved = service.partial_update(&w.id, patch(&[("note", serde_json::json!("x"))])).await.unwrap().unwrap();
2726 assert_eq!(saved.owner_id.as_deref(), Some("mine"));
2727 let saved = service.update(&w.id, UpdateWidgetDto { name: "renamed".into() }).await.unwrap().unwrap();
2728 assert_eq!(saved.name, "renamed");
2729 }
2730
2731 #[tokio::test]
2734 async fn a_patch_of_the_wrong_type_or_a_null_required_field_is_a_violation_on_its_path() {
2735 let service = svc();
2736 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2737 let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::json!(5))])).await);
2738 assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "invalid_value"));
2739 let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::Value::Null)])).await);
2740 assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "required"));
2741 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2742 }
2743
2744 #[tokio::test]
2745 async fn a_patch_setting_an_empty_protected_field_is_refused() {
2746 let service = svc();
2748 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2749 let v = only_violation(service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("set"))])).await);
2750 assert_eq!((v.path.as_str(), v.code.as_str()), ("secret_hash", "field_not_writable"));
2751 }
2752
2753 #[tokio::test]
2754 async fn emptying_a_trash_larger_than_one_page_reaches_every_row() {
2755 let service = svc();
2756 let mut ids = Vec::new();
2757 for i in 0..150 {
2758 ids.push(service.create(CreateWidgetDto { name: format!("w{i}") }).await.unwrap().id);
2759 }
2760 service.bulk_soft_delete(ids).await.unwrap();
2761 assert_eq!(service.empty_trash().await.unwrap(), 150);
2764 assert_eq!(service.count_deleted().await.unwrap(), 0);
2765 }
2766
2767 #[test]
2768 fn the_entity_name_never_carries_the_module_path() {
2769 use crate::http::CrudService;
2770 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2771 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::entity_name(), "Widget");
2772 }
2773
2774 async fn trash_with_a_refused_row(
2778 service: &GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>,
2779 ) -> (String, String) {
2780 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2781 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2782 service.bulk_soft_delete(vec![a.clone(), b.clone()]).await.unwrap();
2783 let mut data = service.repository().data.lock().await;
2784 data.iter_mut().find(|w| w.id == b).unwrap().name = "forbidden".into();
2785 (a, b)
2786 }
2787
2788 #[tokio::test]
2789 async fn restore_all_asks_the_guard_about_every_row_and_restores_none_when_one_is_refused() {
2790 let guard = Arc::new(NameGuard::default());
2791 let service = guarded(guard.clone());
2792 trash_with_a_refused_row(&service).await;
2793
2794 let err = service.restore_all().await.unwrap_err();
2795 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2796 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing restored");
2797 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::Restore));
2798 }
2799
2800 #[tokio::test]
2801 async fn empty_trash_asks_the_guard_about_every_row_and_deletes_none_when_one_is_refused() {
2802 let guard = Arc::new(NameGuard::default());
2803 let service = guarded(guard.clone());
2804 trash_with_a_refused_row(&service).await;
2805
2806 let err = service.empty_trash().await.unwrap_err();
2807 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2808 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing deleted");
2809 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::HardDelete));
2810
2811 let service = svc();
2813 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2814 service.soft_delete(&a).await.unwrap();
2815 assert_eq!(service.empty_trash().await.unwrap(), 1);
2816 assert_eq!(service.count_deleted().await.unwrap(), 0);
2817 }
2818
2819 #[tokio::test]
2822 async fn upsert_refuses_rather_than_creating_a_duplicate() {
2823 use crate::http::CrudService;
2824 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2825 let service = svc();
2826 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2827 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2828 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2829 assert!(!<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::supports_upsert());
2830 }
2831
2832 #[tokio::test]
2835 async fn bulk_create_is_one_repository_write_and_names_the_refused_item() {
2836 let service = guarded(Arc::new(NameGuard::default()));
2837 let names = ["a", "forbidden", "c"];
2838 let err = service
2839 .bulk_create(names.iter().map(|n| CreateWidgetDto { name: n.to_string() }).collect())
2840 .await
2841 .unwrap_err();
2842 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2843 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("1/name", "name_forbidden"));
2844 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2845
2846 let created = service
2847 .bulk_create(vec![CreateWidgetDto { name: "a".into() }, CreateWidgetDto { name: "b".into() }])
2848 .await
2849 .unwrap();
2850 assert_eq!(created.len(), 2);
2851 assert_eq!(
2852 service.repository().bulk_creates.load(std::sync::atomic::Ordering::SeqCst),
2853 1,
2854 "the batch is written by one all-or-nothing repository call"
2855 );
2856 }
2857
2858 #[tokio::test]
2859 async fn bulk_create_refuses_an_oversized_batch() {
2860 let service = svc();
2861 let dtos = (0..=MAX_BATCH_SIZE).map(|n| CreateWidgetDto { name: format!("w{n}") }).collect();
2862 assert!(matches!(service.bulk_create(dtos).await, Err(ServiceError::Validation(_))));
2863 assert_eq!(service.count().await.unwrap(), 0);
2864 }
2865
2866 #[tokio::test]
2869 async fn bulk_updates_write_through_the_changed_columns_path() {
2870 let service = svc();
2871 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2872 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2873 service
2874 .bulk_update(vec![
2875 (a.clone(), UpdateWidgetDto { name: "a2".into() }),
2876 (b.clone(), UpdateWidgetDto { name: "b2".into() }),
2877 ])
2878 .await
2879 .unwrap();
2880 service
2881 .bulk_partial_update(vec![(a.clone(), patch(&[("name", serde_json::json!("a3"))]))])
2882 .await
2883 .unwrap();
2884 let repo = service.repository();
2885 assert_eq!(repo.changed_writes.lock().unwrap().len(), 3);
2886 assert_eq!(repo.full_writes.load(std::sync::atomic::Ordering::SeqCst), 0, "no full-row write");
2887 assert_eq!(service.get_by_id(&a).await.unwrap().unwrap().name, "a3");
2888 }
2889
2890 #[tokio::test]
2893 async fn the_trash_list_applies_filters() {
2894 let service = svc();
2895 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2896 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2897 service.bulk_soft_delete(vec![a, b]).await.unwrap();
2898 let filters = HashMap::from([("name".to_string(), "b".to_string())]);
2899 let (rows, total) = service.list_deleted_filtered(1, 20, filters).await.unwrap();
2900 assert_eq!((rows.len(), total), (1, 1));
2901 assert_eq!(rows[0].name, "b");
2902 }
2903
2904 mod http_surface {
2907 use super::*;
2908 use axum::body::Body;
2909 use axum::http::{Request, StatusCode};
2910 use tower::ServiceExt;
2911
2912 type Svc = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2913
2914 fn app() -> (axum::Router, Arc<Svc>) {
2915 let service = Arc::new(svc());
2916 let router = crate::http::BackboneCrudHandler::<Svc, Widget, CreateWidgetDto, UpdateWidgetDto, Widget>::routes(
2917 service.clone(),
2918 "/widgets",
2919 );
2920 (router, service)
2921 }
2922
2923 async fn call(app: &axum::Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, serde_json::Value) {
2924 let mut req = Request::builder().method(method).uri(uri);
2925 if body.is_some() {
2926 req = req.header("content-type", "application/json");
2927 }
2928 let req = req.body(Body::from(body.unwrap_or("").to_string())).unwrap();
2929 let res = app.clone().oneshot(req).await.unwrap();
2930 let status = res.status();
2931 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
2932 let json = if bytes.is_empty() {
2933 serde_json::Value::Null
2934 } else {
2935 serde_json::from_slice(&bytes).unwrap_or_else(|_| {
2936 panic!("not a JSON body: {}", String::from_utf8_lossy(&bytes))
2937 })
2938 };
2939 (status, json)
2940 }
2941
2942 #[tokio::test]
2943 async fn a_malformed_path_id_is_a_400_naming_the_id() {
2944 let (app, _) = app();
2945 for (method, uri) in [("GET", "/widgets/not-a-uuid"), ("DELETE", "/widgets/not-a-uuid")] {
2946 let (status, body) = call(&app, method, uri, None).await;
2947 assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {uri}");
2948 assert_eq!(body["violations"][0]["code"], "invalid_id");
2949 assert_eq!(body["violations"][0]["path"], "id");
2950 }
2951 }
2952
2953 #[tokio::test]
2954 async fn a_missing_record_answers_a_coded_404_without_the_type_path() {
2955 let (app, _) = app();
2956 let missing = uuid::Uuid::new_v4();
2957 for (method, uri) in [
2958 ("GET", format!("/widgets/{missing}")),
2959 ("GET", format!("/widgets/{missing}/deleted")),
2960 ("DELETE", format!("/widgets/trash/{missing}")),
2961 ] {
2962 let (status, body) = call(&app, method, &uri, None).await;
2963 assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri}");
2964 assert_eq!(body["violations"][0]["code"], "not_found", "{body}");
2965 assert_eq!(body["error"], crate::http::NOT_FOUND_MESSAGE);
2966 assert!(!body.to_string().contains("::"), "{body}");
2967 }
2968 }
2969
2970 #[tokio::test]
2971 async fn a_list_page_is_clamped_and_page_zero_reads_as_the_first() {
2972 let (app, service) = app();
2973 service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2974 let (status, body) = call(&app, "GET", "/widgets?limit=1000&page=0", None).await;
2975 assert_eq!(status, StatusCode::OK, "{body}");
2976 assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2977 assert_eq!(body["meta"]["page"], 1);
2978 let (status, body) = call(&app, "GET", "/widgets/trash?limit=1000&page=0", None).await;
2979 assert_eq!(status, StatusCode::OK, "{body}");
2980 assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2981 let (status, body) = call(&app, "GET", "/widgets?limit=100&page=1000", None).await;
2983 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
2984 assert_eq!(body["violations"][0]["code"], "invalid_query");
2985 assert_eq!(body["violations"][0]["path"], "page");
2986 assert_eq!(body["violations"][0]["params"]["reason"], "too_deep");
2987 }
2988
2989 #[tokio::test]
2990 async fn a_list_parameter_the_request_got_wrong_is_refused_by_name() {
2991 let (app, service) = app();
2992 service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2993 for (uri, parameter, reason) in [
2994 ("/widgets?page=abc", "page", "not_a_number"),
2995 ("/widgets?limit=-1", "limit", "not_a_number"),
2996 ("/widgets/trash?page=x", "page", "not_a_number"),
2997 ("/widgets/count?limit=x", "limit", "not_a_number"),
2998 ("/widgets/aggregate?page=x", "page", "not_a_number"),
2999 ("/widgets?sort_order=sideways", "sort_order", "invalid_value"),
3000 ("/widgets/trash?sort_order=up", "sort_order", "invalid_value"),
3001 ("/widgets?include=nothing", "include", "unknown_relation"),
3002 ("/widgets?with=nothing", "with", "unknown_relation"),
3003 ("/widgets/trash?include=nothing", "include", "unknown_relation"),
3004 ("/widgets?fields=id,na%20me", "fields", "invalid_field_name"),
3005 ] {
3006 let (status, body) = call(&app, "GET", uri, None).await;
3007 assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}: {body}");
3008 let v = &body["violations"][0];
3009 assert_eq!(v["code"], "invalid_query", "{uri}: {body}");
3010 assert_eq!(v["path"], parameter, "{uri}: {body}");
3011 assert_eq!(v["params"]["parameter"], parameter, "{uri}: {body}");
3012 assert_eq!(v["params"]["reason"], reason, "{uri}: {body}");
3013 assert!(v["message"].as_str().unwrap().contains(parameter), "{uri}: {body}");
3014 assert_eq!(body["error"], v["message"], "{uri}: {body}");
3015 }
3016 for uri in ["/widgets?page=1&limit=5&sort_order=DESC", "/widgets?fields=id,name", "/widgets/count"] {
3018 let (status, body) = call(&app, "GET", uri, None).await;
3019 assert_eq!(status, StatusCode::OK, "{uri}: {body}");
3020 }
3021 let w = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
3023 let (status, body) = call(&app, "GET", &format!("/widgets/{}?include=nothing", w.id), None).await;
3024 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
3025 assert_eq!(body["violations"][0]["path"], "include");
3026 }
3027
3028 #[tokio::test]
3029 async fn a_refusal_that_names_no_rule_still_carries_a_code() {
3030 let (app, service) = app();
3031 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
3032 let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":"taken"}"#)).await;
3034 assert_eq!(status, StatusCode::CONFLICT);
3035 assert_eq!(body["violations"][0]["code"], "conflict", "{body}");
3036 let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":7}"#)).await;
3038 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
3039 assert_eq!(body["violations"][0]["path"], "name");
3040 }
3041
3042 #[tokio::test]
3043 async fn a_server_fault_answers_generically_and_keeps_the_detail_out() {
3044 let (app, _) = app();
3045 let (status, body) = call(&app, "GET", &format!("/widgets/{BROKEN_ID}"), None).await;
3046 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
3047 assert_eq!(body["error"], crate::http::INTERNAL_ERROR_MESSAGE);
3048 assert!(!body.to_string().contains("secret_schema"), "{body}");
3049 }
3050
3051 #[tokio::test]
3052 async fn a_duplicate_on_put_and_patch_is_a_409_like_on_create() {
3053 let (app, service) = app();
3054 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
3055 let uri = format!("/widgets/{}", w.id);
3056 let (status, _) = call(&app, "PUT", &uri, Some(r#"{"name":"taken"}"#)).await;
3057 assert_eq!(status, StatusCode::CONFLICT);
3058 let (status, _) = call(&app, "PATCH", &uri, Some(r#"{"name":"taken"}"#)).await;
3059 assert_eq!(status, StatusCode::CONFLICT);
3060 }
3061
3062 #[tokio::test]
3063 async fn an_unknown_key_is_refused_on_create_put_and_bulk() {
3064 let (app, service) = app();
3065 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","colour":"red"}"#)).await;
3066 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
3067 assert_eq!(body["violations"][0]["code"], "unknown_field");
3068 assert_eq!(body["violations"][0]["path"], "colour");
3069 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
3070
3071 let (status, _) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","id":"x","metadata":{}}"#)).await;
3073 assert_eq!(status, StatusCode::CREATED);
3074
3075 let w = service.list(1, 1, HashMap::new()).await.unwrap().0.remove(0);
3076 let (status, body) = call(&app, "PUT", &format!("/widgets/{}", w.id), Some(r#"{"name":"b","colour":"red"}"#)).await;
3077 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
3078 assert_eq!(body["violations"][0]["path"], "colour");
3079
3080 let (status, body) = call(&app, "POST", "/widgets/bulk", Some(r#"[{"name":"x"},{"name":"y","colour":"red"}]"#)).await;
3081 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
3082 assert_eq!(body["violations"][0]["path"], "1/colour");
3083
3084 let bulk = format!(r#"[{{"id":"{}","name":"z","colour":"red"}}]"#, w.id);
3085 let (status, body) = call(&app, "PUT", "/widgets/bulk", Some(&bulk)).await;
3086 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
3087 assert_eq!(body["violations"][0]["path"], "0/colour");
3088 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a", "nothing written");
3089 }
3090
3091 #[tokio::test]
3092 async fn a_body_that_cannot_be_read_answers_in_the_json_envelope() {
3093 let (app, _) = app();
3094 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": "#)).await;
3095 assert_eq!(status, StatusCode::BAD_REQUEST);
3096 assert_eq!(body["success"], false);
3097 assert_eq!(body["violations"][0]["code"], "malformed_body");
3098
3099 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": 5}"#)).await;
3100 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
3101 assert_eq!(body["violations"][0]["path"], "name");
3102 }
3103
3104 #[tokio::test]
3105 async fn the_generic_upsert_route_answers_501() {
3106 let (app, service) = app();
3107 let (status, _) = call(&app, "POST", "/widgets/upsert", Some(r#"{"name":"a"}"#)).await;
3108 assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
3109 assert_eq!(service.count().await.unwrap(), 0);
3110 }
3111
3112 #[tokio::test]
3113 async fn the_trash_route_reads_the_filters() {
3114 let (app, service) = app();
3115 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
3116 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
3117 service.bulk_soft_delete(vec![a, b]).await.unwrap();
3118 let (status, body) = call(&app, "GET", "/widgets/trash?name=b", None).await;
3119 assert_eq!(status, StatusCode::OK);
3120 assert_eq!(body["data"].as_array().unwrap().len(), 1);
3121 assert_eq!(body["data"][0]["name"], "b");
3122 }
3123 }
3124}