1use async_trait::async_trait;
32use std::collections::HashMap;
33use std::marker::PhantomData;
34use std::sync::Arc;
35
36use backbone_messaging::crud_event::{CrudEvent, CrudEventPublisher, EventMetadata, NoOpCrudEventPublisher};
37
38use crate::persistence::traits::{CrudRepository, PersistentEntity, RepositoryError};
39
40#[derive(Debug, thiserror::Error)]
44pub enum ServiceError {
45 #[error("not found")]
46 NotFound,
47
48 #[error("already exists: {0}")]
49 AlreadyExists(String),
50
51 #[error("validation failed: {0}")]
52 Validation(String),
53
54 #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
58 Violations(Vec<crate::violation::Violation>),
59
60 #[error("repository error: {0}")]
61 Repository(#[from] RepositoryError),
62
63 #[error("internal error: {0}")]
64 Internal(String),
65}
66
67pub type ServiceResult<T> = Result<T, ServiceError>;
68
69pub trait FromCreateDto<DTO>: Sized {
76 fn from_create_dto(dto: DTO) -> ServiceResult<Self>;
77}
78
79pub trait ApplyUpdateDto<DTO> {
81 fn apply_update(self, dto: DTO) -> ServiceResult<Self>
82 where
83 Self: Sized;
84}
85
86#[async_trait]
92pub trait ServiceLifecycle<E: PersistentEntity>: Send + Sync {
93 async fn before_create(&self, entity: &mut E) -> ServiceResult<()> {
94 let _ = entity;
95 Ok(())
96 }
97
98 async fn after_create(&self, entity: &E) -> ServiceResult<()> {
99 let _ = entity;
100 Ok(())
101 }
102
103 async fn before_update(&self, entity: &mut E) -> ServiceResult<()> {
104 let _ = entity;
105 Ok(())
106 }
107
108 async fn after_update(&self, entity: &E) -> ServiceResult<()> {
109 let _ = entity;
110 Ok(())
111 }
112
113 async fn before_delete(&self, entity: &E) -> ServiceResult<()> {
114 let _ = entity;
115 Ok(())
116 }
117
118 async fn after_delete(&self, id: &str) -> ServiceResult<()> {
119 let _ = id;
120 Ok(())
121 }
122
123 async fn before_restore(&self, entity: &E) -> ServiceResult<()> {
125 let _ = entity;
126 Ok(())
127 }
128
129 async fn before_hard_delete(&self, entity: &E) -> ServiceResult<()> {
132 let _ = entity;
133 Ok(())
134 }
135
136 async fn before_restore_all(&self) -> ServiceResult<()> {
139 Ok(())
140 }
141
142 async fn before_empty_trash(&self) -> ServiceResult<()> {
145 Ok(())
146 }
147}
148
149pub struct NoOpLifecycle<E> {
151 _phantom: PhantomData<E>,
152}
153
154impl<E> NoOpLifecycle<E> {
155 pub fn new() -> Self {
156 Self {
157 _phantom: PhantomData,
158 }
159 }
160}
161
162impl<E> Default for NoOpLifecycle<E> {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168#[async_trait]
169impl<E: PersistentEntity> ServiceLifecycle<E> for NoOpLifecycle<E> {}
170
171pub struct GenericCrudService<E, C, U, R>
185where
186 E: PersistentEntity + Clone,
187 R: CrudRepository<E>,
188{
189 repository: Arc<R>,
190 lifecycle: Arc<dyn ServiceLifecycle<E>>,
191 event_publisher: Arc<dyn CrudEventPublisher<E>>,
192 guard: Arc<dyn crate::write_guard::WriteGuard<E>>,
193 _phantom: PhantomData<(C, U)>,
194}
195
196impl<E, C, U, R> GenericCrudService<E, C, U, R>
197where
198 E: PersistentEntity + Clone + FromCreateDto<C> + ApplyUpdateDto<U>,
199 C: Send + Sync + 'static,
200 U: Send + Sync + 'static,
201 R: CrudRepository<E>,
202{
203 pub fn new(
205 repository: Arc<R>,
206 lifecycle: Arc<dyn ServiceLifecycle<E>>,
207 event_publisher: Arc<dyn CrudEventPublisher<E>>,
208 ) -> Self {
209 Self {
210 repository,
211 lifecycle,
212 event_publisher,
213 guard: Arc::new(crate::write_guard::AllowAll),
214 _phantom: PhantomData,
215 }
216 }
217
218 pub fn with_guard(mut self, guard: Arc<dyn crate::write_guard::WriteGuard<E>>) -> Self {
221 self.guard = guard;
222 self
223 }
224
225 async fn consult(
227 &self,
228 kind: crate::write_guard::WriteKind,
229 before: Option<&E>,
230 after: Option<&E>,
231 ) -> ServiceResult<()> {
232 self.consult_as(kind, before, after, true).await
233 }
234
235 async fn consult_quietly(
239 &self,
240 kind: crate::write_guard::WriteKind,
241 before: Option<&E>,
242 after: Option<&E>,
243 ) -> ServiceResult<()> {
244 self.consult_as(kind, before, after, false).await
245 }
246
247 async fn consult_as(
248 &self,
249 kind: crate::write_guard::WriteKind,
250 before: Option<&E>,
251 after: Option<&E>,
252 log_shadow: bool,
253 ) -> ServiceResult<()> {
254 let ctx = crate::write_guard::WriteCtx::new(kind, before, after);
255 let outcome = self.guard.check(&ctx).await;
256 for v in outcome.shadow.iter().filter(|_| log_shadow) {
257 tracing::warn!(
258 target: "backbone::write_guard",
259 entity = std::any::type_name::<E>(),
260 kind = ?kind,
261 code = %v.code,
262 path = %v.path,
263 "a rule running in shadow would refuse this write: {}",
264 v.message
265 );
266 }
267 if outcome.refuse.is_empty() {
268 Ok(())
269 } else {
270 Err(ServiceError::Violations(outcome.refuse))
271 }
272 }
273
274 async fn check_references(&self, before: Option<&E>, after: &E) -> ServiceResult<()> {
285 let references = E::references();
286 if references.is_empty() {
287 return Ok(());
288 }
289 let after_obj = as_object(after)?;
290 let before_obj = before.map(as_object).transpose()?;
291 let mut refused = Vec::new();
292 for reference in references {
293 let Some(value) = after_obj.get(reference.field).filter(|v| !v.is_null()) else {
294 continue;
295 };
296 if before_obj.as_ref().is_some_and(|b| b.get(reference.field) == Some(value)) {
297 continue;
298 }
299 let visible = self
300 .repository
301 .reference_visible(reference.table, reference.column, value)
302 .await
303 .map_err(ServiceError::Repository)?;
304 if visible == Some(false) {
305 refused.push(crate::violation::Violation::new(
306 reference.field,
307 "reference_missing",
308 format!("{} refers to a record that does not exist", reference.field),
309 ));
310 }
311 }
312 if refused.is_empty() {
313 Ok(())
314 } else {
315 Err(ServiceError::Violations(refused))
316 }
317 }
318
319 pub fn with_lifecycle(repository: Arc<R>, lifecycle: Arc<dyn ServiceLifecycle<E>>) -> Self {
321 Self::new(repository, lifecycle, NoOpCrudEventPublisher::arc())
322 }
323
324 pub fn with_repository(repository: Arc<R>) -> Self {
326 Self::new(
327 repository,
328 Arc::new(NoOpLifecycle::new()),
329 NoOpCrudEventPublisher::arc(),
330 )
331 }
332
333 pub fn with_event_publisher(mut self, publisher: Arc<dyn CrudEventPublisher<E>>) -> Self {
335 self.event_publisher = publisher;
336 self
337 }
338
339 pub fn repository(&self) -> &R {
341 &self.repository
342 }
343
344 pub async fn list(
347 &self,
348 page: u32,
349 limit: u32,
350 filters: HashMap<String, String>,
351 ) -> ServiceResult<(Vec<E>, u64)> {
352 self.repository
353 .list_filtered(page, limit, filters)
354 .await
355 .map_err(ServiceError::Repository)
356 }
357
358 pub async fn list_with_info(
361 &self,
362 page: u32,
363 limit: u32,
364 filters: HashMap<String, String>,
365 ) -> ServiceResult<(Vec<E>, backbone_orm::repository::PaginationInfo)> {
366 self.repository
367 .list_filtered_with_info(page, limit, filters)
368 .await
369 .map_err(ServiceError::Repository)
370 }
371
372 pub async fn aggregate(
374 &self,
375 spec: &backbone_orm::repository::AggregateSpec,
376 filters: HashMap<String, String>,
377 ) -> ServiceResult<backbone_orm::repository::AggregateResult> {
378 self.repository
379 .aggregate_filtered(spec, filters)
380 .await
381 .map_err(ServiceError::Repository)
382 }
383
384 pub async fn create(&self, dto: C) -> ServiceResult<E> {
385 let mut entity = E::from_create_dto(dto)?;
386 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity)).await?;
387 self.lifecycle.before_create(&mut entity).await?;
388 self.check_references(None, &entity).await?;
389 let saved = self
390 .repository
391 .create(entity)
392 .await
393 .map_err(ServiceError::Repository)?;
394 self.lifecycle.after_create(&saved).await?;
395
396 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
397 let _ = self
398 .event_publisher
399 .publish(CrudEvent::Created { entity: saved.clone(), metadata: meta })
400 .await;
401
402 Ok(saved)
403 }
404
405 pub async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
406 self.repository
407 .find_by_id(id)
408 .await
409 .map_err(ServiceError::Repository)
410 }
411
412 pub async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
413 let existing = self
414 .repository
415 .find_by_id(id)
416 .await
417 .map_err(ServiceError::Repository)?;
418 let Some(before) = existing else {
419 return Ok(None);
420 };
421 let mut updated = before.clone().apply_update(dto)?;
422 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
423 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
424 self.lifecycle.before_update(&mut updated).await?;
425 self.check_references(Some(&before), &updated).await?;
426 let Some(saved) = self
428 .repository
429 .update_changed(&before, updated)
430 .await
431 .map_err(ServiceError::Repository)?
432 else {
433 return Ok(None);
434 };
435 self.lifecycle.after_update(&saved).await?;
436
437 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
438 let _ = self
439 .event_publisher
440 .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
441 .await;
442
443 Ok(Some(saved))
444 }
445
446 pub async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
447 let existing = self
448 .repository
449 .find_by_id(id)
450 .await
451 .map_err(ServiceError::Repository)?;
452 let Some(entity) = existing else {
453 return Ok(false);
454 };
455 self.consult(crate::write_guard::WriteKind::Delete, Some(&entity), None).await?;
456 self.lifecycle.before_delete(&entity).await?;
457 let deleted = self
458 .repository
459 .soft_delete(id)
460 .await
461 .map_err(ServiceError::Repository)?;
462 self.lifecycle.after_delete(id).await?;
463
464 if deleted {
465 let meta = EventMetadata::new(id, std::any::type_name::<E>());
466 let _ = self
467 .event_publisher
468 .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
469 .await;
470 }
471
472 Ok(deleted)
473 }
474
475 pub async fn restore(&self, id: &str) -> ServiceResult<Option<E>> {
476 let Some(trashed) = self.get_deleted_by_id(id).await? else {
477 return Ok(None);
478 };
479 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
480 self.lifecycle.before_restore(&trashed).await?;
481 let restored = self
482 .repository
483 .restore(id)
484 .await
485 .map_err(ServiceError::Repository)?;
486
487 if let Some(ref entity) = restored {
488 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
489 let _ = self
490 .event_publisher
491 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
492 .await;
493 }
494
495 Ok(restored)
496 }
497
498 pub async fn hard_delete(&self, id: &str) -> ServiceResult<bool> {
499 let Some(entity) = self.get_deleted_by_id(id).await? else {
500 return Ok(false);
501 };
502 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&entity), None).await?;
503 self.lifecycle.before_hard_delete(&entity).await?;
504 let deleted = self
505 .repository
506 .hard_delete(id)
507 .await
508 .map_err(ServiceError::Repository)?;
509
510 if deleted {
511 let meta = EventMetadata::new(id, std::any::type_name::<E>());
512 let _ = self
513 .event_publisher
514 .publish(CrudEvent::HardDeleted {
515 entity_id: id.to_string(),
516 metadata: meta,
517 })
518 .await;
519 }
520
521 Ok(deleted)
522 }
523
524 pub async fn list_deleted(
525 &self,
526 page: u32,
527 limit: u32,
528 ) -> ServiceResult<(Vec<E>, u64)> {
529 self.repository
530 .list_deleted(page, limit)
531 .await
532 .map_err(ServiceError::Repository)
533 }
534
535 pub async fn empty_trash(&self) -> ServiceResult<u64> {
543 self.lifecycle.before_empty_trash().await?;
544 let trashed = self.whole_trash("emptied").await?;
545 if trashed.is_empty() {
546 return Ok(0);
547 }
548 for entity in &trashed {
549 self.consult(crate::write_guard::WriteKind::HardDelete, Some(entity), None).await?;
550 self.lifecycle.before_hard_delete(entity).await?;
551 }
552 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
553 let affected = self
554 .repository
555 .bulk_hard_delete(&ids)
556 .await
557 .map_err(ServiceError::Repository)?;
558 for id in &ids {
559 let meta = EventMetadata::new(id, std::any::type_name::<E>());
560 let _ = self
561 .event_publisher
562 .publish(CrudEvent::HardDeleted {
563 entity_id: id.to_string(),
564 metadata: meta,
565 })
566 .await;
567 }
568 Ok(affected)
569 }
570
571 async fn whole_trash(&self, verb: &str) -> ServiceResult<Vec<E>> {
577 let too_many = |total: u64| {
578 ServiceError::Validation(format!(
579 "The trash holds {total} items, more than the {MAX_BATCH_SIZE} that can be {verb} \
580 at once. Select the items to act on instead."
581 ))
582 };
583 let per_page = backbone_orm::repository::PaginationParams::MAX_PER_PAGE;
584 let mut rows: Vec<E> = Vec::new();
585 let mut page = 1u32;
586 loop {
587 let (batch, total) = self
588 .repository
589 .list_deleted(page, per_page)
590 .await
591 .map_err(ServiceError::Repository)?;
592 if total > MAX_BATCH_SIZE as u64 {
593 return Err(too_many(total));
594 }
595 let fetched = batch.len();
596 rows.extend(batch);
597 if rows.len() > MAX_BATCH_SIZE {
598 return Err(too_many(rows.len() as u64));
599 }
600 if fetched == 0 || rows.len() as u64 >= total || fetched < per_page as usize {
601 break;
602 }
603 page += 1;
604 }
605 Ok(rows)
606 }
607
608 pub async fn count(&self) -> ServiceResult<u64> {
609 self.repository
610 .count()
611 .await
612 .map_err(ServiceError::Repository)
613 }
614
615 pub async fn count_active(&self) -> ServiceResult<u64> {
616 self.repository
617 .count()
618 .await
619 .map_err(ServiceError::Repository)
620 }
621
622 pub async fn find_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
624 self.get_by_id(id).await
625 }
626
627 pub async fn permanent_delete(&self, id: &str) -> ServiceResult<bool> {
629 self.hard_delete(id).await
630 }
631
632 pub async fn count_deleted(&self) -> ServiceResult<u64> {
634 self.repository
635 .count_deleted()
636 .await
637 .map_err(ServiceError::Repository)
638 }
639
640 pub async fn get_deleted_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
642 self.repository
643 .find_by_id_including_deleted(id)
644 .await
645 .map_err(ServiceError::Repository)
646 }
647
648 pub async fn list_deleted_filtered(
650 &self,
651 page: u32,
652 limit: u32,
653 filters: HashMap<String, String>,
654 ) -> ServiceResult<(Vec<E>, u64)> {
655 self.repository
656 .list_deleted_filtered(page, limit, filters)
657 .await
658 .map_err(ServiceError::Repository)
659 }
660
661 pub async fn upsert(&self, dto: C) -> ServiceResult<E> {
669 let _ = dto;
670 Err(ServiceError::Validation(UPSERT_UNSUPPORTED.to_string()))
671 }
672
673 pub async fn partial_update(
678 &self,
679 id: &str,
680 fields: HashMap<String, serde_json::Value>,
681 ) -> ServiceResult<Option<E>>
682 where
683 E: serde::Serialize + serde::de::DeserializeOwned,
684 {
685 let existing = self
686 .repository
687 .find_by_id(id)
688 .await
689 .map_err(ServiceError::Repository)?;
690 let Some(before) = existing else {
691 return Ok(None);
692 };
693 let mut patched = merge_patch(&before, fields)?;
694 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
695 self.lifecycle.before_update(&mut patched).await?;
696 self.check_references(Some(&before), &patched).await?;
697 let Some(saved) = self
700 .repository
701 .update_changed(&before, patched)
702 .await
703 .map_err(ServiceError::Repository)?
704 else {
705 return Ok(None);
706 };
707 self.lifecycle.after_update(&saved).await?;
708
709 let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
710 let _ = self
711 .event_publisher
712 .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
713 .await;
714
715 Ok(Some(saved))
716 }
717
718 pub async fn bulk_create(&self, dtos: Vec<C>) -> ServiceResult<Vec<E>>
725 where
726 E: FromCreateDto<C>,
727 {
728 check_batch_size(dtos.len())?;
729 let mut prepared = Vec::with_capacity(dtos.len());
730 for (index, dto) in dtos.into_iter().enumerate() {
731 let at = |e: ServiceError| at_batch_index(e, index);
732 let mut entity = E::from_create_dto(dto).map_err(at)?;
733 self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity))
734 .await
735 .map_err(at)?;
736 self.lifecycle.before_create(&mut entity).await.map_err(at)?;
737 self.check_references(None, &entity).await.map_err(at)?;
738 prepared.push(entity);
739 }
740 let saved = self
741 .repository
742 .bulk_create(prepared)
743 .await
744 .map_err(ServiceError::Repository)?;
745 for entity in &saved {
746 self.lifecycle.after_create(entity).await?;
747 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
748 let _ = self
749 .event_publisher
750 .publish(CrudEvent::Created { entity: entity.clone(), metadata: meta })
751 .await;
752 }
753 Ok(saved)
754 }
755
756 pub async fn bulk_soft_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
765 check_batch_size(ids.len())?;
766 let ids = dedup_ids(ids);
767 let mut entities = Vec::with_capacity(ids.len());
772 for id in &ids {
773 match self
774 .repository
775 .find_by_id(id)
776 .await
777 .map_err(ServiceError::Repository)?
778 {
779 Some(e) => entities.push(e),
780 None => return Err(ServiceError::Validation(format!("id '{id}' not found"))),
781 }
782 }
783 for entity in &entities {
784 self.consult(crate::write_guard::WriteKind::Delete, Some(entity), None).await?;
785 self.lifecycle.before_delete(entity).await?;
786 }
787 let affected = self
788 .repository
789 .bulk_soft_delete(&ids)
790 .await
791 .map_err(ServiceError::Repository)?;
792 for (id, entity) in ids.iter().zip(entities.into_iter()) {
793 self.lifecycle.after_delete(id).await?;
794 let meta = EventMetadata::new(id, std::any::type_name::<E>());
795 let _ = self
796 .event_publisher
797 .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
798 .await;
799 }
800 Ok(affected)
801 }
802
803 pub async fn bulk_restore(&self, ids: Vec<String>) -> ServiceResult<Vec<E>> {
805 check_batch_size(ids.len())?;
806 let ids = dedup_ids(ids);
807 for id in &ids {
810 if let Some(trashed) = self.get_deleted_by_id(id).await? {
811 self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
812 self.lifecycle.before_restore(&trashed).await?;
813 }
814 }
815 let restored = self
816 .repository
817 .bulk_restore(&ids)
818 .await
819 .map_err(ServiceError::Repository)?;
820 for entity in &restored {
821 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
822 let _ = self
823 .event_publisher
824 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
825 .await;
826 }
827 Ok(restored)
828 }
829
830 pub async fn restore_all(&self) -> ServiceResult<u64> {
837 self.lifecycle.before_restore_all().await?;
838 let trashed = self.whole_trash("restored").await?;
839 if trashed.is_empty() {
840 return Ok(0);
841 }
842 for entity in &trashed {
843 self.consult(crate::write_guard::WriteKind::Restore, Some(entity), None).await?;
844 self.lifecycle.before_restore(entity).await?;
845 }
846 let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
847 let restored = self
848 .repository
849 .bulk_restore(&ids)
850 .await
851 .map_err(ServiceError::Repository)?;
852 for entity in &restored {
853 let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
854 let _ = self
855 .event_publisher
856 .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
857 .await;
858 }
859 Ok(restored.len() as u64)
860 }
861
862 pub async fn bulk_permanent_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
864 check_batch_size(ids.len())?;
865 let ids = dedup_ids(ids);
866 for id in &ids {
872 if let Some(trashed) = self.get_deleted_by_id(id).await? {
873 self.consult(crate::write_guard::WriteKind::HardDelete, Some(&trashed), None).await?;
874 self.lifecycle.before_hard_delete(&trashed).await?;
875 }
876 }
877 let affected = self
878 .repository
879 .bulk_hard_delete(&ids)
880 .await
881 .map_err(ServiceError::Repository)?;
882 for id in &ids {
883 let meta = EventMetadata::new(id, std::any::type_name::<E>());
884 let _ = self
885 .event_publisher
886 .publish(CrudEvent::HardDeleted {
887 entity_id: id.to_string(),
888 metadata: meta,
889 })
890 .await;
891 }
892 Ok(affected)
893 }
894
895 pub async fn bulk_update(&self, items: Vec<(String, U)>) -> ServiceResult<Vec<E>> {
897 check_batch_size(items.len())?;
898 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
899 return Err(ServiceError::Validation(format!(
900 "duplicate id '{dup}' in bulk update"
901 )));
902 }
903 let mut befores = Vec::with_capacity(items.len());
904 let mut prepared = Vec::with_capacity(items.len());
905 for (index, (id, dto)) in items.into_iter().enumerate() {
906 let Some(before) = self
907 .repository
908 .find_by_id(&id)
909 .await
910 .map_err(ServiceError::Repository)?
911 else {
912 return Err(ServiceError::Validation(format!("id '{id}' not found")));
913 };
914 let mut updated = before.clone().apply_update(dto)?;
915 refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
916 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
917 self.lifecycle.before_update(&mut updated).await?;
918 self.check_references(Some(&before), &updated)
919 .await
920 .map_err(|e| at_batch_index(e, index))?;
921 prepared.push((before.clone(), updated));
922 befores.push(before);
923 }
924 let saved = self
927 .repository
928 .bulk_update_changed(prepared)
929 .await
930 .map_err(ServiceError::Repository)?;
931 self.publish_bulk_updates(befores, &saved).await?;
932 Ok(saved)
933 }
934
935 async fn publish_bulk_updates(&self, befores: Vec<E>, saved: &[E]) -> ServiceResult<()> {
939 for (before, after) in befores.into_iter().zip(saved.iter()) {
940 self.lifecycle.after_update(after).await?;
941 let meta = EventMetadata::new(after.entity_id(), std::any::type_name::<E>());
942 let _ = self
943 .event_publisher
944 .publish(CrudEvent::Updated {
945 before,
946 after: after.clone(),
947 metadata: meta,
948 })
949 .await;
950 }
951 Ok(())
952 }
953
954 pub async fn bulk_partial_update(
956 &self,
957 items: Vec<(String, HashMap<String, serde_json::Value>)>,
958 ) -> ServiceResult<Vec<E>>
959 where
960 E: serde::Serialize + serde::de::DeserializeOwned,
961 {
962 check_batch_size(items.len())?;
963 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
964 return Err(ServiceError::Validation(format!(
965 "duplicate id '{dup}' in bulk partial update"
966 )));
967 }
968 let mut befores = Vec::with_capacity(items.len());
969 let mut prepared = Vec::with_capacity(items.len());
970 for (index, (id, fields)) in items.into_iter().enumerate() {
971 let Some(before) = self
972 .repository
973 .find_by_id(&id)
974 .await
975 .map_err(ServiceError::Repository)?
976 else {
977 return Err(ServiceError::Validation(format!("id '{id}' not found")));
978 };
979 let mut patched = merge_patch(&before, fields)?;
980 self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
981 self.lifecycle.before_update(&mut patched).await?;
982 self.check_references(Some(&before), &patched)
983 .await
984 .map_err(|e| at_batch_index(e, index))?;
985 prepared.push((before.clone(), patched));
986 befores.push(before);
987 }
988 let saved = self
989 .repository
990 .bulk_update_changed(prepared)
991 .await
992 .map_err(ServiceError::Repository)?;
993 self.publish_bulk_updates(befores, &saved).await?;
994 Ok(saved)
995 }
996
997 pub async fn preview_bulk_partial_update(
1006 &self,
1007 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1008 ) -> ServiceResult<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>
1009 where
1010 E: serde::Serialize + serde::de::DeserializeOwned,
1011 {
1012 check_batch_size(items.len())?;
1013 if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
1014 return Err(ServiceError::Validation(format!(
1015 "duplicate id '{dup}' in bulk partial update"
1016 )));
1017 }
1018 let mut rows = Vec::with_capacity(items.len());
1019 for (id, fields) in items {
1020 let outcome = self.preview_one(&id, fields).await;
1021 rows.push(crate::http::BulkPreviewRow { id, outcome });
1022 }
1023 Ok(rows)
1024 }
1025
1026 async fn preview_one(
1027 &self,
1028 id: &str,
1029 fields: HashMap<String, serde_json::Value>,
1030 ) -> ServiceResult<(E, E)>
1031 where
1032 E: serde::Serialize + serde::de::DeserializeOwned,
1033 {
1034 let Some(before) = self
1035 .repository
1036 .find_by_id(id)
1037 .await
1038 .map_err(ServiceError::Repository)?
1039 else {
1040 return Err(ServiceError::Validation(format!("id '{id}' not found")));
1041 };
1042 let mut patched = merge_patch(&before, fields)?;
1043 self.consult_quietly(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched))
1044 .await?;
1045 self.lifecycle.before_update(&mut patched).await?;
1046 self.check_references(Some(&before), &patched).await?;
1047 Ok((before, patched))
1048 }
1049}
1050
1051pub const ALWAYS_WRITE_PROTECTED: &[&str] = &["id", "metadata"];
1058
1059fn as_object(entity: &impl serde::Serialize) -> ServiceResult<serde_json::Map<String, serde_json::Value>> {
1060 match serde_json::to_value(entity).map_err(|e| ServiceError::Internal(e.to_string()))? {
1061 serde_json::Value::Object(map) => Ok(map),
1062 _ => Err(ServiceError::Internal("an entity must serialize to a JSON object".into())),
1063 }
1064}
1065
1066fn refuse_protected_changes<E: PersistentEntity>(
1077 before: &serde_json::Map<String, serde_json::Value>,
1078 after: &serde_json::Map<String, serde_json::Value>,
1079) -> ServiceResult<()> {
1080 let in_transition = crate::write_guard::current_transition().is_some();
1081 let refused: Vec<crate::violation::Violation> = ALWAYS_WRITE_PROTECTED
1082 .iter()
1083 .chain(E::write_protected_fields())
1084 .filter(|key| !(in_transition && E::state_fields().contains(*key)))
1085 .filter(|key| before.get(**key) != after.get(**key))
1086 .map(|key| {
1087 crate::violation::Violation::new(
1088 *key,
1089 "field_not_writable",
1090 format!(
1091 "field_not_writable: `{key}` cannot be changed by a generic write; \
1092 it changes only through the operation that owns it"
1093 ),
1094 )
1095 })
1096 .collect();
1097 if refused.is_empty() {
1098 Ok(())
1099 } else {
1100 Err(ServiceError::Violations(refused))
1101 }
1102}
1103
1104fn merge_patch<E: PersistentEntity>(
1112 before: &E,
1113 fields: HashMap<String, serde_json::Value>,
1114) -> ServiceResult<E> {
1115 let before_obj = as_object(before)?;
1116 let mut merged = before_obj.clone();
1117 let mut given = Vec::new();
1118 for (key, value) in fields {
1119 if !value.is_null() {
1120 given.push(key.clone());
1121 }
1122 merged.insert(key, value);
1123 }
1124 let patched: E = serde_path_to_error::deserialize(serde_json::Value::Object(merged))
1125 .map_err(|e| patch_type_refusal(&e))?;
1126 let after_obj = as_object(&patched)?;
1127
1128 let mut unknown: Vec<String> = given.into_iter().filter(|k| !after_obj.contains_key(k)).collect();
1129 if !unknown.is_empty() {
1130 unknown.sort();
1131 return Err(ServiceError::Violations(
1132 unknown
1133 .into_iter()
1134 .map(|k| {
1135 let message = format!("unknown_field: `{k}` is not a field of this record");
1136 crate::violation::Violation::new(k, "unknown_field", message)
1137 })
1138 .collect(),
1139 ));
1140 }
1141 refuse_protected_changes::<E>(&before_obj, &after_obj)?;
1142 Ok(patched)
1143}
1144
1145fn patch_type_refusal(err: &serde_path_to_error::Error<serde_json::Error>) -> ServiceError {
1149 let path = err
1150 .path()
1151 .iter()
1152 .filter_map(|segment| match segment {
1153 serde_path_to_error::Segment::Seq { index } => Some(index.to_string()),
1154 serde_path_to_error::Segment::Map { key } => Some(key.clone()),
1155 serde_path_to_error::Segment::Enum { variant } => Some(variant.clone()),
1156 serde_path_to_error::Segment::Unknown => None,
1157 })
1158 .collect::<Vec<_>>()
1159 .join("/");
1160 let message = err.inner().to_string();
1161 let message = match message.rfind(" at line ") {
1162 Some(at) => message[..at].to_string(),
1163 None => message,
1164 };
1165 let violation = if let Some(field) = message
1166 .strip_prefix("missing field `")
1167 .and_then(|rest| rest.split('`').next())
1168 {
1169 let field_path = if path.is_empty() { field.to_string() } else { format!("{path}/{field}") };
1170 crate::violation::Violation::new(field_path.clone(), "required", format!("{field_path} is required"))
1171 } else if message.starts_with("invalid type: null") {
1172 let subject = if path.is_empty() { "A value".to_string() } else { path.clone() };
1173 crate::violation::Violation::new(path, "required", format!("{subject} is required"))
1174 } else {
1175 let text = if path.is_empty() { message } else { format!("{path}: {message}") };
1176 crate::violation::Violation::new(path, "invalid_value", text)
1177 };
1178 ServiceError::Violations(vec![violation])
1179}
1180
1181pub const MAX_BATCH_SIZE: usize = 1000;
1185
1186fn check_batch_size(count: usize) -> Result<(), ServiceError> {
1188 if count > MAX_BATCH_SIZE {
1189 return Err(ServiceError::Validation(format!(
1190 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1191 )));
1192 }
1193 Ok(())
1194}
1195
1196pub const UPSERT_UNSUPPORTED: &str =
1198 "upsert is not available for this resource: a create body names no record to match. \
1199 Create the record, or update it by id.";
1200
1201fn at_batch_index(err: ServiceError, index: usize) -> ServiceError {
1204 let item = |message: String| format!("item {index}: {message}");
1205 match err {
1206 ServiceError::Violations(violations) => ServiceError::Violations(
1207 violations
1208 .into_iter()
1209 .map(|mut v| {
1210 v.path = crate::violation::batch_item_path(index, &v.path);
1211 v
1212 })
1213 .collect(),
1214 ),
1215 ServiceError::Repository(e) => ServiceError::Repository(e.at_batch_index(index)),
1216 ServiceError::Validation(m) => ServiceError::Validation(item(m)),
1217 ServiceError::AlreadyExists(m) => ServiceError::AlreadyExists(item(m)),
1218 ServiceError::Internal(m) => ServiceError::Internal(item(m)),
1219 ServiceError::NotFound => ServiceError::Validation(item("not found".to_string())),
1220 }
1221}
1222
1223fn dedup_ids(ids: Vec<String>) -> Vec<String> {
1227 let mut seen = std::collections::HashSet::new();
1228 ids.into_iter().filter(|id| seen.insert(id.clone())).collect()
1229}
1230
1231fn first_duplicate_id(ids: impl IntoIterator<Item = String>) -> Option<String> {
1234 let mut seen = std::collections::HashSet::new();
1235 for id in ids {
1236 if !seen.insert(id.clone()) {
1237 return Some(id);
1238 }
1239 }
1240 None
1241}
1242
1243#[async_trait::async_trait]
1254impl<E, C, U, R> crate::http::CrudService<E, C, U> for GenericCrudService<E, C, U, R>
1255where
1256 E: PersistentEntity
1257 + Clone
1258 + serde::Serialize
1259 + serde::de::DeserializeOwned
1260 + FromCreateDto<C>
1261 + ApplyUpdateDto<U>
1262 + Send
1263 + Sync
1264 + 'static,
1265 C: Send + Sync + 'static,
1266 U: Send + Sync + 'static,
1267 R: CrudRepository<E> + Send + Sync + 'static,
1268{
1269 type Error = ServiceError;
1270
1271 fn violations_of(err: &ServiceError) -> Option<Vec<crate::violation::Violation>> {
1272 match err {
1273 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => Some(v.clone()),
1274 _ => None,
1275 }
1276 }
1277
1278 fn status_of(err: &ServiceError) -> Option<axum::http::StatusCode> {
1279 Some(crate::http::service_error_status(err))
1280 }
1281
1282 fn supports_upsert() -> bool {
1283 false
1284 }
1285
1286 fn entity_name() -> &'static str {
1287 let full = std::any::type_name::<E>();
1290 full.rsplit("::").next().unwrap_or(full)
1291 }
1292
1293 async fn fetch_related_json(&self, table: &str, ids: &[String]) -> Vec<serde_json::Value> {
1294 self.repository().fetch_related_json(table, ids).await
1295 }
1296
1297 async fn list(
1298 &self,
1299 page: u32,
1300 limit: u32,
1301 filters: HashMap<String, String>,
1302 ) -> Result<(Vec<E>, u64), ServiceError> {
1303 self.list(page, limit, filters).await
1304 }
1305
1306 async fn list_with_info(
1307 &self,
1308 page: u32,
1309 limit: u32,
1310 filters: HashMap<String, String>,
1311 ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), ServiceError> {
1312 self.list_with_info(page, limit, filters).await
1313 }
1314
1315 async fn aggregate(
1316 &self,
1317 spec: &backbone_orm::repository::AggregateSpec,
1318 filters: HashMap<String, String>,
1319 ) -> Result<backbone_orm::repository::AggregateResult, ServiceError> {
1320 self.aggregate(spec, filters).await
1321 }
1322
1323 fn table_name(&self) -> Option<&str> {
1324 self.repository.table_name()
1325 }
1326
1327 async fn create(&self, dto: C) -> Result<E, ServiceError> {
1328 self.create(dto).await
1329 }
1330
1331 async fn get_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1332 self.get_by_id(id).await
1333 }
1334
1335 async fn update(&self, id: &str, dto: U) -> Result<Option<E>, ServiceError> {
1336 self.update(id, dto).await
1337 }
1338
1339 async fn partial_update(
1340 &self,
1341 id: &str,
1342 fields: HashMap<String, serde_json::Value>,
1343 ) -> Result<Option<E>, ServiceError> {
1344 self.partial_update(id, fields).await
1345 }
1346
1347 async fn soft_delete(&self, id: &str) -> Result<bool, ServiceError> {
1348 self.soft_delete(id).await
1349 }
1350
1351 async fn bulk_create(&self, items: Vec<C>) -> Result<Vec<E>, ServiceError> {
1352 self.bulk_create(items).await
1353 }
1354
1355 async fn upsert(&self, dto: C) -> Result<E, ServiceError> {
1356 self.upsert(dto).await
1357 }
1358
1359 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), ServiceError> {
1360 self.list_deleted(page, limit).await
1361 }
1362
1363 async fn restore(&self, id: &str) -> Result<Option<E>, ServiceError> {
1364 self.restore(id).await
1365 }
1366
1367 async fn empty_trash(&self) -> Result<u64, ServiceError> {
1368 self.empty_trash().await
1369 }
1370
1371 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1372 self.get_deleted_by_id(id).await
1373 }
1374
1375 async fn permanent_delete(&self, id: &str) -> Result<bool, ServiceError> {
1376 self.permanent_delete(id).await
1377 }
1378
1379 async fn list_deleted_filtered(
1380 &self,
1381 page: u32,
1382 limit: u32,
1383 filters: HashMap<String, String>,
1384 ) -> Result<(Vec<E>, u64), ServiceError> {
1385 self.list_deleted_filtered(page, limit, filters).await
1386 }
1387
1388 async fn count_active(&self) -> Result<u64, ServiceError> {
1389 self.count_active().await
1390 }
1391
1392 async fn count_deleted(&self) -> Result<u64, ServiceError> {
1393 self.count_deleted().await
1394 }
1395
1396 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1397 self.bulk_soft_delete(ids).await
1398 }
1399
1400 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<E>, ServiceError> {
1401 self.bulk_restore(ids).await
1402 }
1403
1404 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1405 self.bulk_permanent_delete(ids).await
1406 }
1407
1408 async fn restore_all(&self) -> Result<u64, ServiceError> {
1409 self.restore_all().await
1410 }
1411
1412 async fn bulk_update(&self, items: Vec<(String, U)>) -> Result<Vec<E>, ServiceError> {
1413 self.bulk_update(items).await
1414 }
1415
1416 async fn bulk_partial_update(
1417 &self,
1418 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1419 ) -> Result<Vec<E>, ServiceError> {
1420 self.bulk_partial_update(items).await
1421 }
1422
1423 async fn preview_bulk_partial_update(
1424 &self,
1425 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1426 ) -> Result<Option<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>, ServiceError> {
1427 self.preview_bulk_partial_update(items).await.map(Some)
1428 }
1429}
1430
1431impl<E, C, U, R> Clone for GenericCrudService<E, C, U, R>
1433where
1434 E: PersistentEntity + Clone,
1435 R: CrudRepository<E>,
1436{
1437 fn clone(&self) -> Self {
1438 Self {
1439 repository: self.repository.clone(),
1440 lifecycle: self.lifecycle.clone(),
1441 event_publisher: self.event_publisher.clone(),
1442 guard: self.guard.clone(),
1443 _phantom: PhantomData,
1444 }
1445 }
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450 use super::*;
1451 use chrono::{DateTime, Utc};
1452 use serde::{Deserialize, Serialize};
1453
1454 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1457 struct Widget {
1458 id: String,
1459 name: String,
1460 #[serde(skip_serializing_if = "Option::is_none")]
1461 note: Option<String>,
1462 secret_hash: String,
1463 #[serde(default)]
1465 state: String,
1466 #[serde(default, skip_serializing_if = "Option::is_none")]
1468 owner_id: Option<String>,
1469 #[serde(skip_serializing_if = "Option::is_none")]
1470 deleted_at: Option<DateTime<Utc>>,
1471 created_at: DateTime<Utc>,
1472 updated_at: DateTime<Utc>,
1473 }
1474
1475 impl crate::persistence::traits::PersistentEntity for Widget {
1476 fn write_protected_fields() -> &'static [&'static str] {
1477 &["secret_hash", "state"]
1478 }
1479 fn state_fields() -> &'static [&'static str] {
1480 &["state"]
1481 }
1482 fn references() -> &'static [crate::persistence::traits::Reference] {
1483 const REFERENCES: &[crate::persistence::traits::Reference] =
1486 &[crate::persistence::traits::Reference::new("owner_id", "owners", "id")];
1487 REFERENCES
1488 }
1489 fn entity_id(&self) -> String {
1490 self.id.clone()
1491 }
1492 fn set_entity_id(&mut self, id: String) {
1493 self.id = id;
1494 }
1495 fn created_at(&self) -> Option<DateTime<Utc>> {
1496 Some(self.created_at)
1497 }
1498 fn set_created_at(&mut self, ts: DateTime<Utc>) {
1499 self.created_at = ts;
1500 }
1501 fn updated_at(&self) -> Option<DateTime<Utc>> {
1502 Some(self.updated_at)
1503 }
1504 fn set_updated_at(&mut self, ts: DateTime<Utc>) {
1505 self.updated_at = ts;
1506 }
1507 fn deleted_at(&self) -> Option<DateTime<Utc>> {
1508 self.deleted_at
1509 }
1510 fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
1511 self.deleted_at = ts;
1512 }
1513 }
1514
1515 #[derive(Deserialize)]
1516 struct CreateWidgetDto {
1517 name: String,
1518 }
1519
1520 #[derive(Deserialize)]
1521 struct UpdateWidgetDto {
1522 name: String,
1523 }
1524
1525 impl backbone_orm::EntityRepoMeta for Widget {
1526 fn column_types() -> HashMap<String, String> {
1527 HashMap::new()
1528 }
1529 fn search_fields() -> &'static [&'static str] {
1530 &["name"]
1531 }
1532 }
1533
1534 const BROKEN_ID: &str = "00000000-0000-0000-0000-00000000dead";
1536
1537 impl FromCreateDto<CreateWidgetDto> for Widget {
1538 fn from_create_dto(dto: CreateWidgetDto) -> ServiceResult<Self> {
1539 let owner_id = dto.name.strip_prefix("owned-by:").map(str::to_string);
1541 Ok(Widget {
1542 id: uuid::Uuid::new_v4().to_string(),
1543 name: dto.name,
1544 note: None,
1545 secret_hash: "h0".into(),
1546 state: "draft".into(),
1547 owner_id,
1548 deleted_at: None,
1549 created_at: Utc::now(),
1550 updated_at: Utc::now(),
1551 })
1552 }
1553 }
1554
1555 impl ApplyUpdateDto<UpdateWidgetDto> for Widget {
1556 fn apply_update(mut self, dto: UpdateWidgetDto) -> ServiceResult<Self> {
1557 self.name = dto.name;
1558 self.updated_at = Utc::now();
1559 Ok(self)
1560 }
1561 }
1562
1563 struct InMemoryWidgetRepo {
1565 data: tokio::sync::Mutex<Vec<Widget>>,
1566 changed_writes: std::sync::Mutex<Vec<(Widget, Widget)>>,
1568 full_writes: std::sync::atomic::AtomicUsize,
1570 vanish_on_write: std::sync::atomic::AtomicBool,
1572 bulk_creates: std::sync::atomic::AtomicUsize,
1574 visible_owners: std::sync::Mutex<Vec<String>>,
1576 }
1577
1578 impl InMemoryWidgetRepo {
1579 fn new() -> Self {
1580 Self {
1581 data: tokio::sync::Mutex::new(Vec::new()),
1582 changed_writes: std::sync::Mutex::new(Vec::new()),
1583 full_writes: std::sync::atomic::AtomicUsize::new(0),
1584 vanish_on_write: std::sync::atomic::AtomicBool::new(false),
1585 bulk_creates: std::sync::atomic::AtomicUsize::new(0),
1586 visible_owners: std::sync::Mutex::new(vec!["mine".to_string()]),
1587 }
1588 }
1589 }
1590
1591 #[async_trait]
1592 impl crate::persistence::traits::CrudRepository<Widget> for InMemoryWidgetRepo {
1593 async fn reference_visible(
1594 &self,
1595 table: &str,
1596 column: &str,
1597 value: &serde_json::Value,
1598 ) -> Result<Option<bool>, RepositoryError> {
1599 assert_eq!((table, column), ("owners", "id"));
1600 let visible = self.visible_owners.lock().unwrap();
1601 Ok(Some(value.as_str().is_some_and(|v| visible.iter().any(|o| o == v))))
1602 }
1603
1604 async fn create(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1605 self.data.lock().await.push(entity.clone());
1606 Ok(entity)
1607 }
1608
1609 async fn find_by_id(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1610 if id == BROKEN_ID {
1611 return Err(RepositoryError::DatabaseError(
1612 "relation \"secret_schema.widgets\" is broken".into(),
1613 ));
1614 }
1615 Ok(self
1616 .data
1617 .lock()
1618 .await
1619 .iter()
1620 .find(|w| w.id == id && w.deleted_at.is_none())
1621 .cloned())
1622 }
1623
1624 async fn update(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1625 self.full_writes
1626 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1627 let mut data = self.data.lock().await;
1628 data.retain(|w| w.id != entity.id);
1629 data.push(entity.clone());
1630 Ok(entity)
1631 }
1632
1633 async fn update_changed(
1634 &self,
1635 before: &Widget,
1636 after: Widget,
1637 ) -> Result<Option<Widget>, RepositoryError> {
1638 self.changed_writes
1639 .lock()
1640 .unwrap()
1641 .push((before.clone(), after.clone()));
1642 if self
1643 .vanish_on_write
1644 .load(std::sync::atomic::Ordering::SeqCst)
1645 {
1646 return Ok(None);
1647 }
1648 if after.name == "taken" {
1649 return Err(RepositoryError::AlreadyExists(
1650 "duplicate key value violates unique constraint \"widgets_name_key\"".into(),
1651 ));
1652 }
1653 let mut data = self.data.lock().await;
1654 data.retain(|w| w.id != after.id);
1655 data.push(after.clone());
1656 Ok(Some(after))
1657 }
1658
1659 async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1660 let mut data = self.data.lock().await;
1661 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1662 w.deleted_at = Some(Utc::now());
1663 return Ok(true);
1664 }
1665 Ok(false)
1666 }
1667
1668 async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1669 Ok(self.data.lock().await.iter().find(|w| w.id == id).cloned())
1670 }
1671
1672 async fn restore(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1673 let mut data = self.data.lock().await;
1674 if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1675 w.deleted_at = None;
1676 return Ok(Some(w.clone()));
1677 }
1678 Ok(None)
1679 }
1680
1681 async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1682 let mut data = self.data.lock().await;
1683 let before = data.len();
1684 data.retain(|w| w.id != id);
1685 Ok(data.len() < before)
1686 }
1687
1688 async fn list(
1689 &self,
1690 page: u32,
1691 limit: u32,
1692 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1693 let data = self.data.lock().await;
1694 let active: Vec<_> = data.iter().filter(|w| w.deleted_at.is_none()).cloned().collect();
1695 let total = active.len() as u64;
1696 let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1697 let limit = limit as usize;
1698 let page = active.into_iter().skip(offset).take(limit).collect();
1699 Ok((page, total))
1700 }
1701
1702 async fn list_deleted(
1703 &self,
1704 page: u32,
1705 limit: u32,
1706 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1707 let data = self.data.lock().await;
1708 let deleted: Vec<_> = data.iter().filter(|w| w.deleted_at.is_some()).cloned().collect();
1709 let total = deleted.len() as u64;
1710 let limit = (limit as usize).min(100);
1712 let offset = (page.saturating_sub(1) as usize) * limit;
1713 let page = deleted.into_iter().skip(offset).take(limit).collect();
1714 Ok((page, total))
1715 }
1716
1717 async fn count(&self) -> Result<u64, RepositoryError> {
1718 let data = self.data.lock().await;
1719 Ok(data.iter().filter(|w| w.deleted_at.is_none()).count() as u64)
1720 }
1721
1722 async fn count_deleted(&self) -> Result<u64, RepositoryError> {
1723 let data = self.data.lock().await;
1724 Ok(data.iter().filter(|w| w.deleted_at.is_some()).count() as u64)
1725 }
1726
1727 async fn list_deleted_filtered(
1728 &self,
1729 page: u32,
1730 limit: u32,
1731 filters: HashMap<String, String>,
1732 ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1733 let (rows, _) = self.list_deleted(page, limit).await?;
1734 let rows: Vec<Widget> = rows
1735 .into_iter()
1736 .filter(|w| filters.get("name").is_none_or(|n| *n == w.name))
1737 .collect();
1738 let total = rows.len() as u64;
1739 Ok((rows, total))
1740 }
1741
1742 async fn bulk_create(&self, entities: Vec<Widget>) -> Result<Vec<Widget>, RepositoryError> {
1743 self.bulk_creates
1744 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1745 let mut data = self.data.lock().await;
1746 data.extend(entities.clone());
1747 Ok(entities)
1748 }
1749
1750 async fn empty_trash(&self) -> Result<u64, RepositoryError> {
1751 let mut data = self.data.lock().await;
1752 let before = data.len();
1753 data.retain(|w| w.deleted_at.is_none());
1754 Ok((before - data.len()) as u64)
1755 }
1756 }
1757
1758 #[tokio::test]
1759 async fn create_and_get_roundtrip() {
1760 let repo = Arc::new(InMemoryWidgetRepo::new());
1761 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1762 GenericCrudService::with_repository(repo);
1763
1764 let widget = service
1765 .create(CreateWidgetDto {
1766 name: "sprocket".into(),
1767 })
1768 .await
1769 .unwrap();
1770
1771 let found = service.get_by_id(&widget.id).await.unwrap();
1772 assert!(found.is_some());
1773 assert_eq!(found.unwrap().name, "sprocket");
1774 }
1775
1776 #[tokio::test]
1777 async fn soft_delete_hides_from_list() {
1778 let repo = Arc::new(InMemoryWidgetRepo::new());
1779 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1780 GenericCrudService::with_repository(repo);
1781
1782 let w = service.create(CreateWidgetDto { name: "w".into() }).await.unwrap();
1783 service.soft_delete(&w.id).await.unwrap();
1784
1785 let (items, _) = service.list(1, 20, Default::default()).await.unwrap();
1786 assert!(items.is_empty());
1787
1788 let (deleted, _) = service.list_deleted(1, 20).await.unwrap();
1789 assert_eq!(deleted.len(), 1);
1790 }
1791
1792 #[tokio::test]
1793 async fn update_publishes_event_and_returns_new_entity() {
1794 let repo = Arc::new(InMemoryWidgetRepo::new());
1795 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1796 GenericCrudService::with_repository(repo);
1797
1798 let w = service.create(CreateWidgetDto { name: "old".into() }).await.unwrap();
1799 let updated = service
1800 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1801 .await
1802 .unwrap();
1803 assert_eq!(updated.unwrap().name, "new");
1804 }
1805
1806 #[tokio::test]
1809 async fn a_patch_writes_only_through_the_changed_columns_path() {
1810 let repo = Arc::new(InMemoryWidgetRepo::new());
1811 let service: GenericCrudService<
1812 Widget,
1813 CreateWidgetDto,
1814 UpdateWidgetDto,
1815 InMemoryWidgetRepo,
1816 > = GenericCrudService::with_repository(repo.clone());
1817 let w = service
1818 .create(CreateWidgetDto { name: "old".into() })
1819 .await
1820 .unwrap();
1821
1822 let mut fields = HashMap::new();
1823 fields.insert("note".to_string(), serde_json::json!("edited"));
1824 let saved = service
1825 .partial_update(&w.id, fields)
1826 .await
1827 .unwrap()
1828 .unwrap();
1829 assert_eq!(saved.note.as_deref(), Some("edited"));
1830
1831 let writes = repo.changed_writes.lock().unwrap().clone();
1832 assert_eq!(writes.len(), 1, "a PATCH must save through update_changed");
1833 assert_eq!(writes[0].0, w, "the write starts from the row as read");
1834 assert_eq!(
1835 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1836 0
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn a_put_writes_only_through_the_changed_columns_path() {
1842 let repo = Arc::new(InMemoryWidgetRepo::new());
1843 let service: GenericCrudService<
1844 Widget,
1845 CreateWidgetDto,
1846 UpdateWidgetDto,
1847 InMemoryWidgetRepo,
1848 > = GenericCrudService::with_repository(repo.clone());
1849 let w = service
1850 .create(CreateWidgetDto { name: "old".into() })
1851 .await
1852 .unwrap();
1853 service
1854 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1855 .await
1856 .unwrap()
1857 .unwrap();
1858 assert_eq!(repo.changed_writes.lock().unwrap().len(), 1);
1859 assert_eq!(
1860 repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1861 0
1862 );
1863 }
1864
1865 #[tokio::test]
1866 async fn a_write_whose_row_vanished_meanwhile_answers_not_found() {
1867 let repo = Arc::new(InMemoryWidgetRepo::new());
1868 let service: GenericCrudService<
1869 Widget,
1870 CreateWidgetDto,
1871 UpdateWidgetDto,
1872 InMemoryWidgetRepo,
1873 > = GenericCrudService::with_repository(repo.clone());
1874 let w = service
1875 .create(CreateWidgetDto { name: "old".into() })
1876 .await
1877 .unwrap();
1878 repo.vanish_on_write
1879 .store(true, std::sync::atomic::Ordering::SeqCst);
1880
1881 let mut fields = HashMap::new();
1882 fields.insert("note".to_string(), serde_json::json!("edited"));
1883 assert!(service
1884 .partial_update(&w.id, fields)
1885 .await
1886 .unwrap()
1887 .is_none());
1888 assert!(service
1889 .update(&w.id, UpdateWidgetDto { name: "new".into() })
1890 .await
1891 .unwrap()
1892 .is_none());
1893 }
1894
1895 #[tokio::test]
1898 async fn a_transition_moves_its_protected_state_field() {
1899 let service = svc();
1900 let w = service
1901 .create(CreateWidgetDto { name: "w".into() })
1902 .await
1903 .unwrap();
1904 let mut fields = HashMap::new();
1905 fields.insert("state".to_string(), serde_json::json!("active"));
1906 let saved =
1907 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1908 .await
1909 .expect("a verb moves its own state field")
1910 .unwrap();
1911 assert_eq!(saved.state, "active");
1912 }
1913
1914 #[tokio::test]
1915 async fn a_transition_still_cannot_move_another_protected_field() {
1916 let service = svc();
1917 let w = service
1918 .create(CreateWidgetDto { name: "w".into() })
1919 .await
1920 .unwrap();
1921 let mut fields = HashMap::new();
1922 fields.insert("state".to_string(), serde_json::json!("active"));
1923 fields.insert("secret_hash".to_string(), serde_json::json!("h1"));
1924 let err =
1925 crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1926 .await
1927 .unwrap_err();
1928 match err {
1929 ServiceError::Violations(v) => {
1930 assert_eq!(v.len(), 1, "{v:?}");
1931 assert_eq!(
1932 (v[0].path.as_str(), v[0].code.as_str()),
1933 ("secret_hash", "field_not_writable")
1934 );
1935 }
1936 other => panic!("expected a violation, got {other:?}"),
1937 }
1938 }
1939
1940 #[tokio::test]
1941 async fn a_generic_write_still_cannot_move_the_state_field() {
1942 let service = svc();
1943 let w = service
1944 .create(CreateWidgetDto { name: "w".into() })
1945 .await
1946 .unwrap();
1947 let mut fields = HashMap::new();
1948 fields.insert("state".to_string(), serde_json::json!("active"));
1949 let err = service.partial_update(&w.id, fields).await.unwrap_err();
1950 assert!(
1951 matches!(err, ServiceError::Violations(ref v) if v[0].path == "state"),
1952 "{err:?}"
1953 );
1954 }
1955
1956 fn svc() -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
1959 GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()))
1960 }
1961
1962 #[tokio::test]
1963 async fn bulk_soft_delete_success() {
1964 let service = svc();
1965 let mut ids = Vec::new();
1966 for n in 0..3 {
1967 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1968 }
1969 let n = service.bulk_soft_delete(ids).await.unwrap();
1970 assert_eq!(n, 3);
1971 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1972 assert!(active.is_empty());
1973 }
1974
1975 #[tokio::test]
1976 async fn bulk_soft_delete_is_all_or_nothing_on_missing_id() {
1977 let service = svc();
1978 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1979 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1980
1981 let res = service
1983 .bulk_soft_delete(vec![a.id.clone(), b.id.clone(), "does-not-exist".into()])
1984 .await;
1985 assert!(res.is_err());
1986
1987 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1988 assert_eq!(active.len(), 2, "no rows should have been deleted");
1989 }
1990
1991 #[tokio::test]
1992 async fn bulk_restore_and_restore_all() {
1993 let service = svc();
1994 let mut ids = Vec::new();
1995 for n in 0..3 {
1996 ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1997 }
1998 service.bulk_soft_delete(ids.clone()).await.unwrap();
1999
2000 let restored = service.bulk_restore(vec![ids[0].clone(), ids[1].clone()]).await.unwrap();
2002 assert_eq!(restored.len(), 2);
2003
2004 let n = service.restore_all().await.unwrap();
2006 assert_eq!(n, 1);
2007
2008 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2009 assert_eq!(active.len(), 3);
2010 }
2011
2012 #[tokio::test]
2013 async fn bulk_update_is_all_or_nothing_on_missing_id() {
2014 let service = svc();
2015 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2016
2017 let res = service
2018 .bulk_update(vec![
2019 (a.id.clone(), UpdateWidgetDto { name: "A2".into() }),
2020 ("missing".into(), UpdateWidgetDto { name: "X".into() }),
2021 ])
2022 .await;
2023 assert!(res.is_err());
2024
2025 let still = service.get_by_id(&a.id).await.unwrap().unwrap();
2027 assert_eq!(still.name, "a");
2028 }
2029
2030 #[tokio::test]
2031 async fn bulk_partial_update_success() {
2032 let service = svc();
2033 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2034 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2035
2036 let mut patch_a = std::collections::HashMap::new();
2037 patch_a.insert("name".to_string(), serde_json::json!("a2"));
2038 let mut patch_b = std::collections::HashMap::new();
2039 patch_b.insert("name".to_string(), serde_json::json!("b2"));
2040
2041 let saved = service
2042 .bulk_partial_update(vec![(a.id.clone(), patch_a), (b.id.clone(), patch_b)])
2043 .await
2044 .unwrap();
2045 assert_eq!(saved.len(), 2);
2046 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a2");
2047 assert_eq!(service.get_by_id(&b.id).await.unwrap().unwrap().name, "b2");
2048 }
2049
2050 #[tokio::test]
2051 async fn bulk_soft_delete_tolerates_duplicate_ids() {
2052 let service = svc();
2053 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2054 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2055
2056 let n = service
2058 .bulk_soft_delete(vec![a.id.clone(), a.id.clone(), b.id.clone()])
2059 .await
2060 .unwrap();
2061 assert_eq!(n, 2, "two distinct rows soft-deleted");
2062
2063 let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2064 assert!(active.is_empty());
2065 }
2066
2067 #[tokio::test]
2068 async fn bulk_update_rejects_duplicate_ids() {
2069 let service = svc();
2070 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2071
2072 let res = service
2074 .bulk_update(vec![
2075 (a.id.clone(), UpdateWidgetDto { name: "first".into() }),
2076 (a.id.clone(), UpdateWidgetDto { name: "second".into() }),
2077 ])
2078 .await;
2079 assert!(res.is_err());
2080 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2081 }
2082
2083 #[tokio::test]
2084 async fn bulk_soft_delete_rejects_oversized_batch() {
2085 let service = svc();
2086 let ids: Vec<String> = (0..MAX_BATCH_SIZE + 1).map(|n| format!("id-{n}")).collect();
2087 assert!(service.bulk_soft_delete(ids).await.is_err());
2089 }
2090
2091 struct RekeyWidgetDto {
2096 name: String,
2097 secret_hash: String,
2098 }
2099
2100 impl ApplyUpdateDto<RekeyWidgetDto> for Widget {
2101 fn apply_update(mut self, dto: RekeyWidgetDto) -> ServiceResult<Self> {
2102 self.name = dto.name;
2103 self.secret_hash = dto.secret_hash;
2104 Ok(self)
2105 }
2106 }
2107
2108 type RekeyService = GenericCrudService<Widget, CreateWidgetDto, RekeyWidgetDto, InMemoryWidgetRepo>;
2109
2110 fn patch(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
2111 pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
2112 }
2113
2114 fn validation_message(res: ServiceResult<impl std::fmt::Debug>) -> String {
2117 match res {
2118 Err(ServiceError::Validation(m)) => m,
2119 Err(e @ ServiceError::Violations(_)) => e.to_string(),
2120 other => panic!("expected a validation error, got {other:?}"),
2121 }
2122 }
2123
2124 #[tokio::test]
2125 async fn patch_rejects_a_field_the_entity_does_not_have() {
2126 let service = svc();
2127 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2128
2129 let msg = validation_message(service.partial_update(&w.id, patch(&[("nmae", serde_json::json!("b"))])).await);
2130 assert!(msg.contains("unknown_field") && msg.contains("nmae"), "{msg}");
2131 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2132 }
2133
2134 #[tokio::test]
2135 async fn patch_rejects_changing_the_id() {
2136 let service = svc();
2137 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2138
2139 let msg = validation_message(service.partial_update(&w.id, patch(&[("id", serde_json::json!("other"))])).await);
2140 assert!(msg.contains("field_not_writable") && msg.contains("id"), "{msg}");
2141 assert!(service.get_by_id(&w.id).await.unwrap().is_some());
2142 }
2143
2144 #[tokio::test]
2145 async fn patch_rejects_changing_a_field_the_entity_protects() {
2146 let service = svc();
2147 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2148
2149 let msg = validation_message(
2150 service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await,
2151 );
2152 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2153 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().secret_hash, "h0");
2154 }
2155
2156 #[tokio::test]
2157 async fn patch_accepts_a_protected_field_echoed_unchanged() {
2158 let service = svc();
2159 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2160
2161 let saved = service
2162 .partial_update(&w.id, patch(&[("name", serde_json::json!("b")), ("secret_hash", serde_json::json!("h0"))]))
2163 .await
2164 .unwrap()
2165 .unwrap();
2166 assert_eq!(saved.name, "b");
2167 }
2168
2169 #[tokio::test]
2170 async fn patch_sets_and_clears_an_optional_field_that_is_not_serialized_when_empty() {
2171 let service = svc();
2172 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2173
2174 let set = service.partial_update(&w.id, patch(&[("note", serde_json::json!("hi"))])).await.unwrap().unwrap();
2175 assert_eq!(set.note.as_deref(), Some("hi"));
2176 let cleared = service.partial_update(&w.id, patch(&[("note", serde_json::Value::Null)])).await.unwrap().unwrap();
2177 assert_eq!(cleared.note, None);
2178 }
2179
2180 #[tokio::test]
2181 async fn put_rejects_changing_a_field_the_entity_protects() {
2182 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2183 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2184
2185 let msg = validation_message(
2186 service.update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h1".into() }).await,
2187 );
2188 assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2189
2190 let saved = service
2192 .update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h0".into() })
2193 .await
2194 .unwrap()
2195 .unwrap();
2196 assert_eq!(saved.name, "b");
2197 }
2198
2199 #[tokio::test]
2200 async fn bulk_update_rejects_a_protected_change_before_writing_any_row() {
2201 let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2202 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2203 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2204
2205 let res = service
2206 .bulk_update(vec![
2207 (a.id.clone(), RekeyWidgetDto { name: "a2".into(), secret_hash: "h0".into() }),
2208 (b.id.clone(), RekeyWidgetDto { name: "b2".into(), secret_hash: "h1".into() }),
2209 ])
2210 .await;
2211 assert!(validation_message(res).contains("field_not_writable"));
2212 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2213 }
2214
2215 #[tokio::test]
2216 async fn bulk_partial_update_rejects_a_protected_change_before_writing_any_row() {
2217 let service = svc();
2218 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2219 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2220
2221 let res = service
2222 .bulk_partial_update(vec![
2223 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2224 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2225 ])
2226 .await;
2227 assert!(validation_message(res).contains("field_not_writable"));
2228 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2229 }
2230
2231 #[tokio::test]
2232 async fn a_bulk_preview_says_what_each_row_would_do_and_writes_nothing() {
2233 let service = svc();
2234 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2235 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2236
2237 let rows = service
2238 .preview_bulk_partial_update(vec![
2239 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2240 (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2241 ("missing".into(), patch(&[("name", serde_json::json!("x"))])),
2242 ])
2243 .await
2244 .unwrap();
2245 assert_eq!(rows.len(), 3);
2246 let (before, after) = rows[0].outcome.as_ref().unwrap();
2247 assert_eq!((before.name.as_str(), after.name.as_str()), ("a", "a2"));
2248 let refused = rows[1].outcome.as_ref().err().unwrap().to_string();
2249 assert!(refused.contains("field_not_writable"), "{refused}");
2250 assert!(rows[2].outcome.is_err(), "a row that is not there is refused, not dropped");
2251 assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2253 }
2254
2255 #[tokio::test]
2256 async fn a_bulk_preview_refuses_a_batch_the_write_would_refuse_whole() {
2257 let service = svc();
2258 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2259 let res = service
2260 .preview_bulk_partial_update(vec![
2261 (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2262 (a.id.clone(), patch(&[("name", serde_json::json!("a3"))])),
2263 ])
2264 .await;
2265 assert!(validation_message(res.map(|r| r.len())).contains("duplicate id"));
2266 }
2267
2268 #[tokio::test]
2269 async fn bulk_partial_update_rejects_an_unknown_field() {
2270 let service = svc();
2271 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2272
2273 let res = service.bulk_partial_update(vec![(a.id.clone(), patch(&[("colour", serde_json::json!("red"))]))]).await;
2274 assert!(validation_message(res).contains("unknown_field"));
2275 }
2276
2277 #[derive(Default)]
2280 struct RecordingPublisher {
2281 kinds: tokio::sync::Mutex<Vec<&'static str>>,
2282 }
2283
2284 #[async_trait]
2285 impl CrudEventPublisher<Widget> for RecordingPublisher {
2286 async fn publish(&self, event: CrudEvent<Widget>) -> Result<(), backbone_messaging::EventError> {
2287 let kind = match event {
2288 CrudEvent::Updated { .. } => "updated",
2289 CrudEvent::Patched { .. } => "patched",
2290 _ => "other",
2291 };
2292 self.kinds.lock().await.push(kind);
2293 Ok(())
2294 }
2295 }
2296
2297 #[tokio::test]
2298 async fn patch_publishes_an_updated_event() {
2299 let publisher = Arc::new(RecordingPublisher::default());
2300 let service = svc().with_event_publisher(publisher.clone());
2301 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2302 publisher.kinds.lock().await.clear();
2303
2304 service.partial_update(&w.id, patch(&[("name", serde_json::json!("b"))])).await.unwrap();
2305 assert_eq!(*publisher.kinds.lock().await, vec!["updated"]);
2306 }
2307
2308 struct RefuseTrash;
2311
2312 #[async_trait]
2313 impl ServiceLifecycle<Widget> for RefuseTrash {
2314 async fn before_restore(&self, _entity: &Widget) -> ServiceResult<()> {
2315 Err(ServiceError::Validation("restore refused".into()))
2316 }
2317 async fn before_hard_delete(&self, _entity: &Widget) -> ServiceResult<()> {
2318 Err(ServiceError::Validation("hard delete refused".into()))
2319 }
2320 async fn before_restore_all(&self) -> ServiceResult<()> {
2321 Err(ServiceError::Validation("restore all refused".into()))
2322 }
2323 async fn before_empty_trash(&self) -> ServiceResult<()> {
2324 Err(ServiceError::Validation("empty trash refused".into()))
2325 }
2326 }
2327
2328 #[tokio::test]
2329 async fn every_trash_operation_consults_the_lifecycle() {
2330 let repo = Arc::new(InMemoryWidgetRepo::new());
2331 let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2332 GenericCrudService::with_lifecycle(repo, Arc::new(RefuseTrash));
2333 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2334 service.soft_delete(&w.id).await.unwrap();
2335
2336 assert!(service.restore(&w.id).await.is_err(), "restore");
2337 assert!(service.bulk_restore(vec![w.id.clone()]).await.is_err(), "bulk restore");
2338 assert!(service.restore_all().await.is_err(), "restore all");
2339 assert!(service.hard_delete(&w.id).await.is_err(), "hard delete");
2340 assert!(service.bulk_permanent_delete(vec![w.id.clone()]).await.is_err(), "bulk permanent delete");
2341 assert!(service.empty_trash().await.is_err(), "empty trash");
2342
2343 let still = service.get_deleted_by_id(&w.id).await.unwrap().unwrap();
2345 assert!(still.deleted_at.is_some());
2346 }
2347
2348 #[tokio::test]
2351 async fn a_refused_field_is_a_violation_naming_its_path_and_code() {
2352 let service = svc();
2353 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2354
2355 let err = service
2356 .partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1")), ("colour", serde_json::json!("red"))]))
2357 .await
2358 .unwrap_err();
2359 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2360 let pairs: Vec<(&str, &str)> = v.iter().map(|x| (x.path.as_str(), x.code.as_str())).collect();
2361 assert_eq!(pairs, vec![("colour", "unknown_field")]);
2362 assert!(err.to_string().starts_with("validation failed: unknown_field: `colour`"), "{err}");
2364
2365 let err = service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await.unwrap_err();
2366 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2367 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("secret_hash", "field_not_writable"));
2368 }
2369
2370 #[derive(Default)]
2373 struct NameGuard {
2374 seen: std::sync::Mutex<Vec<crate::write_guard::WriteKind>>,
2375 }
2376
2377 #[async_trait]
2378 impl crate::write_guard::WriteGuard<Widget> for NameGuard {
2379 async fn check(&self, ctx: &crate::write_guard::WriteCtx<'_, Widget>) -> crate::write_guard::GuardOutcome {
2380 self.seen.lock().unwrap().push(ctx.kind);
2381 let row = ctx.after.or(ctx.before).expect("a write concerns a row");
2382 let mut out = crate::write_guard::GuardOutcome::default();
2383 match row.name.as_str() {
2384 "forbidden" => out.refuse.push(crate::violation::Violation::new("name", "name_forbidden", "this name is refused")),
2385 "watched" => out.shadow.push(crate::violation::Violation::new("name", "name_watched", "would be refused")),
2386 _ => {}
2387 }
2388 out
2389 }
2390 }
2391
2392 fn guarded(guard: Arc<NameGuard>) -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2393 svc().with_guard(guard)
2394 }
2395
2396 #[tokio::test]
2397 async fn a_guard_refusal_stops_create_update_and_patch_with_nothing_written() {
2398 let guard = Arc::new(NameGuard::default());
2399 let service = guarded(guard.clone());
2400
2401 let err = service.create(CreateWidgetDto { name: "forbidden".into() }).await.unwrap_err();
2402 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2403 assert!(service.list(1, 20, Default::default()).await.unwrap().0.is_empty(), "nothing created");
2404
2405 let w = service.create(CreateWidgetDto { name: "ok".into() }).await.unwrap();
2406 assert!(service.update(&w.id, UpdateWidgetDto { name: "forbidden".into() }).await.is_err());
2407 assert!(service.partial_update(&w.id, patch(&[("name", serde_json::json!("forbidden"))])).await.is_err());
2408 assert!(service
2409 .bulk_partial_update(vec![(w.id.clone(), patch(&[("name", serde_json::json!("forbidden"))]))])
2410 .await
2411 .is_err());
2412 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "ok");
2413 }
2414
2415 #[tokio::test]
2416 async fn a_shadowed_rule_lets_the_write_through() {
2417 let service = guarded(Arc::new(NameGuard::default()));
2418 let w = service.create(CreateWidgetDto { name: "watched".into() }).await.unwrap();
2419 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "watched");
2420 }
2421
2422 #[tokio::test]
2423 async fn the_guard_is_asked_about_every_write_kind() {
2424 use crate::write_guard::WriteKind::*;
2425 let guard = Arc::new(NameGuard::default());
2426 let service = guarded(guard.clone());
2427 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2428 service.update(&w.id, UpdateWidgetDto { name: "b".into() }).await.unwrap();
2429 service.partial_update(&w.id, patch(&[("name", serde_json::json!("c"))])).await.unwrap();
2430 service.soft_delete(&w.id).await.unwrap();
2431 service.restore(&w.id).await.unwrap();
2432 service.soft_delete(&w.id).await.unwrap();
2433 service.hard_delete(&w.id).await.unwrap();
2434 assert_eq!(*guard.seen.lock().unwrap(), vec![Create, Update, Update, Delete, Restore, Delete, HardDelete]);
2435 }
2436
2437 #[test]
2438 fn the_generic_service_hands_its_violations_to_the_http_layer() {
2439 use crate::http::CrudService;
2440 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2441 let v = vec![crate::violation::Violation::new("name", "x", "y")];
2442 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::Violations(v.clone())), Some(v.clone()));
2443 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::NotFound), None);
2444 let db = ServiceError::Repository(RepositoryError::Violations(v.clone()));
2446 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&db), Some(v));
2447 }
2448
2449 fn only_violation(res: ServiceResult<impl std::fmt::Debug>) -> crate::violation::Violation {
2452 match res {
2453 Err(ServiceError::Violations(mut v)) if v.len() == 1 => v.remove(0),
2454 other => panic!("expected one violation, got {other:?}"),
2455 }
2456 }
2457
2458 #[tokio::test]
2459 async fn a_reference_to_a_hidden_row_is_refused_on_every_write_kind() {
2460 let service = svc();
2461 let v = only_violation(service.create(CreateWidgetDto { name: "owned-by:theirs".into() }).await);
2463 assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2464 assert!(!v.message.contains("owners"), "{}", v.message);
2465 assert_eq!(service.count().await.unwrap(), 0);
2466 let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2468 service.create(CreateWidgetDto { name: "plain".into() }).await.unwrap();
2469
2470 let v = only_violation(service.partial_update(&w.id, patch(&[("owner_id", serde_json::json!("theirs"))])).await);
2472 assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2473 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().owner_id.as_deref(), Some("mine"));
2474 let v = only_violation(
2476 service
2477 .bulk_create(vec![CreateWidgetDto { name: "x".into() }, CreateWidgetDto { name: "owned-by:theirs".into() }])
2478 .await,
2479 );
2480 assert_eq!((v.path.as_str(), v.code.as_str()), ("1/owner_id", "reference_missing"));
2481 let v = only_violation(
2482 service.bulk_partial_update(vec![(w.id.clone(), patch(&[("owner_id", serde_json::json!("theirs"))]))]).await,
2483 );
2484 assert_eq!(v.path, "0/owner_id");
2485 }
2486
2487 #[tokio::test]
2488 async fn an_unchanged_reference_is_not_checked_again() {
2489 let service = svc();
2490 let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2491 service.repository().visible_owners.lock().unwrap().clear();
2493 let saved = service.partial_update(&w.id, patch(&[("note", serde_json::json!("x"))])).await.unwrap().unwrap();
2494 assert_eq!(saved.owner_id.as_deref(), Some("mine"));
2495 let saved = service.update(&w.id, UpdateWidgetDto { name: "renamed".into() }).await.unwrap().unwrap();
2496 assert_eq!(saved.name, "renamed");
2497 }
2498
2499 #[tokio::test]
2502 async fn a_patch_of_the_wrong_type_or_a_null_required_field_is_a_violation_on_its_path() {
2503 let service = svc();
2504 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2505 let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::json!(5))])).await);
2506 assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "invalid_value"));
2507 let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::Value::Null)])).await);
2508 assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "required"));
2509 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2510 }
2511
2512 #[tokio::test]
2513 async fn a_patch_setting_an_empty_protected_field_is_refused() {
2514 let service = svc();
2516 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2517 let v = only_violation(service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("set"))])).await);
2518 assert_eq!((v.path.as_str(), v.code.as_str()), ("secret_hash", "field_not_writable"));
2519 }
2520
2521 #[tokio::test]
2522 async fn emptying_a_trash_larger_than_one_page_reaches_every_row() {
2523 let service = svc();
2524 let mut ids = Vec::new();
2525 for i in 0..150 {
2526 ids.push(service.create(CreateWidgetDto { name: format!("w{i}") }).await.unwrap().id);
2527 }
2528 service.bulk_soft_delete(ids).await.unwrap();
2529 assert_eq!(service.empty_trash().await.unwrap(), 150);
2532 assert_eq!(service.count_deleted().await.unwrap(), 0);
2533 }
2534
2535 #[test]
2536 fn the_entity_name_never_carries_the_module_path() {
2537 use crate::http::CrudService;
2538 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2539 assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::entity_name(), "Widget");
2540 }
2541
2542 async fn trash_with_a_refused_row(
2546 service: &GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>,
2547 ) -> (String, String) {
2548 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2549 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2550 service.bulk_soft_delete(vec![a.clone(), b.clone()]).await.unwrap();
2551 let mut data = service.repository().data.lock().await;
2552 data.iter_mut().find(|w| w.id == b).unwrap().name = "forbidden".into();
2553 (a, b)
2554 }
2555
2556 #[tokio::test]
2557 async fn restore_all_asks_the_guard_about_every_row_and_restores_none_when_one_is_refused() {
2558 let guard = Arc::new(NameGuard::default());
2559 let service = guarded(guard.clone());
2560 trash_with_a_refused_row(&service).await;
2561
2562 let err = service.restore_all().await.unwrap_err();
2563 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2564 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing restored");
2565 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::Restore));
2566 }
2567
2568 #[tokio::test]
2569 async fn empty_trash_asks_the_guard_about_every_row_and_deletes_none_when_one_is_refused() {
2570 let guard = Arc::new(NameGuard::default());
2571 let service = guarded(guard.clone());
2572 trash_with_a_refused_row(&service).await;
2573
2574 let err = service.empty_trash().await.unwrap_err();
2575 assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2576 assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing deleted");
2577 assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::HardDelete));
2578
2579 let service = svc();
2581 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2582 service.soft_delete(&a).await.unwrap();
2583 assert_eq!(service.empty_trash().await.unwrap(), 1);
2584 assert_eq!(service.count_deleted().await.unwrap(), 0);
2585 }
2586
2587 #[tokio::test]
2590 async fn upsert_refuses_rather_than_creating_a_duplicate() {
2591 use crate::http::CrudService;
2592 type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2593 let service = svc();
2594 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2595 assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2596 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2597 assert!(!<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::supports_upsert());
2598 }
2599
2600 #[tokio::test]
2603 async fn bulk_create_is_one_repository_write_and_names_the_refused_item() {
2604 let service = guarded(Arc::new(NameGuard::default()));
2605 let names = ["a", "forbidden", "c"];
2606 let err = service
2607 .bulk_create(names.iter().map(|n| CreateWidgetDto { name: n.to_string() }).collect())
2608 .await
2609 .unwrap_err();
2610 let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2611 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("1/name", "name_forbidden"));
2612 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2613
2614 let created = service
2615 .bulk_create(vec![CreateWidgetDto { name: "a".into() }, CreateWidgetDto { name: "b".into() }])
2616 .await
2617 .unwrap();
2618 assert_eq!(created.len(), 2);
2619 assert_eq!(
2620 service.repository().bulk_creates.load(std::sync::atomic::Ordering::SeqCst),
2621 1,
2622 "the batch is written by one all-or-nothing repository call"
2623 );
2624 }
2625
2626 #[tokio::test]
2627 async fn bulk_create_refuses_an_oversized_batch() {
2628 let service = svc();
2629 let dtos = (0..=MAX_BATCH_SIZE).map(|n| CreateWidgetDto { name: format!("w{n}") }).collect();
2630 assert!(matches!(service.bulk_create(dtos).await, Err(ServiceError::Validation(_))));
2631 assert_eq!(service.count().await.unwrap(), 0);
2632 }
2633
2634 #[tokio::test]
2637 async fn bulk_updates_write_through_the_changed_columns_path() {
2638 let service = svc();
2639 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2640 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2641 service
2642 .bulk_update(vec![
2643 (a.clone(), UpdateWidgetDto { name: "a2".into() }),
2644 (b.clone(), UpdateWidgetDto { name: "b2".into() }),
2645 ])
2646 .await
2647 .unwrap();
2648 service
2649 .bulk_partial_update(vec![(a.clone(), patch(&[("name", serde_json::json!("a3"))]))])
2650 .await
2651 .unwrap();
2652 let repo = service.repository();
2653 assert_eq!(repo.changed_writes.lock().unwrap().len(), 3);
2654 assert_eq!(repo.full_writes.load(std::sync::atomic::Ordering::SeqCst), 0, "no full-row write");
2655 assert_eq!(service.get_by_id(&a).await.unwrap().unwrap().name, "a3");
2656 }
2657
2658 #[tokio::test]
2661 async fn the_trash_list_applies_filters() {
2662 let service = svc();
2663 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2664 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2665 service.bulk_soft_delete(vec![a, b]).await.unwrap();
2666 let filters = HashMap::from([("name".to_string(), "b".to_string())]);
2667 let (rows, total) = service.list_deleted_filtered(1, 20, filters).await.unwrap();
2668 assert_eq!((rows.len(), total), (1, 1));
2669 assert_eq!(rows[0].name, "b");
2670 }
2671
2672 mod http_surface {
2675 use super::*;
2676 use axum::body::Body;
2677 use axum::http::{Request, StatusCode};
2678 use tower::ServiceExt;
2679
2680 type Svc = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2681
2682 fn app() -> (axum::Router, Arc<Svc>) {
2683 let service = Arc::new(svc());
2684 let router = crate::http::BackboneCrudHandler::<Svc, Widget, CreateWidgetDto, UpdateWidgetDto, Widget>::routes(
2685 service.clone(),
2686 "/widgets",
2687 );
2688 (router, service)
2689 }
2690
2691 async fn call(app: &axum::Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, serde_json::Value) {
2692 let mut req = Request::builder().method(method).uri(uri);
2693 if body.is_some() {
2694 req = req.header("content-type", "application/json");
2695 }
2696 let req = req.body(Body::from(body.unwrap_or("").to_string())).unwrap();
2697 let res = app.clone().oneshot(req).await.unwrap();
2698 let status = res.status();
2699 let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
2700 let json = if bytes.is_empty() {
2701 serde_json::Value::Null
2702 } else {
2703 serde_json::from_slice(&bytes).unwrap_or_else(|_| {
2704 panic!("not a JSON body: {}", String::from_utf8_lossy(&bytes))
2705 })
2706 };
2707 (status, json)
2708 }
2709
2710 #[tokio::test]
2711 async fn a_malformed_path_id_is_a_400_naming_the_id() {
2712 let (app, _) = app();
2713 for (method, uri) in [("GET", "/widgets/not-a-uuid"), ("DELETE", "/widgets/not-a-uuid")] {
2714 let (status, body) = call(&app, method, uri, None).await;
2715 assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {uri}");
2716 assert_eq!(body["violations"][0]["code"], "invalid_id");
2717 assert_eq!(body["violations"][0]["path"], "id");
2718 }
2719 }
2720
2721 #[tokio::test]
2722 async fn a_missing_record_answers_a_coded_404_without_the_type_path() {
2723 let (app, _) = app();
2724 let missing = uuid::Uuid::new_v4();
2725 for (method, uri) in [
2726 ("GET", format!("/widgets/{missing}")),
2727 ("GET", format!("/widgets/{missing}/deleted")),
2728 ("DELETE", format!("/widgets/trash/{missing}")),
2729 ] {
2730 let (status, body) = call(&app, method, &uri, None).await;
2731 assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri}");
2732 assert_eq!(body["violations"][0]["code"], "not_found", "{body}");
2733 assert_eq!(body["error"], crate::http::NOT_FOUND_MESSAGE);
2734 assert!(!body.to_string().contains("::"), "{body}");
2735 }
2736 }
2737
2738 #[tokio::test]
2739 async fn a_list_page_is_clamped_and_page_zero_reads_as_the_first() {
2740 let (app, service) = app();
2741 service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2742 let (status, body) = call(&app, "GET", "/widgets?limit=1000&page=0", None).await;
2743 assert_eq!(status, StatusCode::OK, "{body}");
2744 assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2745 assert_eq!(body["meta"]["page"], 1);
2746 let (status, body) = call(&app, "GET", "/widgets/trash?limit=1000&page=0", None).await;
2747 assert_eq!(status, StatusCode::OK, "{body}");
2748 assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2749 let (status, body) = call(&app, "GET", "/widgets?limit=100&page=1000", None).await;
2751 assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
2752 assert_eq!(body["violations"][0]["code"], "invalid_request");
2753 }
2754
2755 #[tokio::test]
2756 async fn a_refusal_that_names_no_rule_still_carries_a_code() {
2757 let (app, service) = app();
2758 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2759 let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":"taken"}"#)).await;
2761 assert_eq!(status, StatusCode::CONFLICT);
2762 assert_eq!(body["violations"][0]["code"], "conflict", "{body}");
2763 let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":7}"#)).await;
2765 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2766 assert_eq!(body["violations"][0]["path"], "name");
2767 }
2768
2769 #[tokio::test]
2770 async fn a_server_fault_answers_generically_and_keeps_the_detail_out() {
2771 let (app, _) = app();
2772 let (status, body) = call(&app, "GET", &format!("/widgets/{BROKEN_ID}"), None).await;
2773 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
2774 assert_eq!(body["error"], crate::http::INTERNAL_ERROR_MESSAGE);
2775 assert!(!body.to_string().contains("secret_schema"), "{body}");
2776 }
2777
2778 #[tokio::test]
2779 async fn a_duplicate_on_put_and_patch_is_a_409_like_on_create() {
2780 let (app, service) = app();
2781 let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2782 let uri = format!("/widgets/{}", w.id);
2783 let (status, _) = call(&app, "PUT", &uri, Some(r#"{"name":"taken"}"#)).await;
2784 assert_eq!(status, StatusCode::CONFLICT);
2785 let (status, _) = call(&app, "PATCH", &uri, Some(r#"{"name":"taken"}"#)).await;
2786 assert_eq!(status, StatusCode::CONFLICT);
2787 }
2788
2789 #[tokio::test]
2790 async fn an_unknown_key_is_refused_on_create_put_and_bulk() {
2791 let (app, service) = app();
2792 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","colour":"red"}"#)).await;
2793 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2794 assert_eq!(body["violations"][0]["code"], "unknown_field");
2795 assert_eq!(body["violations"][0]["path"], "colour");
2796 assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2797
2798 let (status, _) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","id":"x","metadata":{}}"#)).await;
2800 assert_eq!(status, StatusCode::CREATED);
2801
2802 let w = service.list(1, 1, HashMap::new()).await.unwrap().0.remove(0);
2803 let (status, body) = call(&app, "PUT", &format!("/widgets/{}", w.id), Some(r#"{"name":"b","colour":"red"}"#)).await;
2804 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2805 assert_eq!(body["violations"][0]["path"], "colour");
2806
2807 let (status, body) = call(&app, "POST", "/widgets/bulk", Some(r#"[{"name":"x"},{"name":"y","colour":"red"}]"#)).await;
2808 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2809 assert_eq!(body["violations"][0]["path"], "1/colour");
2810
2811 let bulk = format!(r#"[{{"id":"{}","name":"z","colour":"red"}}]"#, w.id);
2812 let (status, body) = call(&app, "PUT", "/widgets/bulk", Some(&bulk)).await;
2813 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2814 assert_eq!(body["violations"][0]["path"], "0/colour");
2815 assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a", "nothing written");
2816 }
2817
2818 #[tokio::test]
2819 async fn a_body_that_cannot_be_read_answers_in_the_json_envelope() {
2820 let (app, _) = app();
2821 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": "#)).await;
2822 assert_eq!(status, StatusCode::BAD_REQUEST);
2823 assert_eq!(body["success"], false);
2824 assert_eq!(body["violations"][0]["code"], "malformed_body");
2825
2826 let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": 5}"#)).await;
2827 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2828 assert_eq!(body["violations"][0]["path"], "name");
2829 }
2830
2831 #[tokio::test]
2832 async fn the_generic_upsert_route_answers_501() {
2833 let (app, service) = app();
2834 let (status, _) = call(&app, "POST", "/widgets/upsert", Some(r#"{"name":"a"}"#)).await;
2835 assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
2836 assert_eq!(service.count().await.unwrap(), 0);
2837 }
2838
2839 #[tokio::test]
2840 async fn the_trash_route_reads_the_filters() {
2841 let (app, service) = app();
2842 let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2843 let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2844 service.bulk_soft_delete(vec![a, b]).await.unwrap();
2845 let (status, body) = call(&app, "GET", "/widgets/trash?name=b", None).await;
2846 assert_eq!(status, StatusCode::OK);
2847 assert_eq!(body["data"].as_array().unwrap().len(), 1);
2848 assert_eq!(body["data"][0]["name"], "b");
2849 }
2850 }
2851}