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