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