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 readiness: crate::testing::ready_receiver(),
502 })
503 }
504
505 fn router_with_storage_and_indexer(
506 storage: Arc<dyn Storage>,
507 kb: KbSlug,
508 max_patchable_size: u64,
509 indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
510 ) -> axum::Router {
511 let mut kbs = BTreeMap::new();
512 kbs.insert(KB.to_string(), kb);
513 crate::router::build_router(crate::state::AppState {
514 storage,
515 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
516 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
517 declared_kbs: Arc::new(kbs),
518 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
519 max_body_size: 16 * 1024 * 1024,
520 max_patchable_size,
521 indexer_tx,
522 searcher: Arc::new(crate::testing::NoopSearcher),
523 events: None,
524 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
525 readiness: crate::testing::ready_receiver(),
526 })
527 }
528
529 async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> String {
530 let response = router
531 .oneshot(
532 Request::builder()
533 .method("PUT")
534 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
535 .header("authorization", format!("Bearer {TOKEN}"))
536 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
537 .body(Body::from(Bytes::from_static(body)))
538 .unwrap(),
539 )
540 .await
541 .unwrap();
542
543 assert_eq!(response.status(), StatusCode::CREATED);
544 response
545 .headers()
546 .get(axum::http::header::ETAG)
547 .unwrap()
548 .to_str()
549 .unwrap()
550 .to_string()
551 }
552
553 async fn get_object(router: axum::Router, path: &str) -> Bytes {
554 let response = router
555 .oneshot(
556 Request::builder()
557 .method("GET")
558 .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
559 .header("authorization", format!("Bearer {TOKEN}"))
560 .body(Body::empty())
561 .unwrap(),
562 )
563 .await
564 .unwrap();
565
566 assert_eq!(response.status(), StatusCode::OK);
567 to_bytes(response.into_body(), usize::MAX).await.unwrap()
568 }
569
570 async fn post_replace(
571 router: axum::Router,
572 path: &str,
573 if_match: &str,
574 body: &'static [u8],
575 ) -> Response {
576 router
577 .oneshot(
578 Request::builder()
579 .method("POST")
580 .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
581 .header("authorization", format!("Bearer {TOKEN}"))
582 .header(axum::http::header::CONTENT_TYPE, "application/json")
583 .header(axum::http::header::IF_MATCH, if_match)
584 .body(Body::from(Bytes::from_static(body)))
585 .unwrap(),
586 )
587 .await
588 .unwrap()
589 }
590
591 async fn object_with_etag(
592 storage: &crate::testing::InMemoryStorage,
593 kb: &KbSlug,
594 path: &str,
595 body: &'static [u8],
596 ) -> String {
597 storage
598 .put_object(
599 kb,
600 &ObjectPath::try_from_str(path).unwrap(),
601 Bytes::from_static(body),
602 Some("text/markdown"),
603 ConditionalHeaders::default(),
604 )
605 .await
606 .unwrap()
607 .etag
608 .unwrap()
609 }
610
611 async fn assert_invalid_request_response(response: Response) {
612 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
613 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
614 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
615 assert_eq!(json["error"], "invalid_request");
616 }
617
618 #[tokio::test]
619 async fn test_unauthorized_status_and_body() {
620 let resp = ApiErrorResponse::unauthorized("req-123".to_string()).into_response();
621 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
622 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
623 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
624 assert_eq!(json["error"], "unauthorized");
625 assert_eq!(json["request_id"], "req-123");
626 }
627
628 #[tokio::test]
629 async fn test_not_found_status() {
630 let err = ApiError::Core(CoreError::NotFound {
631 resource: "foo".into(),
632 });
633 let resp = ApiErrorResponse {
634 error: err,
635 request_id: "rid".into(),
636 }
637 .into_response();
638 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
639 }
640
641 #[tokio::test]
642 async fn test_payload_too_large_status() {
643 let err = ApiError::Core(CoreError::PayloadTooLarge {
644 size: 20_000_000,
645 limit: 16_777_216,
646 });
647 let resp = ApiErrorResponse {
648 error: err,
649 request_id: "rid".into(),
650 }
651 .into_response();
652 assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
653 }
654
655 #[tokio::test]
656 async fn test_request_id_in_body() {
657 let err = ApiError::Core(CoreError::InvalidInput {
658 message: "bad".into(),
659 });
660 let resp = ApiErrorResponse {
661 error: err,
662 request_id: "my-req-id".into(),
663 }
664 .into_response();
665 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
666 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
667 assert_eq!(json["request_id"], "my-req-id");
668 }
669
670 #[tokio::test]
671 async fn test_indexer_backpressure_upsert_503_body_and_retry_after() {
672 let resp = ApiErrorResponse {
673 error: ApiError::IndexerBackpressureUpsert,
674 request_id: "rid".to_string(),
675 }
676 .into_response();
677
678 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
679 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
680 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
681 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
682 assert_eq!(json["error"], "backend_unavailable");
683 assert_eq!(
684 json["message"],
685 "object stored; indexer queue full — retry to re-enqueue"
686 );
687 assert_eq!(json["request_id"], "rid");
688 }
689
690 #[tokio::test]
691 async fn test_indexer_backpressure_tombstone_503_body_and_retry_after() {
692 let resp = ApiErrorResponse {
693 error: ApiError::IndexerBackpressureTombstone,
694 request_id: "rid".to_string(),
695 }
696 .into_response();
697
698 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
699 assert_eq!(resp.headers().get("retry-after").unwrap(), "5");
700 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
701 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
702 assert_eq!(json["error"], "backend_unavailable");
703 assert_eq!(
704 json["message"],
705 "deleted from storage; retry to clear from search index"
706 );
707 assert_eq!(json["request_id"], "rid");
708 }
709
710 #[test]
711 fn test_from_write_error_indexer_backpressure() {
712 assert!(matches!(
713 ApiError::from(WriteError::IndexerBackpressureUpsert),
714 ApiError::IndexerBackpressureUpsert
715 ));
716 assert!(matches!(
717 ApiError::from(WriteError::IndexerBackpressureTombstone),
718 ApiError::IndexerBackpressureTombstone
719 ));
720 }
721
722 #[test]
723 fn test_from_write_error_patch_too_large() {
724 let api_err = ApiError::from(WriteError::PatchTooLarge {
725 size: 200 * 1024 * 1024,
726 limit: 100 * 1024 * 1024,
727 });
728
729 let (status, code) = api_err.status_and_code();
730 assert_eq!(status.as_u16(), 413);
731 assert_eq!(code, "payload_too_large");
732 }
733
734 #[test]
735 fn test_from_write_error_patch_line_out_of_range() {
736 let api_err = ApiError::from(WriteError::PatchLineOutOfRange {
737 first: 999,
738 last: 1000,
739 total_lines: 20,
740 total_bytes: 100,
741 });
742
743 let (status, code) = api_err.status_and_code();
744 assert_eq!(status.as_u16(), 416);
745 assert_eq!(code, "range_not_satisfiable");
746 }
747
748 #[test]
749 fn test_from_write_error_patch_invalid_range() {
750 let api_err = ApiError::from(WriteError::PatchInvalidRange {
751 message: "test".into(),
752 });
753
754 let (status, code) = api_err.status_and_code();
755 assert_eq!(status.as_u16(), 400);
756 assert_eq!(code, "invalid_request");
757 }
758
759 #[test]
761 fn test_from_write_error_invalid_manifest() {
762 let api_err = ApiError::from(WriteError::InvalidManifest {
763 message: "description must be a single line without control characters".into(),
764 });
765
766 let (status, code) = api_err.status_and_code();
767 assert_eq!(status.as_u16(), 400);
768 assert_eq!(code, "invalid_request");
769 assert!(api_err.to_string().contains("description"));
770 }
771
772 #[test]
773 fn test_from_write_error_replace_no_match() {
774 let api_err = ApiError::from(WriteError::ReplaceNoMatch);
775
776 let (status, code) = api_err.status_and_code();
777 assert_eq!(status.as_u16(), 422);
778 assert_eq!(code, "no_match");
779 }
780
781 #[test]
782 fn test_from_write_error_replace_ambiguous() {
783 let api_err = ApiError::from(WriteError::ReplaceAmbiguous { count: 3 });
784
785 let (status, code) = api_err.status_and_code();
786 assert_eq!(status.as_u16(), 422);
787 assert_eq!(code, "ambiguous_match");
788 }
789
790 #[tokio::test]
791 async fn test_ambiguous_match_body_includes_match_count() {
792 let resp = ApiErrorResponse {
793 error: ApiError::ReplaceAmbiguous { count: 3 },
794 request_id: "req-1".into(),
795 }
796 .into_response();
797
798 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
799 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
800 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
801 assert_eq!(json["error"], "ambiguous_match");
802 assert_eq!(json["match_count"], 3);
803 assert_eq!(json["request_id"], "req-1");
804 }
805
806 #[tokio::test]
807 async fn test_no_match_body_omits_match_count() {
808 let resp = ApiErrorResponse {
809 error: ApiError::ReplaceNoMatch,
810 request_id: "req-1".into(),
811 }
812 .into_response();
813
814 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
815 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
816 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
817 assert_eq!(json["error"], "no_match");
818 assert!(json.get("match_count").is_none());
819 }
820
821 #[tokio::test]
822 async fn replace_missing_if_match_returns_400_invalid_request() {
823 let response = router()
824 .oneshot(
825 Request::builder()
826 .method("POST")
827 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
828 .header("authorization", format!("Bearer {TOKEN}"))
829 .header(axum::http::header::CONTENT_TYPE, "application/json")
830 .body(Body::from(Bytes::from_static(
831 br#"{"old_string":"x","new_string":"y"}"#,
832 )))
833 .unwrap(),
834 )
835 .await
836 .unwrap();
837
838 assert_invalid_request_response(response).await;
839 }
840
841 #[tokio::test]
842 async fn replace_if_match_star_returns_400() {
843 let response = router()
844 .oneshot(
845 Request::builder()
846 .method("POST")
847 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
848 .header("authorization", format!("Bearer {TOKEN}"))
849 .header(axum::http::header::CONTENT_TYPE, "application/json")
850 .header(axum::http::header::IF_MATCH, "*")
851 .body(Body::from(Bytes::from_static(
852 br#"{"old_string":"x","new_string":"y"}"#,
853 )))
854 .unwrap(),
855 )
856 .await
857 .unwrap();
858
859 assert_invalid_request_response(response).await;
860 }
861
862 #[tokio::test]
863 async fn replace_multi_value_if_match_returns_400() {
864 let response = router()
865 .oneshot(
866 Request::builder()
867 .method("POST")
868 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
869 .header("authorization", format!("Bearer {TOKEN}"))
870 .header(axum::http::header::CONTENT_TYPE, "application/json")
871 .header(axum::http::header::IF_MATCH, "\"a\",\"b\"")
872 .body(Body::from(Bytes::from_static(
873 br#"{"old_string":"x","new_string":"y"}"#,
874 )))
875 .unwrap(),
876 )
877 .await
878 .unwrap();
879
880 assert_invalid_request_response(response).await;
881 }
882
883 #[tokio::test]
884 async fn replace_malformed_json_body_returns_400() {
885 let response = router()
886 .oneshot(
887 Request::builder()
888 .method("POST")
889 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
890 .header("authorization", format!("Bearer {TOKEN}"))
891 .header(axum::http::header::CONTENT_TYPE, "application/json")
892 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
893 .body(Body::from(Bytes::from_static(b"{invalid json")))
894 .unwrap(),
895 )
896 .await
897 .unwrap();
898
899 assert_invalid_request_response(response).await;
900 }
901
902 #[tokio::test]
903 async fn replace_missing_old_string_field_returns_400() {
904 let response = router()
905 .oneshot(
906 Request::builder()
907 .method("POST")
908 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
909 .header("authorization", format!("Bearer {TOKEN}"))
910 .header(axum::http::header::CONTENT_TYPE, "application/json")
911 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
912 .body(Body::from(Bytes::from_static(br#"{"new_string":"y"}"#)))
913 .unwrap(),
914 )
915 .await
916 .unwrap();
917
918 assert_invalid_request_response(response).await;
919 }
920
921 #[tokio::test]
922 async fn replace_empty_old_string_returns_400() {
923 let response = router()
924 .oneshot(
925 Request::builder()
926 .method("POST")
927 .uri(format!("/api/v1/knowledgebases/{KB}/replace/hello.md"))
928 .header("authorization", format!("Bearer {TOKEN}"))
929 .header(axum::http::header::CONTENT_TYPE, "application/json")
930 .header(axum::http::header::IF_MATCH, "\"valid-etag\"")
931 .body(Body::from(Bytes::from_static(
932 br#"{"old_string":"","new_string":"y"}"#,
933 )))
934 .unwrap(),
935 )
936 .await
937 .unwrap();
938
939 assert_invalid_request_response(response).await;
940 }
941
942 #[tokio::test]
943 async fn replace_single_match_happy_returns_200_with_etag_and_match_count() {
944 let router = router();
945 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
946
947 let response = post_replace(
948 router.clone(),
949 "hello.md",
950 &etag,
951 br#"{"old_string":"world","new_string":"planet"}"#,
952 )
953 .await;
954
955 assert_eq!(response.status(), StatusCode::OK);
956 assert!(response.headers().get(axum::http::header::ETAG).is_some());
957 assert_eq!(
958 response.headers().get("content-location").unwrap(),
959 "/api/v1/knowledgebases/notes/hello.md"
960 );
961 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
962 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
963 assert!(json["etag"].as_str().is_some());
964 assert_eq!(json["match_count"], 1);
965 assert_eq!(json["total_bytes"], 12);
966 assert_eq!(&get_object(router, "hello.md").await[..], b"hello planet");
967 }
968
969 #[tokio::test]
970 async fn replace_no_match_returns_422_no_match() {
971 let router = router();
972 let etag = put_object(router.clone(), "hello.md", b"hello world").await;
973
974 let response = post_replace(
975 router.clone(),
976 "hello.md",
977 &etag,
978 br#"{"old_string":"nonexistent","new_string":"x"}"#,
979 )
980 .await;
981
982 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
983 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
984 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
985 assert_eq!(json["error"], "no_match");
986 assert_eq!(&get_object(router, "hello.md").await[..], b"hello world");
987 }
988
989 #[tokio::test]
990 async fn replace_ambiguous_returns_422_with_match_count() {
991 let router = router();
992 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
993
994 let response = post_replace(
995 router.clone(),
996 "hello.md",
997 &etag,
998 br#"{"old_string":"a","new_string":"Z"}"#,
999 )
1000 .await;
1001
1002 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1003 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1004 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1005 assert_eq!(json["error"], "ambiguous_match");
1006 assert_eq!(json["match_count"], 2);
1007 assert_eq!(&get_object(router, "hello.md").await[..], b"a b a");
1008 }
1009
1010 #[tokio::test]
1011 async fn replace_all_true_multiple_matches_returns_200_with_count_2() {
1012 let router = router();
1013 let etag = put_object(router.clone(), "hello.md", b"a b a").await;
1014
1015 let response = post_replace(
1016 router.clone(),
1017 "hello.md",
1018 &etag,
1019 br#"{"old_string":"a","new_string":"Z","replace_all":true}"#,
1020 )
1021 .await;
1022
1023 assert_eq!(response.status(), StatusCode::OK);
1024 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1025 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1026 assert_eq!(json["match_count"], 2);
1027 assert_eq!(json["total_bytes"], 5);
1028 assert_eq!(&get_object(router, "hello.md").await[..], b"Z b Z");
1029 }
1030
1031 #[tokio::test]
1032 async fn replace_stale_etag_returns_412() {
1033 let router = router();
1034 put_object(router.clone(), "hello.md", b"hello world").await;
1035
1036 let response = post_replace(
1037 router,
1038 "hello.md",
1039 "\"stale\"",
1040 br#"{"old_string":"world","new_string":"planet"}"#,
1041 )
1042 .await;
1043
1044 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1045 }
1046
1047 #[tokio::test]
1048 async fn replace_post_splice_size_over_cap_returns_413() {
1049 let router = router_with_max_patchable_size(20);
1050 let etag = put_object(router.clone(), "hello.md", b"1234567890").await;
1051
1052 let response = post_replace(
1053 router,
1054 "hello.md",
1055 &etag,
1056 br#"{"old_string":"0","new_string":"abcdefghijklmnopqrstuvwxyz"}"#,
1057 )
1058 .await;
1059
1060 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
1061 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1062 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1063 assert_eq!(json["error"], "payload_too_large");
1064 }
1065
1066 #[tokio::test]
1067 async fn replace_indexer_backpressure_returns_503_with_retry_after() {
1068 let kb = KbSlug::try_new(KB).unwrap();
1069 let storage = crate::testing::InMemoryStorage::with_kbs([&kb]);
1070 let etag = object_with_etag(&storage, &kb, "hello.md", b"hello world").await;
1071 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1072 indexer_tx
1073 .try_send(IndexEvent::Upsert {
1074 kb: kb.clone(),
1075 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1076 etag: "queued".to_string(),
1077 mtime: 0,
1078 })
1079 .unwrap();
1080 let router =
1081 router_with_storage_and_indexer(Arc::new(storage), kb, 16 * 1024 * 1024, indexer_tx);
1082
1083 let response = post_replace(
1084 router,
1085 "hello.md",
1086 &etag,
1087 br#"{"old_string":"world","new_string":"planet"}"#,
1088 )
1089 .await;
1090
1091 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1092 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1093 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1094 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1095 assert_eq!(json["error"], "backend_unavailable");
1096 }
1097
1098 #[tokio::test]
1099 async fn test_precondition_failed_body_shape_unchanged() {
1100 let resp = ApiErrorResponse {
1101 error: ApiError::PreconditionFailed,
1102 request_id: "req-1".into(),
1103 }
1104 .into_response();
1105
1106 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1107 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1108 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1109 let object = json.as_object().unwrap();
1110 assert_eq!(object.len(), 3);
1111 assert!(object.contains_key("error"));
1112 assert!(object.contains_key("message"));
1113 assert!(object.contains_key("request_id"));
1114 }
1115
1116 #[tokio::test]
1119 async fn test_precondition_failed_412() {
1120 let resp = ApiError::PreconditionFailed.into_response();
1121 assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
1122 }
1123
1124 #[tokio::test]
1125 async fn test_not_modified_304_empty_body() {
1126 let resp = ApiError::NotModified.into_response();
1127 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1128 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1129 assert!(body.is_empty(), "304 must have an empty body");
1130 }
1131
1132 #[tokio::test]
1133 async fn test_malformed_range_400() {
1134 let resp = ApiError::MalformedRange("bytes=abc".to_string()).into_response();
1135 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1136 }
1137
1138 #[tokio::test]
1140 async fn test_range_not_satisfiable_416_content_range_header() {
1141 let resp = ApiError::RangeNotSatisfiable {
1142 complete_length: 100,
1143 }
1144 .into_response();
1145 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1146 let cr = resp
1147 .headers()
1148 .get("content-range")
1149 .expect("content-range header must be present on 416");
1150 assert_eq!(cr.to_str().unwrap(), "bytes */100");
1151 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1152 assert!(body.is_empty(), "416 body must be empty per RFC 7233 §4.4");
1153 }
1154
1155 #[tokio::test]
1158 async fn test_storage_range_not_satisfiable_416_content_range_header() {
1159 let resp = ApiError::Storage(StorageError::RangeNotSatisfiable {
1160 complete_length: 42,
1161 })
1162 .into_response();
1163 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1164 let cr = resp
1165 .headers()
1166 .get("content-range")
1167 .expect("content-range header must be present on 416");
1168 assert_eq!(cr.to_str().unwrap(), "bytes */42");
1169 }
1170
1171 #[tokio::test]
1173 async fn test_storage_not_modified_304_empty_body() {
1174 let resp = ApiError::Storage(StorageError::NotModified).into_response();
1175 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
1176 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1177 assert!(body.is_empty(), "304 must have an empty body");
1178 }
1179
1180 #[test]
1182 fn test_from_storage_error_not_modified() {
1183 let api_err = ApiError::from(StorageError::NotModified);
1184 assert!(matches!(api_err, ApiError::NotModified));
1185 }
1186
1187 #[test]
1189 fn test_from_storage_error_precondition_failed() {
1190 let api_err = ApiError::from(StorageError::PreconditionFailed);
1191 assert!(matches!(api_err, ApiError::PreconditionFailed));
1192 }
1193
1194 #[test]
1196 fn test_from_storage_error_range_not_satisfiable() {
1197 let api_err = ApiError::from(StorageError::RangeNotSatisfiable {
1198 complete_length: 999,
1199 });
1200 assert!(
1201 matches!(
1202 api_err,
1203 ApiError::RangeNotSatisfiable {
1204 complete_length: 999
1205 }
1206 ),
1207 "expected RangeNotSatisfiable with complete_length=999, got {api_err:?}"
1208 );
1209 }
1210
1211 #[test]
1213 fn test_from_storage_error_other_wrapped() {
1214 let api_err = ApiError::from(StorageError::NotFound {
1215 key: "foo".to_string(),
1216 });
1217 assert!(matches!(api_err, ApiError::Storage(_)));
1218 }
1219
1220 #[test]
1224 fn test_from_core_error_bucket_not_found_is_sanitised() {
1225 let api_err = ApiError::from(CoreError::from(StorageError::BucketNotFound {
1226 bucket: "nt-default-notes".to_string(),
1227 }));
1228 assert!(
1229 matches!(&api_err, ApiError::Core(CoreError::NotFound { resource }) if resource == "knowledge base storage"),
1230 "{api_err:?}"
1231 );
1232 let api_err = ApiError::from(CoreError::from(StorageError::NotFound {
1234 key: "a.md".to_string(),
1235 }));
1236 assert!(matches!(
1237 api_err,
1238 ApiError::Core(CoreError::Storage(StorageError::NotFound { .. }))
1239 ));
1240 }
1241
1242 #[tokio::test]
1246 async fn a_bucket_not_found_reached_through_core_error_names_no_bucket() {
1247 for error in [
1248 ApiError::Core(CoreError::Storage(StorageError::BucketNotFound {
1249 bucket: "nt-acme-notes".to_string(),
1250 })),
1251 ApiError::Storage(StorageError::BucketNotFound {
1252 bucket: "nt-acme-notes".to_string(),
1253 }),
1254 ] {
1255 let resp = ApiErrorResponse {
1256 error,
1257 request_id: "req-1".into(),
1258 }
1259 .into_response();
1260 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1261 let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1262 let text = String::from_utf8(body.to_vec()).unwrap();
1263 let json: serde_json::Value = serde_json::from_str(&text).unwrap();
1264 assert_eq!(json["error"], "not_found");
1265 assert_eq!(json["message"], "not found: knowledge base storage");
1266 assert!(!text.contains("nt-"), "{text}");
1267 assert!(!text.contains("bucket"), "{text}");
1268 }
1269 }
1270
1271 mod line_range_error {
1272 use super::*;
1273 use axum::body::Body;
1274 use axum::http::Request;
1275 use bytes::Bytes;
1276 use notedthat_core::KbSlug;
1277 use std::collections::BTreeMap;
1278 use std::sync::Arc;
1279 use tower::util::ServiceExt;
1280
1281 const KB: &str = "notes";
1282 const TOKEN: &str = "test-token-abc";
1283
1284 fn twenty_line_markdown() -> String {
1285 let mut body = String::new();
1286 for line in 1..=20 {
1287 std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1288 }
1289 body
1290 }
1291
1292 fn router() -> axum::Router {
1293 let kb = KbSlug::try_new(KB).unwrap();
1294 let mut kbs = BTreeMap::new();
1295 kbs.insert(KB.to_string(), kb);
1296 let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1297 tokio::spawn(async move { while rx.recv().await.is_some() {} });
1298
1299 crate::router::build_router(crate::state::AppState {
1300 storage: Arc::new(crate::testing::InMemoryStorage::with_kbs(kbs.values())),
1301 access_policies: Arc::new(notedthat_core::signed_in_policies(&kbs)),
1302 kb_details: Arc::new(notedthat_core::slug_kb_details(&kbs)),
1303 declared_kbs: Arc::new(kbs),
1304 authenticator: Arc::new(notedthat_core::Authenticator::new(TOKEN)),
1305 max_body_size: 16 * 1024 * 1024,
1306 max_patchable_size: 16 * 1024 * 1024,
1307 indexer_tx,
1308 searcher: Arc::new(crate::testing::NoopSearcher),
1309 events: None,
1310 index_health: Arc::new(notedthat_indexer::IndexHealth::new()),
1311 readiness: crate::testing::ready_receiver(),
1312 })
1313 }
1314
1315 async fn put_ranges_md(router: axum::Router) {
1316 let response = router
1317 .oneshot(
1318 Request::builder()
1319 .method("PUT")
1320 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1321 .header("authorization", format!("Bearer {TOKEN}"))
1322 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1323 .body(Body::from(Bytes::from(twenty_line_markdown())))
1324 .unwrap(),
1325 )
1326 .await
1327 .unwrap();
1328
1329 assert_eq!(response.status(), StatusCode::CREATED);
1330 }
1331
1332 #[tokio::test]
1333 async fn malformed_line_range_returns_json_400() {
1334 let response = ApiError::MalformedRange("lines=abc".into()).into_response();
1335
1336 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1337 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1338 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1339 assert_eq!(json["error"], "malformed_range");
1340 }
1341
1342 #[tokio::test]
1343 async fn line_range_not_satisfiable_returns_dual_headers_and_empty_body() {
1344 let response = ApiError::LineRangeNotSatisfiable {
1345 line_total: 20,
1346 byte_total: 100,
1347 }
1348 .into_response();
1349
1350 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1351 assert_eq!(
1352 response.headers().get("content-range").unwrap(),
1353 "lines */20"
1354 );
1355 assert_eq!(
1356 response.headers().get("x-content-range-bytes").unwrap(),
1357 "*/100"
1358 );
1359 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1360 assert!(body.is_empty());
1361 }
1362
1363 #[tokio::test]
1364 async fn out_of_range_line_get_returns_dual_headers_and_empty_body() {
1365 let router = router();
1366 put_ranges_md(router.clone()).await;
1367
1368 let response = router
1369 .oneshot(
1370 Request::builder()
1371 .method("GET")
1372 .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
1373 .header("authorization", format!("Bearer {TOKEN}"))
1374 .header(axum::http::header::RANGE, "lines=100-200")
1375 .body(Body::empty())
1376 .unwrap(),
1377 )
1378 .await
1379 .unwrap();
1380
1381 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1382 assert_eq!(
1383 response.headers().get("content-range").unwrap(),
1384 "lines */20"
1385 );
1386 assert_eq!(
1387 response.headers().get("x-content-range-bytes").unwrap(),
1388 "*/160"
1389 );
1390 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1391 assert!(body.is_empty());
1392 }
1393
1394 #[tokio::test]
1395 async fn byte_range_not_satisfiable_omits_line_byte_header() {
1396 let response = ApiError::RangeNotSatisfiable {
1397 complete_length: 100,
1398 }
1399 .into_response();
1400
1401 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1402 assert_eq!(
1403 response.headers().get("content-range").unwrap(),
1404 "bytes */100"
1405 );
1406 assert!(response.headers().get("x-content-range-bytes").is_none());
1407 }
1408
1409 #[test]
1410 fn patch_line_out_of_range_maps_to_line_range_not_satisfiable() {
1411 let error = ApiError::from(WriteError::PatchLineOutOfRange {
1412 first: 100,
1413 last: 200,
1414 total_lines: 20,
1415 total_bytes: 100,
1416 });
1417
1418 assert!(matches!(
1419 error,
1420 ApiError::LineRangeNotSatisfiable {
1421 line_total: 20,
1422 byte_total: 100
1423 }
1424 ));
1425 }
1426 }
1427}