1use axum::Json;
4use axum::http::header::{CONTENT_RANGE, RETRY_AFTER};
5use axum::http::{HeaderName, StatusCode};
6use axum::response::{IntoResponse, Response};
7use notedthat_core::{Error as CoreError, StorageError};
8use serde::Serialize;
9
10#[derive(Debug, thiserror::Error)]
12pub enum ApiError {
13 #[error("unauthorized")]
15 Unauthorized,
16 #[error("forbidden")]
23 Forbidden,
24 #[error("indexer upsert backpressure")]
26 IndexerBackpressureUpsert,
27 #[error("indexer tombstone backpressure")]
29 IndexerBackpressureTombstone,
30 #[error("change event not published after the object was {after}")]
34 EventPublishFailed {
35 after: notedthat_write::WriteEffect,
37 },
38 #[error("events after {requested} are no longer retained; oldest retained is {oldest}")]
40 EventsGone {
41 requested: notedthat_core::EventId,
43 oldest: notedthat_core::EventId,
45 },
46 #[error("event backend unavailable: {message}")]
48 EventsUnavailable {
49 message: String,
51 },
52 #[error(transparent)]
59 Core(CoreError),
60 #[error(transparent)]
67 Storage(StorageError),
68 #[error("precondition failed")]
72 PreconditionFailed,
73 #[error("range not satisfiable")]
78 RangeNotSatisfiable {
79 complete_length: u64,
81 },
82 #[error("not modified")]
86 NotModified,
87 #[error("malformed range: {0}")]
91 MalformedRange(String),
92 #[error("line range not satisfiable")]
97 LineRangeNotSatisfiable {
98 line_total: u64,
100 byte_total: u64,
102 },
103 #[error("no match found for old_string")]
105 ReplaceNoMatch,
106 #[error("multiple matches found ({count}); use replace_all to replace them all")]
108 ReplaceAmbiguous {
109 count: u64,
111 },
112}
113
114impl From<CoreError> for ApiError {
117 fn from(e: CoreError) -> Self {
118 match e {
119 CoreError::Storage(StorageError::BucketNotFound { bucket }) => {
120 Self::bucket_not_found(&bucket)
121 }
122 other => Self::Core(other),
123 }
124 }
125}
126
127impl From<StorageError> for ApiError {
128 fn from(e: StorageError) -> Self {
129 match e {
130 StorageError::NotModified => Self::NotModified,
131 StorageError::PreconditionFailed => Self::PreconditionFailed,
132 StorageError::RangeNotSatisfiable { complete_length } => {
133 Self::RangeNotSatisfiable { complete_length }
134 }
135 StorageError::BucketNotFound { bucket } => Self::bucket_not_found(&bucket),
136 other => Self::Storage(other),
137 }
138 }
139}
140
141impl ApiError {
142 pub(crate) fn bucket_not_found(bucket: &str) -> Self {
152 tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
153 Self::Core(CoreError::NotFound {
154 resource: "knowledge base storage".to_string(),
155 })
156 }
157
158 fn message(&self) -> String {
165 match self {
166 Self::Core(CoreError::Storage(StorageError::BucketNotFound { bucket }))
167 | Self::Storage(StorageError::BucketNotFound { bucket }) => {
168 tracing::warn!(bucket = %bucket, "BUCKET_NOT_FOUND: knowledge base storage is missing");
169 "not found: knowledge base storage".to_string()
170 }
171 other => other.to_string(),
172 }
173 }
174}
175
176impl From<notedthat_write::WriteError> for ApiError {
177 fn from(e: notedthat_write::WriteError) -> Self {
178 match e {
179 notedthat_write::WriteError::Storage(e) => Self::from(e),
180 notedthat_write::WriteError::TooLarge { size, limit }
181 | notedthat_write::WriteError::PatchTooLarge { size, limit } => {
182 Self::Core(CoreError::PayloadTooLarge { size, limit })
183 }
184 notedthat_write::WriteError::Path(e) => Self::Core(e),
185 notedthat_write::WriteError::IndexerBackpressureUpsert => {
186 Self::IndexerBackpressureUpsert
187 }
188 notedthat_write::WriteError::IndexerBackpressureTombstone => {
189 Self::IndexerBackpressureTombstone
190 }
191 notedthat_write::WriteError::EventPublishFailed { after } => {
192 Self::EventPublishFailed { after }
193 }
194 notedthat_write::WriteError::PatchLineOutOfRange {
195 total_lines,
196 total_bytes,
197 ..
198 } => Self::LineRangeNotSatisfiable {
199 line_total: total_lines,
200 byte_total: total_bytes,
201 },
202 notedthat_write::WriteError::PatchInvalidRange { message }
203 | notedthat_write::WriteError::InvalidManifest { message } => {
204 Self::Core(CoreError::InvalidInput { message })
205 }
206 notedthat_write::WriteError::ReplaceNoMatch => Self::ReplaceNoMatch,
207 notedthat_write::WriteError::ReplaceAmbiguous { count } => {
208 Self::ReplaceAmbiguous { count }
209 }
210 }
211 }
212}
213
214#[derive(Serialize)]
216struct ErrorBody<'a> {
217 error: &'a str,
218 message: String,
219 request_id: String,
220}
221
222#[derive(Serialize)]
223struct ReplaceAmbiguousBody<'a> {
224 error: &'a str,
225 message: String,
226 request_id: String,
227 match_count: u64,
228}
229
230pub struct ApiErrorResponse {
233 pub error: ApiError,
235 pub request_id: String,
237}
238
239impl ApiErrorResponse {
240 #[must_use]
242 pub fn unauthorized(request_id: String) -> Self {
243 Self {
244 error: ApiError::Unauthorized,
245 request_id,
246 }
247 }
248}
249
250impl ApiError {
251 fn status_and_code(&self) -> (StatusCode, &'static str) {
252 match self {
253 Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
254 Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
255 Self::IndexerBackpressureUpsert
256 | Self::IndexerBackpressureTombstone
257 | Self::EventPublishFailed { .. }
258 | Self::EventsUnavailable { .. } => {
259 (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
260 }
261 Self::EventsGone { .. } => (StatusCode::GONE, "gone"),
262 Self::Core(CoreError::InvalidInput { .. }) => {
263 (StatusCode::BAD_REQUEST, "invalid_request")
264 }
265 Self::Core(CoreError::NotFound { .. }) => (StatusCode::NOT_FOUND, "not_found"),
266 Self::Core(CoreError::PayloadTooLarge { .. }) => {
267 (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large")
268 }
269 Self::Core(CoreError::MalformedRange(_)) | Self::MalformedRange(_) => {
270 (StatusCode::BAD_REQUEST, "malformed_range")
271 }
272 Self::LineRangeNotSatisfiable { .. }
273 | Self::Core(CoreError::RangeNotSatisfiable { .. })
274 | Self::RangeNotSatisfiable { .. } => {
275 (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
276 }
277 Self::Core(CoreError::NotModified) | Self::NotModified => {
278 (StatusCode::NOT_MODIFIED, "not_modified")
279 }
280 Self::Core(CoreError::PreconditionFailed) | Self::PreconditionFailed => {
281 (StatusCode::PRECONDITION_FAILED, "precondition_failed")
282 }
283 Self::ReplaceNoMatch => (StatusCode::UNPROCESSABLE_ENTITY, "no_match"),
284 Self::ReplaceAmbiguous { .. } => (StatusCode::UNPROCESSABLE_ENTITY, "ambiguous_match"),
285 Self::Core(CoreError::BucketNameTooLong { .. } | CoreError::Config { .. }) => {
286 (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
287 }
288 Self::Core(CoreError::Storage(e)) | Self::Storage(e) => Self::storage_status(e),
289 }
290 }
291
292 fn storage_status(e: &StorageError) -> (StatusCode, &'static str) {
293 match e {
294 StorageError::NotFound { .. } | StorageError::BucketNotFound { .. } => {
295 (StatusCode::NOT_FOUND, "not_found")
296 }
297 StorageError::BackendUnavailable { .. } => {
298 (StatusCode::SERVICE_UNAVAILABLE, "backend_unavailable")
299 }
300 StorageError::NotModified => (StatusCode::NOT_MODIFIED, "not_modified"),
301 StorageError::PreconditionFailed => {
302 (StatusCode::PRECONDITION_FAILED, "precondition_failed")
303 }
304 StorageError::RangeNotSatisfiable { .. } => {
305 (StatusCode::RANGE_NOT_SATISFIABLE, "range_not_satisfiable")
306 }
307 StorageError::Other { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
308 }
309 }
310
311 fn range_not_satisfiable_length(&self) -> Option<u64> {
314 match self {
315 Self::RangeNotSatisfiable { complete_length }
316 | Self::Storage(StorageError::RangeNotSatisfiable { complete_length })
317 | Self::Core(CoreError::RangeNotSatisfiable { complete_length }) => {
318 Some(*complete_length)
319 }
320 _ => None,
321 }
322 }
323
324 fn is_not_modified(&self) -> bool {
326 matches!(
327 self,
328 Self::NotModified
329 | Self::Storage(StorageError::NotModified)
330 | Self::Core(CoreError::NotModified)
331 )
332 }
333}
334
335impl ApiErrorResponse {
336 fn retry_later(request_id: String, message: String) -> Response {
339 let body = ErrorBody {
340 error: "backend_unavailable",
341 message,
342 request_id,
343 };
344 (
345 StatusCode::SERVICE_UNAVAILABLE,
346 [(RETRY_AFTER, "5")],
347 Json(body),
348 )
349 .into_response()
350 }
351}
352
353impl IntoResponse for ApiErrorResponse {
354 fn into_response(self) -> Response {
355 if let ApiError::LineRangeNotSatisfiable {
357 line_total,
358 byte_total,
359 } = &self.error
360 {
361 return (
362 StatusCode::RANGE_NOT_SATISFIABLE,
363 [
364 (CONTENT_RANGE, format!("lines */{line_total}")),
365 (
366 HeaderName::from_static("x-content-range-bytes"),
367 format!("*/{byte_total}"),
368 ),
369 ],
370 )
371 .into_response();
372 }
373
374 if let Some(complete_length) = self.error.range_not_satisfiable_length() {
377 let content_range = format!("bytes */{complete_length}");
378 return (
379 StatusCode::RANGE_NOT_SATISFIABLE,
380 [(CONTENT_RANGE, content_range)],
381 )
382 .into_response();
383 }
384
385 if self.error.is_not_modified() {
387 return StatusCode::NOT_MODIFIED.into_response();
388 }
389
390 match &self.error {
391 ApiError::Unauthorized => {
392 let body = ErrorBody {
393 error: "unauthorized",
394 message: "provide a valid Bearer token in the Authorization header".to_string(),
395 request_id: self.request_id,
396 };
397 return (StatusCode::UNAUTHORIZED, Json(body)).into_response();
398 }
399 ApiError::IndexerBackpressureUpsert => {
400 return Self::retry_later(
401 self.request_id,
402 "object stored; indexer queue full — retry to re-enqueue".to_string(),
403 );
404 }
405 ApiError::IndexerBackpressureTombstone => {
406 return Self::retry_later(
407 self.request_id,
408 "deleted from storage; retry to clear from search index".to_string(),
409 );
410 }
411 ApiError::EventPublishFailed { after } => {
412 let message = match after {
413 notedthat_write::WriteEffect::Stored => {
414 "object stored; change event not published — retry to publish"
415 }
416 notedthat_write::WriteEffect::Deleted => {
417 "deleted from storage; change event not published — retry to publish"
418 }
419 };
420 return Self::retry_later(self.request_id, message.to_string());
421 }
422 ApiError::EventsUnavailable { message } => {
423 return Self::retry_later(
424 self.request_id,
425 format!("event backend unavailable: {message}"),
426 );
427 }
428 ApiError::ReplaceAmbiguous { count } => {
429 let body = ReplaceAmbiguousBody {
430 error: "ambiguous_match",
431 message: self.error.to_string(),
432 request_id: self.request_id,
433 match_count: *count,
434 };
435 return (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response();
436 }
437 _ => {}
438 }
439
440 let (status, code) = self.error.status_and_code();
442 let message = self.error.message();
443 let body = ErrorBody {
444 error: code,
445 message,
446 request_id: self.request_id,
447 };
448 (status, Json(body)).into_response()
449 }
450}
451
452impl IntoResponse for ApiError {
453 fn into_response(self) -> Response {
454 ApiErrorResponse {
455 error: self,
456 request_id: "unknown".to_string(),
457 }
458 .into_response()
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use axum::body::{Body, to_bytes};
466 use axum::http::Request;
467 use bytes::Bytes;
468 use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
469 use notedthat_indexer::IndexEvent;
470 use notedthat_write::WriteError;
471 use std::collections::BTreeMap;
472 use std::sync::Arc;
473 use tower::util::ServiceExt;
474
475 const KB: &str = "notes";
476 const TOKEN: &str = "test-token-abc";
477
478 fn router() -> axum::Router {
479 router_with_max_patchable_size(16 * 1024 * 1024)
480 }
481
482 fn router_with_max_patchable_size(max_patchable_size: u64) -> axum::Router {
483 let kb = KbSlug::try_new(KB).unwrap();
484 let mut kbs = BTreeMap::new();
485 kbs.insert(KB.to_string(), kb);
486 let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
487 tokio::spawn(async move { while rx.recv().await.is_some() {} });
488
489 crate::router::build_router(crate::state::AppState {
490 storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
491 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
492 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
493 declared_kbs: Arc::new(kbs),
494 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
495 max_body_size: 16 * 1024 * 1024,
496 max_patchable_size,
497 indexer_tx,
498 searcher: Arc::new(crate::testing::NoopSearcher),
499 events: None,
500 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
501 })
502 }
503
504 fn router_with_storage_and_indexer(
505 storage: Arc<dyn Storage>,
506 kb: KbSlug,
507 max_patchable_size: u64,
508 indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
509 ) -> axum::Router {
510 let mut kbs = BTreeMap::new();
511 kbs.insert(KB.to_string(), kb);
512 crate::router::build_router(crate::state::AppState {
513 storage,
514 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
515 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
516 declared_kbs: Arc::new(kbs),
517 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
518 max_body_size: 16 * 1024 * 1024,
519 max_patchable_size,
520 indexer_tx,
521 searcher: Arc::new(crate::testing::NoopSearcher),
522 events: None,
523 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
524 })
525 }
526
527 async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
528 let response = router
529 .oneshot(
530 Request::builder()
531 .method("PUT")
532 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
533 .header("authorization", format!("Bearer {TOKEN}"))
534 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
535 .body(Body::from(Bytes::from_static(body)))
536 .unwrap(),
537 )
538 .await
539 .unwrap();
540
541 assert_eq!(response.status(), StatusCode::CREATED);
542 response
543 .headers()
544 .get(axum::http::header::ETAG)
545 .unwrap()
546 .to_str()
547 .unwrap()
548 .to_string()
549 }
550
551 async fn get_object(router: axum::Router, path: &str) -> Bytes {
552 let response = router
553 .oneshot(
554 Request::builder()
555 .method("GET")
556 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
557 .header("authorization", format!("Bearer {TOKEN}"))
558 .body(Body::empty())
559 .unwrap(),
560 )
561 .await
562 .unwrap();
563
564 assert_eq!(response.status(), StatusCode::OK);
565 to_bytes(response.into_body(), usize::MAX).await.unwrap()
566 }
567
568 async fn post_replace(
569 router: axum::Router,
570 path: &str,
571 if_match: &str,
572 body: &'static [u8],
573 ) -> Response {
574 router
575 .oneshot(
576 Request::builder()
577 .method("POST")
578 .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
579 .header("authorization", format!("Bearer {TOKEN}"))
580 .header(axum::http::header::CONTENT_TYPE, "application/json")
581 .header(axum::http::header::IF_MATCH, if_match)
582 .body(Body::from(Bytes::from_static(body)))
583 .unwrap(),
584 )
585 .await
586 .unwrap()
587 }
588
589 async fn object_with_etag(
590 storage: &crate::testing::InMemoryStorage,
591 kb: &KbSlug,
592 path: &str,
593 body: &'static [u8],
594 ) -> String {
595 storage
596 .put_object(
597 kb,
598 &ObjectPath::try_from_str(path).unwrap(),
599 Bytes::from_static(body),
600 Some("text/markdown"),
601 ConditionalHeaders::default(),
602 )
603 .await
604 .unwrap()
605 .etag
606 .unwrap()
607 }
608
609 async fn assert_invalid_request_response(response: Response) {
610 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
611 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
612 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
613 assert_eq!(json["error"], "invalid_request");
614 }
615
616 #[tokio::test]
617 async fn test_unauthorized_status_and_body() {
618 let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
619 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
620 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
621 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
622 assert_eq!(json["error"], "unauthorized");
623 assert_eq!(json["request_id"], "req-123");
624 }
625
626 #[tokio::test]
627 async fn test_not_found_status() {
628 let err = ApiError::Core(CoreError::NotFound {
629 resource: "foo".into(),
630 });
631 let resp = ApiErrorResponse {
632 error: err,
633 request_id: "rid".into(),
634 }
635 .into_response();
636 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
637 }
638
639 #[tokio::test]
640 async fn test_payload_too_large_status() {
641 let err = ApiError::Core(CoreError::PayloadTooLarge {
642 size: 20_000_000,
643 limit: 16_777_216,
644 });
645 let resp = ApiErrorResponse {
646 error: err,
647 request_id: "rid".into(),
648 }
649 .into_response();
650 assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
651 }
652
653 #[tokio::test]
654 async fn test_request_id_in_body() {
655 let err = ApiError::Core(CoreError::InvalidInput {
656 message: "bad".into(),
657 });
658 let resp = ApiErrorResponse {
659 error: err,
660 request_id: "my-req-id".into(),
661 }
662 .into_response();
663 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
664 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
665 assert_eq!(json["request_id"], "my-req-id");
666 }
667
668 #[tokio::test]
669 async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
670 let resp = ApiErrorResponse {
671 error: ApiError::IndexerBackpressureUpsert,
672 request_id: "rid".to_string(),
673 }
674 .into_response();
675
676 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
677 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
678 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
679 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
680 assert_eq!(json["error"], "backend_unavailable");
681 assert_eq!(
682 json["message"],
683 "object stored; indexer queue full — retry to re-enqueue"
684 );
685 assert_eq!(json["request_id"], "rid");
686 }
687
688 #[tokio::test]
689 async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
690 let resp = ApiErrorResponse {
691 error: ApiError::IndexerBackpressureTombstone,
692 request_id: "rid".to_string(),
693 }
694 .into_response();
695
696 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
697 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
698 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
699 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
700 assert_eq!(json["error"], "backend_unavailable");
701 assert_eq!(
702 json["message"],
703 "deleted from storage; retry to clear from search index"
704 );
705 assert_eq!(json["request_id"], "rid");
706 }
707
708 #[test]
709 fn test_from_write_error_indexer_backpressure() {
710 assert!(matches!(
711 ApiError::from(WriteError::IndexerBackpressureUpsert),
712 ApiError::IndexerBackpressureUpsert
713 ));
714 assert!(matches!(
715 ApiError::from(WriteError::IndexerBackpressureTombstone),
716 ApiError::IndexerBackpressureTombstone
717 ));
718 }
719
720 #[test]
721 fn test_from_write_error_patch_too_large() {
722 let api_err = ApiError::from(WriteError::PatchTooLarge {
723 size: 200 * 1024 * 1024,
724 limit: 100 * 1024 * 1024,
725 });
726
727 let (status, code) = api_err.status_and_code();
728 assert_eq!(status.as_u16(), 413);
729 assert_eq!(code, "payload_too_large");
730 }
731
732 #[test]
733 fn test_from_write_error_patch_line_out_of_range() {
734 let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
735 first: 999,
736 last: 1000,
737 total_lines: 20,
738 total_bytes: 100,
739 });
740
741 let (status, code) = api_err.status_and_code();
742 assert_eq!(status.as_u16(), 416);
743 assert_eq!(code, "range_not_satisfiable");
744 }
745
746 #[test]
747 fn test_from_write_error_patch_invalid_range() {
748 let api_err = ApiError::from(WriteError::PatchInvalidRange {
749 message: "test".into(),
750 });
751
752 let (status, code) = api_err.status_and_code();
753 assert_eq!(status.as_u16(), 400);
754 assert_eq!(code, "invalid_request");
755 }
756
757 #[test]
759 fn test_from_write_error_invalid_manifest() {
760 let api_err = ApiError::from(WriteError::InvalidManifest {
761 message: "description must be a single line without control characters".into(),
762 });
763
764 let (status, code) = api_err.status_and_code();
765 assert_eq!(status.as_u16(), 400);
766 assert_eq!(code, "invalid_request");
767 assert!(api_err.to_string().contains("description"));
768 }
769
770 #[test]
771 fn test_from_write_error_replace_no_match() {
772 let api_err = ApiError::from(WriteError::ReplaceNoMatch);
773
774 let (status, code) = api_err.status_and_code();
775 assert_eq!(status.as_u16(), 422);
776 assert_eq!(code, "no_match");
777 }
778
779 #[test]
780 fn test_from_write_error_replace_ambiguous() {
781 let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
782
783 let (status, code) = api_err.status_and_code();
784 assert_eq!(status.as_u16(), 422);
785 assert_eq!(code, "ambiguous_match");
786 }
787
788 #[tokio::test]
789 async fn test_ambiguous_match_body_includes_match_count() {
790 let resp = ApiErrorResponse {
791 error: ApiError::ReplaceAmbiguous { count: 3 },
792 request_id: "req-1".into(),
793 }
794 .into_response();
795
796 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
797 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
798 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
799 assert_eq!(json["error"], "ambiguous_match");
800 assert_eq!(json["match_count"], 3);
801 assert_eq!(json["request_id"], "req-1");
802 }
803
804 #[tokio::test]
805 async fn test_no_match_body_omits_match_count() {
806 let resp = ApiErrorResponse {
807 error: ApiError::ReplaceNoMatch,
808 request_id: "req-1".into(),
809 }
810 .into_response();
811
812 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
813 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
814 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
815 assert_eq!(json["error"], "no_match");
816 assert!(json.get("match_count").is_none());
817 }
818
819 #[tokio::test]
820 async fn replace_missing_if_match_returns_400_invalid_request() {
821 let response = router()
822 .oneshot(
823 Request::builder()
824 .method("POST")
825 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
826 .header("authorization", format!("Bearer {TOKEN}"))
827 .header(axum::http::header::CONTENT_TYPE, "application/json")
828 .body(Body::from(Bytes::from_static(
829 br#"{"old_string":"x","new_string":"y"}"#,
830 )))
831 .unwrap(),
832 )
833 .await
834 .unwrap();
835
836 assert_invalid_request_response(response).await;
837 }
838
839 #[tokio::test]
840 async fn replace_if_match_star_returns_400() {
841 let response = router()
842 .oneshot(
843 Request::builder()
844 .method("POST")
845 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
846 .header("authorization", format!("Bearer {TOKEN}"))
847 .header(axum::http::header::CONTENT_TYPE, "application/json")
848 .header(axum::http::header::IF_MATCH, "*")
849 .body(Body::from(Bytes::from_static(
850 br#"{"old_string":"x","new_string":"y"}"#,
851 )))
852 .unwrap(),
853 )
854 .await
855 .unwrap();
856
857 assert_invalid_request_response(response).await;
858 }
859
860 #[tokio::test]
861 async fn replace_multi_value_if_match_returns_400() {
862 let response = router()
863 .oneshot(
864 Request::builder()
865 .method("POST")
866 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
867 .header("authorization", format!("Bearer {TOKEN}"))
868 .header(axum::http::header::CONTENT_TYPE, "application/json")
869 .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
870 .body(Body::from(Bytes::from_static(
871 br#"{"old_string":"x","new_string":"y"}"#,
872 )))
873 .unwrap(),
874 )
875 .await
876 .unwrap();
877
878 assert_invalid_request_response(response).await;
879 }
880
881 #[tokio::test]
882 async fn replace_malformed_json_body_returns_400() {
883 let response = router()
884 .oneshot(
885 Request::builder()
886 .method("POST")
887 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
888 .header("authorization", format!("Bearer {TOKEN}"))
889 .header(axum::http::header::CONTENT_TYPE, "application/json")
890 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
891 .body(Body::from(Bytes::from_static(b"{invalid json")))
892 .unwrap(),
893 )
894 .await
895 .unwrap();
896
897 assert_invalid_request_response(response).await;
898 }
899
900 #[tokio::test]
901 async fn replace_missing_old_string_field_returns_400() {
902 let response = router()
903 .oneshot(
904 Request::builder()
905 .method("POST")
906 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
907 .header("authorization", format!("Bearer {TOKEN}"))
908 .header(axum::http::header::CONTENT_TYPE, "application/json")
909 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
910 .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
911 .unwrap(),
912 )
913 .await
914 .unwrap();
915
916 assert_invalid_request_response(response).await;
917 }
918
919 #[tokio::test]
920 async fn replace_empty_old_string_returns_400() {
921 let response = router()
922 .oneshot(
923 Request::builder()
924 .method("POST")
925 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
926 .header("authorization", format!("Bearer {TOKEN}"))
927 .header(axum::http::header::CONTENT_TYPE, "application/json")
928 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
929 .body(Body::from(Bytes::from_static(
930 br#"{"old_string":"","new_string":"y"}"#,
931 )))
932 .unwrap(),
933 )
934 .await
935 .unwrap();
936
937 assert_invalid_request_response(response).await;
938 }
939
940 #[tokio::test]
941 async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
942 let router = router();
943 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
944
945 let response = post_replace(
946 router.clone(),
947 "hello.md",
948 &etag,
949 br#"{"old_string":"world","new_string":"planet"}"#,
950 )
951 .await;
952
953 assert_eq!(response.status(), StatusCode::OK);
954 assert!(response.headers().get(axum::http::header::ETAG).is_some());
955 assert_eq!(
956 response.headers().get("content-location").unwrap(),
957 "/api/v1/knowledgebases/notes/hello.md"
958 );
959 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
960 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
961 assert!(json["etag"].as_str().is_some());
962 assert_eq!(json["match_count"], 1);
963 assert_eq!(json["total_bytes"], 12);
964 assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
965 }
966
967 #[tokio::test]
968 async fn replace_no_match_returns_422_no_match() {
969 let router = router();
970 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
971
972 let response = post_replace(
973 router.clone(),
974 "hello.md",
975 &etag,
976 br#"{"old_string":"nonexistent","new_string":"x"}"#,
977 )
978 .await;
979
980 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
981 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
982 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
983 assert_eq!(json["error"], "no_match");
984 assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
985 }
986
987 #[tokio::test]
988 async fn replace_ambiguous_returns_422_with_match_count() {
989 let router = router();
990 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
991
992 let response = post_replace(
993 router.clone(),
994 "hello.md",
995 &etag,
996 br#"{"old_string":"a","new_string":"Z"}"#,
997 )
998 .await;
999
1000 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1001 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1002 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1003 assert_eq!(json["error"], "ambiguous_match");
1004 assert_eq!(json["match_count"], 2);
1005 assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1006 }
1007
1008 #[tokio::test]
1009 async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1010 let router = router();
1011 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1012
1013 let response = post_replace(
1014 router.clone(),
1015 "hello.md",
1016 &etag,
1017 br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1018 )
1019 .await;
1020
1021 assert_eq!(response.status(), StatusCode::OK);
1022 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1023 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1024 assert_eq!(json["match_count"], 2);
1025 assert_eq!(json["total_bytes"], 5);
1026 assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1027 }
1028
1029 #[tokio::test]
1030 async fn replace_stale_etag_returns_412() {
1031 let router = router();
1032 put_object(router.clone(), "hello.md", b"hello world").await;
1033
1034 let response = post_replace(
1035 router,
1036 "hello.md",
1037 "\"stale\"",
1038 br#"{"old_string":"world","new_string":"planet"}"#,
1039 )
1040 .await;
1041
1042 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1043 }
1044
1045 #[tokio::test]
1046 async fn replace_post_splice_size_over_cap_returns_413() {
1047 let router = router_with_max_patchable_size(20);
1048 let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1049
1050 let response = post_replace(
1051 router,
1052 "hello.md",
1053 &etag,
1054 br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1055 )
1056 .await;
1057
1058 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1059 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1060 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1061 assert_eq!(json["error"], "payload_too_large");
1062 }
1063
1064 #[tokio::test]
1065 async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1066 let kb = KbSlug::try_new(KB).unwrap();
1067 let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1068 let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1069 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1070 indexer_tx
1071 .try_send(IndexEvent::Upsert {
1072 kb: kb.clone(),
1073 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1074 etag: "queued".to_string(),
1075 mtime: 0,
1076 })
1077 .unwrap();
1078 let router =
1079 router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1080
1081 let response = post_replace(
1082 router,
1083 "hello.md",
1084 &etag,
1085 br#"{"old_string":"world","new_string":"planet"}"#,
1086 )
1087 .await;
1088
1089 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1090 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1091 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1092 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1093 assert_eq!(json["error"], "backend_unavailable");
1094 }
1095
1096 #[tokio::test]
1097 async fn test_precondition_failed_body_shape_unchanged() {
1098 let resp = ApiErrorResponse {
1099 error: ApiError::PreconditionFailed,
1100 request_id: "req-1".into(),
1101 }
1102 .into_response();
1103
1104 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1105 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1106 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1107 let object = json.as_object().unwrap();
1108 assert_eq!(object.len(), 3);
1109 assert!(object.contains_key("error"));
1110 assert!(object.contains_key("message"));
1111 assert!(object.contains_key("request_id"));
1112 }
1113
1114 #[tokio::test]
1117 async fn test_precondition_failed_412() {
1118 let resp = ApiError::PreconditionFailed.into_response();
1119 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1120 }
1121
1122 #[tokio::test]
1123 async fn test_not_modified_304_empty_body() {
1124 let resp = ApiError::NotModified.into_response();
1125 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1126 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1127 assert!(body.is_empty(), "304 must have an empty body");
1128 }
1129
1130 #[tokio::test]
1131 async fn test_malformed_range_400() {
1132 let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1133 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1134 }
1135
1136 #[tokio::test]
1138 async fn test_range_not_satisfiable_416_content_range_header() {
1139 let resp = ApiError::RangeNotSatisfiable {
1140 complete_length: 100,
1141 }
1142 .into_response();
1143 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1144 let cr = resp
1145 .headers()
1146 .get("content-range")
1147 .expect("content-range header must be present on 416");
1148 assert_eq!(cr.to_str().unwrap(), "bytes */100");
1149 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1150 assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1151 }
1152
1153 #[tokio::test]
1156 async fn test_storage_range_not_satisfiable_416_content_range_header() {
1157 let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1158 complete_length: 42,
1159 })
1160 .into_response();
1161 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1162 let cr = resp
1163 .headers()
1164 .get("content-range")
1165 .expect("content-range header must be present on 416");
1166 assert_eq!(cr.to_str().unwrap(), "bytes */42");
1167 }
1168
1169 #[tokio::test]
1171 async fn test_storage_not_modified_304_empty_body() {
1172 let resp = ApiError::Storage(StorageError::NotModified).into_response();
1173 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1174 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1175 assert!(body.is_empty(), "304 must have an empty body");
1176 }
1177
1178 #[test]
1180 fn test_from_storage_error_not_modified() {
1181 let api_err = ApiError::from(StorageError::NotModified);
1182 assert!(matches!(api_err, ApiError::NotModified));
1183 }
1184
1185 #[test]
1187 fn test_from_storage_error_precondition_failed() {
1188 let api_err = ApiError::from(StorageError::PreconditionFailed);
1189 assert!(matches!(api_err, ApiError::PreconditionFailed));
1190 }
1191
1192 #[test]
1194 fn test_from_storage_error_range_not_satisfiable() {
1195 let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1196 complete_length: 999,
1197 });
1198 assert!(
1199 matches!(
1200 api_err,
1201 ApiError::RangeNotSatisfiable {
1202 complete_length: 999
1203 }
1204 ),
1205 "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1206 );
1207 }
1208
1209 #[test]
1211 fn test_from_storage_error_other_wrapped() {
1212 let api_err = ApiError::from(StorageError::NotFound {
1213 key: "foo".to_string(),
1214 });
1215 assert!(matches!(api_err, ApiError::Storage(_)));
1216 }
1217
1218 #[test]
1222 fn test_from_core_error_bucket_not_found_is_sanitised() {
1223 let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1224 bucket: "nt-default-notes".to_string(),
1225 }));
1226 assert!(
1227 matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1228 "{api_err:?}"
1229 );
1230 let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1232 key: "a.md".to_string(),
1233 }));
1234 assert!(matches!(
1235 api_err,
1236 ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1237 ));
1238 }
1239
1240 #[tokio::test]
1244 async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1245 for error in [
1246 ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1247 bucket: "nt-acme-notes".to_string(),
1248 })),
1249 ApiError::Storage(StorageError::BucketNotFound {
1250 bucket: "nt-acme-notes".to_string(),
1251 }),
1252 ] {
1253 let resp = ApiErrorResponse {
1254 error,
1255 request_id: "req-1".into(),
1256 }
1257 .into_response();
1258 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1259 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1260 let text = String::from_utf8(body.to_vec()).unwrap();
1261 let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1262 assert_eq!(json["error"], "not_found");
1263 assert_eq!(json["message"], "not found: knowledge base storage");
1264 assert!(!text.contains("nt-"), "{text}");
1265 assert!(!text.contains("bucket"), "{text}");
1266 }
1267 }
1268
1269 mod line_range_error {
1270 use super::*;
1271 use axum::body::Body;
1272 use axum::http::Request;
1273 use bytes::Bytes;
1274 use notedthat_core::KbSlug;
1275 use std::collections::BTreeMap;
1276 use std::sync::Arc;
1277 use tower::util::ServiceExt;
1278
1279 const KB: &str = "notes";
1280 const TOKEN: &str = "test-token-abc";
1281
1282 fn twenty_line_markdown() -> String {
1283 let mut body = String::new();
1284 for line in 1..=20 {
1285 std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1286 }
1287 body
1288 }
1289
1290 fn router() -> axum::Router {
1291 let kb = KbSlug::try_new(KB).unwrap();
1292 let mut kbs = BTreeMap::new();
1293 kbs.insert(KB.to_string(), kb);
1294 let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1295 tokio::spawn(async move { while rx.recv().await.is_some() {} });
1296
1297 crate::router::build_router(crate::state::AppState {
1298 storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1299 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1300 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1301 declared_kbs: Arc::new(kbs),
1302 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1303 max_body_size: 16 * 1024 * 1024,
1304 max_patchable_size: 16 * 1024 * 1024,
1305 indexer_tx,
1306 searcher: Arc::new(crate::testing::NoopSearcher),
1307 events: None,
1308 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1309 })
1310 }
1311
1312 async fn put_ranges_md(router: axum::Router) {
1313 let response = router
1314 .oneshot(
1315 Request::builder()
1316 .method("PUT")
1317 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1318 .header("authorization", format!("Bearer {TOKEN}"))
1319 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1320 .body(Body::from(Bytes::from(twenty_line_markdown())))
1321 .unwrap(),
1322 )
1323 .await
1324 .unwrap();
1325
1326 assert_eq!(response.status(), StatusCode::CREATED);
1327 }
1328
1329 #[tokio::test]
1330 async fn malformed_line_range_returns_json_400() {
1331 let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1332
1333 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1334 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1335 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1336 assert_eq!(json["error"], "malformed_range");
1337 }
1338
1339 #[tokio::test]
1340 async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1341 let response = ApiError::LineRangeNotSatisfiable {
1342 line_total: 20,
1343 byte_total: 100,
1344 }
1345 .into_response();
1346
1347 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1348 assert_eq!(
1349 response.headers().get("content-range").unwrap(),
1350 "lines */20"
1351 );
1352 assert_eq!(
1353 response.headers().get("x-content-range-bytes").unwrap(),
1354 "*/100"
1355 );
1356 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1357 assert!(body.is_empty());
1358 }
1359
1360 #[tokio::test]
1361 async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1362 let router = router();
1363 put_ranges_md(router.clone()).await;
1364
1365 let response = router
1366 .oneshot(
1367 Request::builder()
1368 .method("GET")
1369 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1370 .header("authorization", format!("Bearer {TOKEN}"))
1371 .header(axum::http::header::RANGE, "lines=100-200")
1372 .body(Body::empty())
1373 .unwrap(),
1374 )
1375 .await
1376 .unwrap();
1377
1378 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1379 assert_eq!(
1380 response.headers().get("content-range").unwrap(),
1381 "lines */20"
1382 );
1383 assert_eq!(
1384 response.headers().get("x-content-range-bytes").unwrap(),
1385 "*/160"
1386 );
1387 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1388 assert!(body.is_empty());
1389 }
1390
1391 #[tokio::test]
1392 async fn byte_range_not_satisfiable_omits_line_byte_header() {
1393 let response = ApiError::RangeNotSatisfiable {
1394 complete_length: 100,
1395 }
1396 .into_response();
1397
1398 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1399 assert_eq!(
1400 response.headers().get("content-range").unwrap(),
1401 "bytes */100"
1402 );
1403 assert!(response.headers().get("x-content-range-bytes").is_none());
1404 }
1405
1406 #[test]
1407 fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1408 let error = ApiError::from(WriteError::PatchLineOutOfRange {
1409 first: 100,
1410 last: 200,
1411 total_lines: 20,
1412 total_bytes: 100,
1413 });
1414
1415 assert!(matches!(
1416 error,
1417 ApiError::LineRangeNotSatisfiable {
1418 line_total: 20,
1419 byte_total: 100
1420 }
1421 ));
1422 }
1423 }
1424}