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