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