Skip to main content

notedthat_api_http/
error.rs

1//! HTTP API error types and JSON envelope.
2
3use 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/// HTTP-layer error — maps domain and storage errors to HTTP status codes.
11#[derive(Debug, thiserror::Error)]
12pub enum ApiError {
13    /// The request lacked valid Bearer credentials.
14    #[error("unauthorized")]
15    Unauthorized,
16    /// Indexer queue was full while enqueueing an upsert (PUT/COPY).
17    #[error("indexer upsert backpressure")]
18    IndexerBackpressureUpsert,
19    /// Indexer queue was full while enqueueing a tombstone (DELETE).
20    #[error("indexer tombstone backpressure")]
21    IndexerBackpressureTombstone,
22    /// A domain error from `notedthat-core`.
23    #[error(transparent)]
24    Core(#[from] CoreError),
25    /// A storage-layer error not otherwise promoted to a top-level variant.
26    ///
27    /// Note: `StorageError::NotModified`, `StorageError::PreconditionFailed`, and
28    /// `StorageError::RangeNotSatisfiable` are promoted via the manual `From<StorageError>`
29    /// impl to top-level `ApiError` variants so that `IntoResponse` can emit the
30    /// RFC-required headers (e.g. `Content-Range: bytes */N` for 416).
31    #[error(transparent)]
32    Storage(StorageError),
33    /// A conditional PUT/DELETE failed because a precondition was not met.
34    ///
35    /// Maps to HTTP 412 Precondition Failed.
36    #[error("precondition failed")]
37    PreconditionFailed,
38    /// The requested byte range could not be satisfied.
39    ///
40    /// `complete_length` is the total object size in bytes.  The `IntoResponse`
41    /// impl emits `Content-Range: bytes */complete_length` per RFC 7233 §4.4.
42    #[error("range not satisfiable")]
43    RangeNotSatisfiable {
44        /// Total object size in bytes.
45        complete_length: u64,
46    },
47    /// The backend returned 304 Not Modified for a conditional GET/HEAD.
48    ///
49    /// Maps to HTTP 304 with no body, as required by RFC 7232 §4.1.
50    #[error("not modified")]
51    NotModified,
52    /// The `Range:` header value could not be parsed per RFC 7233.
53    ///
54    /// Maps to HTTP 400 Bad Request.
55    #[error("malformed range: {0}")]
56    MalformedRange(String),
57    /// A `Range: lines=…` header specified a range that exceeds the object's line count.
58    ///
59    /// Maps to HTTP 416 with `Content-Range: lines */<line_total>` and
60    /// `X-Content-Range-Bytes: */<byte_total>` per the line-range extension.
61    #[error("line range not satisfiable")]
62    LineRangeNotSatisfiable {
63        /// Total line count in the object.
64        line_total: u64,
65        /// Total byte count in the object.
66        byte_total: u64,
67    },
68    /// A single-replace operation found no occurrence of `old_string`.
69    #[error("no match found for old_string")]
70    ReplaceNoMatch,
71    /// A single-replace operation found multiple occurrences of `old_string`.
72    #[error("multiple matches found ({count}); use replace_all to replace them all")]
73    ReplaceAmbiguous {
74        /// Number of occurrences found.
75        count: u64,
76    },
77}
78
79/// Promote specific `StorageError` variants to top-level `ApiError` variants so
80/// that `IntoResponse` can emit the RFC-mandated response headers.
81impl 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/// JSON error response body shape: `{ "error": "code", "message": "...", "request_id": "..." }`.
129#[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
144/// An [`ApiError`] paired with a `request_id` string so the JSON body and the
145/// `x-request-id` response header both contain the same value.
146pub struct ApiErrorResponse {
147    /// The underlying error.
148    pub error: ApiError,
149    /// The request ID (from the `x-request-id` header via `tower-http`).
150    pub request_id: String,
151}
152
153impl ApiErrorResponse {
154    /// Build an unauthorized response with the provided request ID.
155    #[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    /// Extract the `complete_length` for a 416 response, regardless of which
221    /// wrapper the `RangeNotSatisfiable` error arrived in.
222    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    /// Return `true` for all variants that map to HTTP 304 (empty body required).
234    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        // Line-mode 416: emit Content-Range: lines */<total> + X-Content-Range-Bytes: */<total_bytes>.
247        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        // 416 Range Not Satisfiable: RFC 7233 §4.4 requires
266        // `Content-Range: bytes */N` and an empty body.
267        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        // 304 Not Modified: RFC 7232 §4.1 forbids a message body.
277        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        // All other variants return a JSON error body.
329        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!("/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!("/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!("/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!("/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!("/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!("/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!("/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!("/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!("/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            "/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    // ─── New variant tests ────────────────────────────────────────────────────
984
985    #[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    /// RFC 7233 §4.4: a 416 response MUST include `Content-Range: bytes */N`.
1006    #[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    /// Same 416 check via the `ApiError::Storage(...)` wrapper (e.g. from router.rs
1023    /// call sites that use explicit wrapping instead of `Into::into`).
1024    #[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    /// 304 from a wrapped `StorageError::NotModified` (explicit wrapping in router.rs).
1039    #[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    /// `From<StorageError>` promotes `NotModified` to `ApiError::NotModified`.
1048    #[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    /// `From<StorageError>` promotes `PreconditionFailed` to `ApiError::PreconditionFailed`.
1055    #[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    /// `From<StorageError>` promotes `RangeNotSatisfiable` with the correct length.
1062    #[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    /// Other `StorageError` variants must still be wrapped in `ApiError::Storage`.
1079    #[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!("/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!("/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}