Skip to main content

notedthat_api_http/router/
mod.rs

1//! Axum router builder and HTTP handlers for the `NotedThat` API.
2
3mod health;
4mod helpers;
5mod kbs;
6mod llms;
7mod objects;
8
9use crate::middleware::auth_middleware;
10use crate::state::AppState;
11use axum::extract::{DefaultBodyLimit, Request};
12use axum::handler::Handler;
13use axum::http::{HeaderName, StatusCode};
14use axum::middleware::from_fn_with_state;
15use axum::response::{IntoResponse, Response};
16use axum::routing::{any, get};
17use axum::{Json, Router};
18use tower::ServiceBuilder;
19use tower_http::request_id::{
20    MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer,
21};
22use tower_http::trace::TraceLayer;
23use uuid::Uuid;
24
25// Re-export helpers used by sibling modules in this crate.
26pub(crate) use helpers::lookup_kb;
27
28use health::{healthz, readyz};
29use kbs::{list_kbs, list_objects};
30use llms::llms_txt;
31use objects::{delete_object, get_object, head_object, patch_object, post_object, put_object};
32
33/// The API route table, declared once in two forms.
34///
35/// `ROUTE_*` is the path relative to [`API_V1_PREFIX`], which is what
36/// `build_router` registers under `nest`. `MATCHED_*` is the absolute path,
37/// which is what axum reports as `MatchedPath` and what the public-read match in
38/// [`crate::middleware`] compares against. Both come from one suffix literal, so
39/// the mount point and each route are each written exactly once.
40///
41/// The routes stay nested rather than registered absolutely and merged: a
42/// `.layer()` on an absolutely-routed sub-router also wraps its fallback, and
43/// merging that fallback answers every unrouted path — `/v1/...` included — with
44/// the API's 401 instead of a 404.
45macro_rules! api_routes {
46    ($($route:ident / $matched:ident => $suffix:literal,)+) => {
47        $(
48            pub(crate) const $route: &str = $suffix;
49            pub(crate) const $matched: &str = concat!("/api/v1", $suffix);
50        )+
51    };
52}
53
54/// Mount point of the versioned machine API on the unified listener (D44).
55pub const API_V1_PREFIX: &str = "/api/v1";
56
57/// Mount point reserved for the future browse surface (D44, #100).
58pub const BROWSE_PREFIX: &str = "/browse";
59
60api_routes! {
61    ROUTE_KBS / MATCHED_KBS => "/knowledgebases",
62    ROUTE_KB / MATCHED_KB => "/knowledgebases/{kb_slug}",
63    ROUTE_KB_SEARCH / MATCHED_KB_SEARCH => "/knowledgebases/{kb_slug}/search",
64    ROUTE_KB_OBJECT / MATCHED_KB_OBJECT => "/knowledgebases/{kb_slug}/{*object_path}",
65}
66
67/// Maximum body size for PUT requests: 16 MiB (D35).
68pub const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
69
70/// A [`MakeRequestId`] implementation that generates `UUIDv7` request IDs.
71#[derive(Clone, Copy, Default)]
72pub struct MakeRequestUuidV7;
73
74impl MakeRequestId for MakeRequestUuidV7 {
75    fn make_request_id<B>(&mut self, _req: &Request<B>) -> Option<RequestId> {
76        let id = Uuid::now_v7().to_string();
77        let hv = id.parse().ok()?;
78        Some(RequestId::new(hv))
79    }
80}
81
82/// Build the complete axum [`Router`] with all routes and middleware.
83pub fn build_router(state: AppState) -> Router {
84    let request_id_header = HeaderName::from_static("x-request-id");
85    let api_routes = Router::new()
86        .route(ROUTE_KBS, get(list_kbs))
87        .route(ROUTE_KB, get(list_objects))
88        .route(
89            ROUTE_KB_SEARCH,
90            axum::routing::post(crate::search_route::search_kb).layer(
91                axum::extract::DefaultBodyLimit::max(crate::search_route::SEARCH_BODY_MAX_BYTES),
92            ),
93        )
94        .route(
95            ROUTE_KB_OBJECT,
96            get(get_object)
97                .head(head_object)
98                .put(put_object)
99                .delete(delete_object)
100                .patch(patch_object.layer(DefaultBodyLimit::disable()))
101                .post(post_object.layer(DefaultBodyLimit::disable())),
102        )
103        .layer(
104            ServiceBuilder::new()
105                .layer(DefaultBodyLimit::max(helpers::body_limit_usize(
106                    MAX_BODY_BYTES,
107                )))
108                .layer(from_fn_with_state(state.clone(), auth_middleware)),
109        )
110        .with_state(state);
111
112    // Request-id generation and tracing wrap every surface this router serves,
113    // including the unauthenticated root routes. Only `auth_middleware` and the
114    // API body limit stay nested on `/api/v1`.
115    Router::new()
116        .route("/healthz", get(healthz))
117        .route("/readyz", get(readyz))
118        .route("/llms.txt", get(llms_txt))
119        .route(BROWSE_PREFIX, any(browse_not_implemented))
120        .route(&format!("{BROWSE_PREFIX}/"), any(browse_not_implemented))
121        .route(
122            &format!("{BROWSE_PREFIX}/{{*path}}"),
123            any(browse_not_implemented),
124        )
125        .nest(API_V1_PREFIX, api_routes)
126        .layer(
127            ServiceBuilder::new()
128                .layer(SetRequestIdLayer::new(
129                    request_id_header.clone(),
130                    MakeRequestUuidV7,
131                ))
132                .layer(PropagateRequestIdLayer::new(request_id_header))
133                .layer(TraceLayer::new_for_http()),
134        )
135}
136
137/// `/browse` is reserved for the future browse surface (D44, #100). Answering
138/// `501 Not Implemented` makes the reservation observable; a bare 404 is
139/// indistinguishable from a mistyped path.
140async fn browse_not_implemented(request: Request) -> Response {
141    let request_id = request
142        .extensions()
143        .get::<RequestId>()
144        .and_then(|id| id.header_value().to_str().ok())
145        .unwrap_or_default()
146        .to_string();
147
148    (
149        StatusCode::NOT_IMPLEMENTED,
150        Json(serde_json::json!({
151            "error": "not_implemented",
152            "message": "The browse surface is reserved and not implemented",
153            "request_id": request_id,
154        })),
155    )
156        .into_response()
157}
158
159#[cfg(test)]
160mod route_constants {
161    use super::{
162        API_V1_PREFIX, MATCHED_KB, MATCHED_KB_OBJECT, MATCHED_KB_SEARCH, MATCHED_KBS, ROUTE_KB,
163        ROUTE_KB_OBJECT, ROUTE_KB_SEARCH, ROUTE_KBS,
164    };
165
166    /// The router registers the relative form and the middleware matches the
167    /// absolute one. If the two stop agreeing, public-read authorization
168    /// silently stops matching the routes it is meant to guard.
169    #[test]
170    fn matched_paths_are_the_nested_routes_under_the_mount_point() {
171        assert_eq!(API_V1_PREFIX, "/api/v1");
172        for (route, matched) in [
173            (ROUTE_KBS, MATCHED_KBS),
174            (ROUTE_KB, MATCHED_KB),
175            (ROUTE_KB_SEARCH, MATCHED_KB_SEARCH),
176            (ROUTE_KB_OBJECT, MATCHED_KB_OBJECT),
177        ] {
178            assert_eq!(matched, format!("{API_V1_PREFIX}{route}"));
179        }
180    }
181}
182
183#[cfg(test)]
184mod patch_route {
185    use super::*;
186    use async_trait::async_trait;
187    use axum::body::{Body, to_bytes};
188    use axum::http::StatusCode;
189    use axum::response::Response;
190    use bytes::Bytes;
191    use notedthat_core::{
192        ByteRange, ConditionalHeaders, KbManifest, KbSlug, ListResponse, ObjectMeta, ObjectPath,
193        ObjectRead, PutOutcome, Storage, StorageError,
194    };
195    use notedthat_indexer::IndexEvent;
196    use std::collections::BTreeMap;
197    use std::sync::Arc;
198    use tower::util::ServiceExt;
199
200    const KB: &str = "notes";
201    const OBJECT_PATH: &str = "patch.md";
202    const TOKEN: &str = "test-token-abc";
203
204    async fn router_with_object(
205        initial_body: &'static [u8],
206        max_patchable_size: u64,
207    ) -> (axum::Router, String) {
208        let kb = KbSlug::try_new(KB).unwrap();
209        let object_path = ObjectPath::try_from_str(OBJECT_PATH).unwrap();
210        let storage = Arc::new(crate::testing::InMemoryStorage::default());
211        let outcome = storage
212            .put_object(
213                &kb,
214                &object_path,
215                Bytes::from_static(initial_body),
216                Some("text/markdown"),
217                ConditionalHeaders::default(),
218            )
219            .await
220            .unwrap();
221
222        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(16);
223        let mut kbs = BTreeMap::new();
224        kbs.insert(KB.to_string(), kb);
225        let router = build_router(AppState {
226            storage,
227            declared_kbs: Arc::new(kbs),
228            public_read_policies: Arc::new(BTreeMap::new()),
229            bearer_token: Arc::new(TOKEN.to_string()),
230            max_body_size: MAX_BODY_BYTES,
231            max_patchable_size,
232            indexer_tx,
233            searcher: Arc::new(crate::testing::NoopSearcher),
234        });
235
236        (router, outcome.etag.unwrap())
237    }
238
239    async fn object_with_etag(
240        storage: &crate::testing::InMemoryStorage,
241        kb: &KbSlug,
242        body: &'static [u8],
243    ) -> String {
244        storage
245            .put_object(
246                kb,
247                &ObjectPath::try_from_str(OBJECT_PATH).unwrap(),
248                Bytes::from_static(body),
249                Some("text/markdown"),
250                ConditionalHeaders::default(),
251            )
252            .await
253            .unwrap()
254            .etag
255            .unwrap()
256    }
257
258    fn router_with_storage(
259        storage: Arc<dyn Storage>,
260        kb: KbSlug,
261        max_patchable_size: u64,
262        indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
263    ) -> axum::Router {
264        let mut kbs = BTreeMap::new();
265        kbs.insert(KB.to_string(), kb);
266        build_router(AppState {
267            storage,
268            declared_kbs: Arc::new(kbs),
269            public_read_policies: Arc::new(BTreeMap::new()),
270            bearer_token: Arc::new(TOKEN.to_string()),
271            max_body_size: MAX_BODY_BYTES,
272            max_patchable_size,
273            indexer_tx,
274            searcher: Arc::new(crate::testing::NoopSearcher),
275        })
276    }
277
278    async fn patch_request(
279        router: axum::Router,
280        header_name: &'static str,
281        header_value: &str,
282        if_match: Option<&str>,
283        body: Bytes,
284    ) -> Response {
285        let mut builder = Request::builder()
286            .method("PATCH")
287            .uri(format!("/api/v1/knowledgebases/{KB}/{OBJECT_PATH}"))
288            .header("authorization", format!("Bearer {TOKEN}"))
289            .header(header_name, header_value);
290        if let Some(etag) = if_match {
291            builder = builder.header(axum::http::header::IF_MATCH, etag);
292        }
293
294        router
295            .oneshot(builder.body(Body::from(body)).unwrap())
296            .await
297            .unwrap()
298    }
299
300    async fn assert_error_code(response: Response, expected_status: StatusCode, expected: &str) {
301        assert_eq!(response.status(), expected_status);
302        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
303        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
304        assert_eq!(json["error"], expected);
305    }
306
307    #[tokio::test]
308    async fn bytes_content_range_returns_ok_with_etag_and_location() {
309        let (router, etag) = router_with_object(b"0123456789abcdefghij", MAX_BODY_BYTES).await;
310
311        let response = patch_request(
312            router,
313            "content-range",
314            "bytes 0-9/*",
315            Some(&etag),
316            Bytes::from_static(b"ABCDEFGHIJ"),
317        )
318        .await;
319
320        assert_eq!(response.status(), StatusCode::OK);
321        assert!(response.headers().get(axum::http::header::ETAG).is_some());
322        assert_eq!(
323            response
324                .headers()
325                .get(axum::http::header::LOCATION)
326                .unwrap(),
327            &format!("/api/v1/knowledgebases/{KB}/{OBJECT_PATH}")
328        );
329        assert!(
330            response
331                .headers()
332                .get(axum::http::header::CONTENT_RANGE)
333                .is_none()
334        );
335        assert!(response.headers().get("nt-patch-mode").is_none());
336    }
337
338    #[tokio::test]
339    async fn lines_content_range_returns_ok() {
340        let (router, etag) = router_with_object(b"one\ntwo\nthree\nfour\n", MAX_BODY_BYTES).await;
341
342        let response = patch_request(
343            router,
344            "content-range",
345            "lines 2-3/*",
346            Some(&etag),
347            Bytes::from_static(b"TWO\nTHREE\n"),
348        )
349        .await;
350
351        assert_eq!(response.status(), StatusCode::OK);
352    }
353
354    #[tokio::test]
355    async fn append_mode_without_if_match_returns_ok() {
356        let (router, _etag) = router_with_object(b"one\n", MAX_BODY_BYTES).await;
357
358        let response = patch_request(
359            router,
360            "nt-patch-mode",
361            "append",
362            None,
363            Bytes::from_static(b"two\n"),
364        )
365        .await;
366
367        assert_eq!(response.status(), StatusCode::OK);
368    }
369
370    #[tokio::test]
371    async fn append_mode_with_if_match_returns_ok() {
372        let (router, etag) = router_with_object(b"one\n", MAX_BODY_BYTES).await;
373
374        let response = patch_request(
375            router,
376            "nt-patch-mode",
377            "append",
378            Some(&etag),
379            Bytes::from_static(b"two\n"),
380        )
381        .await;
382
383        assert_eq!(response.status(), StatusCode::OK);
384    }
385
386    #[tokio::test]
387    async fn bytes_content_range_without_if_match_returns_invalid_request() {
388        let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
389
390        let response = patch_request(
391            router,
392            "content-range",
393            "bytes 0-1/*",
394            None,
395            Bytes::from_static(b"AB"),
396        )
397        .await;
398
399        assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
400    }
401
402    #[tokio::test]
403    async fn if_match_star_returns_invalid_request() {
404        let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
405
406        let response = patch_request(
407            router,
408            "content-range",
409            "bytes 0-1/*",
410            Some("*"),
411            Bytes::from_static(b"AB"),
412        )
413        .await;
414
415        assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
416    }
417
418    #[tokio::test]
419    async fn multi_value_if_match_returns_invalid_request() {
420        let (router, etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
421
422        let response = patch_request(
423            router,
424            "content-range",
425            "bytes 0-1/*",
426            Some(&format!("{etag}, \"other\"")),
427            Bytes::from_static(b"AB"),
428        )
429        .await;
430
431        assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
432    }
433
434    #[tokio::test]
435    async fn nonexistent_object_returns_not_found() {
436        let (router, etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
437
438        let response = router
439            .oneshot(
440                Request::builder()
441                    .method("PATCH")
442                    .uri(format!("/api/v1/knowledgebases/{KB}/missing.md"))
443                    .header("authorization", format!("Bearer {TOKEN}"))
444                    .header("content-range", "bytes 0-1/*")
445                    .header(axum::http::header::IF_MATCH, etag)
446                    .body(Body::from(Bytes::from_static(b"AB")))
447                    .unwrap(),
448            )
449            .await
450            .unwrap();
451
452        assert_error_code(response, StatusCode::NOT_FOUND, "not_found").await;
453    }
454
455    #[tokio::test]
456    async fn body_larger_than_max_patchable_size_returns_payload_too_large() {
457        let (router, _etag) = router_with_object(b"one\n", 4).await;
458
459        let response = patch_request(
460            router,
461            "nt-patch-mode",
462            "append",
463            None,
464            Bytes::from_static(b"abcde"),
465        )
466        .await;
467
468        assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
469    }
470
471    mod errors {
472        use super::*;
473
474        #[derive(Clone)]
475        struct PutPreconditionFailedStorage {
476            inner: crate::testing::InMemoryStorage,
477        }
478
479        #[async_trait]
480        impl Storage for PutPreconditionFailedStorage {
481            async fn ensure_bucket(&self, kb: &KbSlug) -> Result<(), StorageError> {
482                self.inner.ensure_bucket(kb).await
483            }
484
485            async fn read_manifest(&self, kb: &KbSlug) -> Result<KbManifest, StorageError> {
486                self.inner.read_manifest(kb).await
487            }
488
489            async fn write_manifest(
490                &self,
491                kb: &KbSlug,
492                manifest: &KbManifest,
493            ) -> Result<(), StorageError> {
494                self.inner.write_manifest(kb, manifest).await
495            }
496
497            async fn head_object(
498                &self,
499                kb: &KbSlug,
500                path: &ObjectPath,
501                conditionals: ConditionalHeaders,
502            ) -> Result<ObjectMeta, StorageError> {
503                self.inner.head_object(kb, path, conditionals).await
504            }
505
506            async fn get_object(
507                &self,
508                kb: &KbSlug,
509                path: &ObjectPath,
510                range: Option<Vec<ByteRange>>,
511                conditionals: ConditionalHeaders,
512            ) -> Result<ObjectRead, StorageError> {
513                self.inner.get_object(kb, path, range, conditionals).await
514            }
515
516            async fn get_object_stream(
517                &self,
518                kb: &KbSlug,
519                path: &ObjectPath,
520                range: Option<Vec<ByteRange>>,
521                conditionals: ConditionalHeaders,
522            ) -> Result<notedthat_core::ObjectStream, StorageError> {
523                self.inner
524                    .get_object_stream(kb, path, range, conditionals)
525                    .await
526            }
527
528            async fn put_object(
529                &self,
530                _kb: &KbSlug,
531                _path: &ObjectPath,
532                _bytes: Bytes,
533                _content_type: Option<&str>,
534                _conditionals: ConditionalHeaders,
535            ) -> Result<PutOutcome, StorageError> {
536                Err(StorageError::PreconditionFailed)
537            }
538
539            async fn put_staged_object(
540                &self,
541                _kb: &KbSlug,
542                _path: &ObjectPath,
543                _body: notedthat_core::StagedBody,
544                _content_type: Option<&str>,
545                _conditionals: ConditionalHeaders,
546            ) -> Result<PutOutcome, StorageError> {
547                Err(StorageError::PreconditionFailed)
548            }
549
550            async fn copy_object(
551                &self,
552                kb: &KbSlug,
553                source: &ObjectPath,
554                destination: &ObjectPath,
555                options: notedthat_core::CopyObjectOptions,
556            ) -> Result<PutOutcome, StorageError> {
557                self.inner
558                    .copy_object(kb, source, destination, options)
559                    .await
560            }
561
562            async fn delete_object(
563                &self,
564                kb: &KbSlug,
565                path: &ObjectPath,
566                conditionals: ConditionalHeaders,
567            ) -> Result<(), StorageError> {
568                self.inner.delete_object(kb, path, conditionals).await
569            }
570
571            async fn list_objects(
572                &self,
573                kb: &KbSlug,
574                prefix: Option<&str>,
575                limit: u32,
576                cursor: Option<&str>,
577            ) -> Result<ListResponse, StorageError> {
578                self.inner.list_objects(kb, prefix, limit, cursor).await
579            }
580        }
581
582        #[tokio::test]
583        async fn missing_if_match_for_bytes_mode_returns_invalid_request() {
584            let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
585
586            let response = patch_request(
587                router,
588                "content-range",
589                "bytes 0-9/*",
590                None,
591                Bytes::from_static(b"ABCDEFGHIJ"),
592            )
593            .await;
594
595            assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
596        }
597
598        #[tokio::test]
599        async fn body_larger_than_max_patchable_size_returns_payload_too_large() {
600            let (router, _etag) = router_with_object(b"one\n", 10).await;
601
602            let response = patch_request(
603                router,
604                "nt-patch-mode",
605                "append",
606                None,
607                Bytes::from_static(b"more than ten bytes"),
608            )
609            .await;
610
611            assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
612        }
613
614        #[tokio::test]
615        async fn pre_splice_object_larger_than_max_patchable_size_returns_payload_too_large() {
616            let (router, _etag) = router_with_object(b"already too large", 10).await;
617
618            let response = patch_request(
619                router,
620                "nt-patch-mode",
621                "append",
622                None,
623                Bytes::from_static(b"!"),
624            )
625            .await;
626
627            assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
628        }
629
630        #[tokio::test]
631        async fn post_splice_body_larger_than_max_patchable_size_returns_payload_too_large() {
632            let (router, _etag) = router_with_object(b"123456", 10).await;
633
634            let response = patch_request(
635                router,
636                "nt-patch-mode",
637                "append",
638                None,
639                Bytes::from_static(b"78901"),
640            )
641            .await;
642
643            assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
644        }
645
646        #[tokio::test]
647        async fn line_range_out_of_bounds_returns_dual_416_headers_and_empty_body() {
648            let (router, etag) =
649                router_with_object(b"one\ntwo\nthree\nfour\nfive\n", MAX_BODY_BYTES).await;
650
651            let response = patch_request(
652                router,
653                "content-range",
654                "lines 100-200/*",
655                Some(&etag),
656                Bytes::from_static(b"replacement\n"),
657            )
658            .await;
659
660            assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
661            assert_eq!(
662                response.headers().get("content-range").unwrap(),
663                "lines */5"
664            );
665            assert_eq!(
666                response.headers().get("x-content-range-bytes").unwrap(),
667                "*/24"
668            );
669            let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
670            assert!(body.is_empty());
671        }
672
673        #[tokio::test]
674        async fn if_match_mismatch_after_retries_returns_precondition_failed_without_content_range()
675        {
676            let kb = KbSlug::try_new(KB).unwrap();
677            let inner = crate::testing::InMemoryStorage::default();
678            let etag = object_with_etag(&inner, &kb, b"0123456789").await;
679            let (indexer_tx, _rx) = tokio::sync::mpsc::channel(16);
680            let router = router_with_storage(
681                Arc::new(PutPreconditionFailedStorage { inner }),
682                kb,
683                MAX_BODY_BYTES,
684                indexer_tx,
685            );
686
687            let response = patch_request(
688                router,
689                "content-range",
690                "bytes 0-1/*",
691                Some(&etag),
692                Bytes::from_static(b"AB"),
693            )
694            .await;
695
696            assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
697            assert!(
698                response
699                    .headers()
700                    .get(axum::http::header::CONTENT_RANGE)
701                    .is_none()
702            );
703            let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
704            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
705            assert_eq!(json["error"], "precondition_failed");
706        }
707
708        #[tokio::test]
709        async fn indexer_queue_full_returns_backend_unavailable_with_retry_after() {
710            let kb = KbSlug::try_new(KB).unwrap();
711            let storage = crate::testing::InMemoryStorage::default();
712            let etag = object_with_etag(&storage, &kb, b"one\n").await;
713            let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
714            indexer_tx
715                .try_send(IndexEvent::Upsert {
716                    kb: kb.clone(),
717                    object_key: ObjectPath::try_from_str("queued.md").unwrap(),
718                    etag: "queued".to_string(),
719                    mtime: 0,
720                })
721                .unwrap();
722            let router = router_with_storage(Arc::new(storage), kb, MAX_BODY_BYTES, indexer_tx);
723
724            let response = patch_request(
725                router,
726                "nt-patch-mode",
727                "append",
728                Some(&etag),
729                Bytes::from_static(b"two\n"),
730            )
731            .await;
732
733            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
734            assert_eq!(response.headers().get("retry-after").unwrap(), "5");
735            let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
736            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
737            assert_eq!(json["error"], "backend_unavailable");
738        }
739    }
740}
741
742#[cfg(test)]
743mod line_range_get {
744    use super::*;
745    use axum::body::{Body, to_bytes};
746    use axum::http::StatusCode;
747    use axum::response::Response;
748    use bytes::Bytes;
749    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage};
750    use std::collections::BTreeMap;
751    use std::sync::Arc;
752    use tower::util::ServiceExt;
753
754    const KB: &str = "notes";
755    const TOKEN: &str = "test-token-abc";
756
757    fn twenty_line_markdown() -> String {
758        markdown_lines(1, 20)
759    }
760
761    fn markdown_lines(first: u32, last: u32) -> String {
762        let mut body = String::new();
763        for line in first..=last {
764            std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
765        }
766        body
767    }
768
769    async fn router_with_markdown_object(body: String) -> axum::Router {
770        let kb = KbSlug::try_new(KB).unwrap();
771        let object_path = ObjectPath::try_from_str("ranges.md").unwrap();
772        let storage = Arc::new(crate::testing::InMemoryStorage::default());
773        storage
774            .put_object(
775                &kb,
776                &object_path,
777                Bytes::from(body),
778                Some("text/markdown"),
779                ConditionalHeaders::default(),
780            )
781            .await
782            .unwrap();
783
784        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
785        let mut kbs = BTreeMap::new();
786        kbs.insert(KB.to_string(), kb);
787        build_router(AppState {
788            storage,
789            declared_kbs: Arc::new(kbs),
790            public_read_policies: Arc::new(BTreeMap::new()),
791            bearer_token: Arc::new(TOKEN.to_string()),
792            max_body_size: MAX_BODY_BYTES,
793            max_patchable_size: MAX_BODY_BYTES,
794            indexer_tx,
795            searcher: Arc::new(crate::testing::NoopSearcher),
796        })
797    }
798
799    async fn get_ranges_md(router: axum::Router, range: &str) -> Response {
800        router
801            .oneshot(
802                Request::builder()
803                    .method("GET")
804                    .uri(format!("/api/v1/knowledgebases/{KB}/ranges.md"))
805                    .header("authorization", format!("Bearer {TOKEN}"))
806                    .header(axum::http::header::RANGE, range)
807                    .body(Body::empty())
808                    .unwrap(),
809            )
810            .await
811            .unwrap()
812    }
813
814    #[tokio::test]
815    async fn returns_first_five_lines_when_closed_range_requested() {
816        let router = router_with_markdown_object(twenty_line_markdown()).await;
817
818        let response = get_ranges_md(router, "lines=1-5").await;
819
820        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
821        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
822        assert_eq!(body, Bytes::from(markdown_lines(1, 5)));
823    }
824
825    #[tokio::test]
826    async fn returns_last_three_lines_when_suffix_range_requested() {
827        let router = router_with_markdown_object(twenty_line_markdown()).await;
828
829        let response = get_ranges_md(router, "lines=-3").await;
830
831        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
832        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
833        assert_eq!(body, Bytes::from(markdown_lines(18, 20)));
834    }
835
836    #[tokio::test]
837    async fn returns_empty_body_when_insert_range_requested() {
838        let router = router_with_markdown_object(twenty_line_markdown()).await;
839
840        let response = get_ranges_md(router, "lines=5-4").await;
841
842        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
843        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
844        assert!(body.is_empty());
845    }
846
847    #[tokio::test]
848    async fn returns_full_body_when_unknown_range_unit_requested() {
849        let body = twenty_line_markdown();
850        let router = router_with_markdown_object(body.clone()).await;
851
852        let response = get_ranges_md(router, "items=0-5").await;
853
854        assert_eq!(response.status(), StatusCode::OK);
855        let actual = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
856        assert_eq!(actual, Bytes::from(body));
857    }
858
859    mod headers {
860        use super::*;
861
862        fn ten_line_markdown() -> String {
863            markdown_lines(1, 10)
864        }
865
866        #[tokio::test]
867        async fn returns_line_and_byte_content_ranges_when_closed_range_requested() {
868            let router = router_with_markdown_object(ten_line_markdown()).await;
869
870            let response = get_ranges_md(router, "lines=2-4").await;
871
872            assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
873            assert_eq!(
874                response.headers().get("Content-Range").unwrap(),
875                "lines 2-4/10"
876            );
877            assert_eq!(
878                response.headers().get("X-Content-Range-Bytes").unwrap(),
879                "8-31/80"
880            );
881        }
882
883        #[tokio::test]
884        async fn returns_slice_content_length_when_closed_range_requested() {
885            let router = router_with_markdown_object(ten_line_markdown()).await;
886
887            let response = get_ranges_md(router, "lines=2-4").await;
888
889            assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
890            assert_eq!(response.headers().get("content-length").unwrap(), "24");
891        }
892
893        #[tokio::test]
894        async fn returns_zero_length_and_empty_byte_range_when_insert_range_requested() {
895            let router = router_with_markdown_object(ten_line_markdown()).await;
896
897            let response = get_ranges_md(router, "lines=5-4").await;
898
899            assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
900            assert_eq!(response.headers().get("content-length").unwrap(), "0");
901            assert_eq!(
902                response.headers().get("Content-Range").unwrap(),
903                "lines 5-4/10"
904            );
905            assert_eq!(
906                response.headers().get("X-Content-Range-Bytes").unwrap(),
907                "32-31/80"
908            );
909        }
910
911        #[tokio::test]
912        async fn omits_line_byte_range_header_when_byte_range_requested() {
913            let router = router_with_markdown_object(ten_line_markdown()).await;
914
915            let response = get_ranges_md(router, "bytes=0-9").await;
916
917            assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
918            assert!(response.headers().get("X-Content-Range-Bytes").is_none());
919        }
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use axum::body::{Body, to_bytes};
927    use axum::http::StatusCode;
928    use axum::response::Response;
929    use bytes::Bytes;
930    use notedthat_core::{ConditionalHeaders, KbSlug, ObjectPath, Storage, StorageError};
931    use notedthat_indexer::IndexEvent;
932    use std::collections::BTreeMap;
933    use std::sync::Arc;
934    use tower::util::ServiceExt;
935
936    const KB: &str = "notes";
937    const TOKEN: &str = "test-token-abc";
938
939    fn router() -> axum::Router {
940        let kb = KbSlug::try_new(KB).unwrap();
941        let mut kbs = BTreeMap::new();
942        kbs.insert(KB.to_string(), kb);
943        let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
944        tokio::spawn(async move { while rx.recv().await.is_some() {} });
945
946        build_router(AppState {
947            storage: Arc::new(crate::testing::InMemoryStorage::default()),
948            declared_kbs: Arc::new(kbs),
949            public_read_policies: Arc::new(BTreeMap::new()),
950            bearer_token: Arc::new(TOKEN.to_string()),
951            max_body_size: MAX_BODY_BYTES,
952            max_patchable_size: MAX_BODY_BYTES,
953            indexer_tx,
954            searcher: Arc::new(crate::testing::NoopSearcher),
955        })
956    }
957
958    async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> Response {
959        router
960            .oneshot(
961                Request::builder()
962                    .method("PUT")
963                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
964                    .header("authorization", format!("Bearer {TOKEN}"))
965                    .header(axum::http::header::CONTENT_TYPE, "text/markdown")
966                    .body(Body::from(Bytes::from_static(body)))
967                    .unwrap(),
968            )
969            .await
970            .unwrap()
971    }
972
973    async fn put_object_etag(router: axum::Router, path: &str, body: &'static [u8]) -> String {
974        let response = put_object(router, path, body).await;
975        assert_eq!(response.status(), StatusCode::CREATED);
976        response
977            .headers()
978            .get(axum::http::header::ETAG)
979            .unwrap()
980            .to_str()
981            .unwrap()
982            .to_string()
983    }
984
985    async fn get_object(router: axum::Router, path: &str) -> Response {
986        router
987            .oneshot(
988                Request::builder()
989                    .method("GET")
990                    .uri(format!("/api/v1/knowledgebases/{KB}/{path}"))
991                    .header("authorization", format!("Bearer {TOKEN}"))
992                    .body(Body::empty())
993                    .unwrap(),
994            )
995            .await
996            .unwrap()
997    }
998
999    async fn post_replace(
1000        router: axum::Router,
1001        path: &str,
1002        if_match: &str,
1003        body: &'static [u8],
1004    ) -> Response {
1005        router
1006            .oneshot(
1007                Request::builder()
1008                    .method("POST")
1009                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/{path}"))
1010                    .header("authorization", format!("Bearer {TOKEN}"))
1011                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1012                    .header(axum::http::header::IF_MATCH, if_match)
1013                    .body(Body::from(Bytes::from_static(body)))
1014                    .unwrap(),
1015            )
1016            .await
1017            .unwrap()
1018    }
1019
1020    #[tokio::test]
1021    async fn get_on_replace_prefixed_path_still_reads_object_via_catch_all() {
1022        let router = router();
1023        put_object_etag(router.clone(), "replace/foo.md", b"hi").await;
1024
1025        let response = get_object(router, "replace/foo.md").await;
1026
1027        assert_eq!(response.status(), StatusCode::OK);
1028        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1029        assert_eq!(&body[..], b"hi");
1030    }
1031
1032    #[tokio::test]
1033    async fn patch_on_replace_prefixed_path_still_reaches_patch_object() {
1034        let router = router();
1035        let etag = put_object_etag(router.clone(), "replace/bar.md", b"old\n").await;
1036
1037        let response = router
1038            .oneshot(
1039                Request::builder()
1040                    .method("PATCH")
1041                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/bar.md"))
1042                    .header("authorization", format!("Bearer {TOKEN}"))
1043                    .header(axum::http::header::CONTENT_RANGE, "lines 1-1/*")
1044                    .header(axum::http::header::IF_MATCH, etag)
1045                    .body(Body::from(Bytes::from_static(b"new\n")))
1046                    .unwrap(),
1047            )
1048            .await
1049            .unwrap();
1050
1051        assert_eq!(response.status(), StatusCode::OK);
1052    }
1053
1054    #[tokio::test]
1055    async fn put_and_delete_on_replace_prefixed_path_still_work() {
1056        let router = router();
1057        let put = put_object(router.clone(), "replace/delete.md", b"gone").await;
1058
1059        assert_eq!(put.status(), StatusCode::CREATED);
1060        let delete = router
1061            .oneshot(
1062                Request::builder()
1063                    .method("DELETE")
1064                    .uri(format!("/api/v1/knowledgebases/{KB}/replace/delete.md"))
1065                    .header("authorization", format!("Bearer {TOKEN}"))
1066                    .body(Body::empty())
1067                    .unwrap(),
1068            )
1069            .await
1070            .unwrap();
1071        assert_eq!(delete.status(), StatusCode::NO_CONTENT);
1072    }
1073
1074    #[tokio::test]
1075    async fn post_on_non_replace_path_returns_404_not_found() {
1076        let response = router()
1077            .oneshot(
1078                Request::builder()
1079                    .method("POST")
1080                    .uri(format!("/api/v1/knowledgebases/{KB}/foo.md"))
1081                    .header("authorization", format!("Bearer {TOKEN}"))
1082                    .body(Body::empty())
1083                    .unwrap(),
1084            )
1085            .await
1086            .unwrap();
1087
1088        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1089        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1090        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1091        assert_eq!(json["error"], "not_found");
1092        assert!(
1093            json["message"]
1094                .as_str()
1095                .unwrap()
1096                .contains("supported actions: 'replace/<path>'")
1097        );
1098    }
1099
1100    #[tokio::test]
1101    async fn post_on_replace_prefixed_path_dispatches_to_replace_handler() {
1102        let router = router();
1103        let etag = put_object_etag(router.clone(), "target.md", b"hello world").await;
1104
1105        let response = post_replace(
1106            router,
1107            "target.md",
1108            &etag,
1109            br#"{"old_string":"world","new_string":"planet"}"#,
1110        )
1111        .await;
1112
1113        assert_eq!(response.status(), StatusCode::OK);
1114        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1115        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1116        assert_eq!(json["match_count"], 1);
1117    }
1118
1119    #[tokio::test]
1120    async fn post_on_replace_replace_path_targets_the_replace_prefixed_object() {
1121        let router = router();
1122        let etag = put_object_etag(router.clone(), "replace/nested.md", b"foo bar").await;
1123
1124        let response = post_replace(
1125            router.clone(),
1126            "replace/nested.md",
1127            &etag,
1128            br#"{"old_string":"bar","new_string":"baz"}"#,
1129        )
1130        .await;
1131
1132        assert_eq!(response.status(), StatusCode::OK);
1133        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1134        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1135        assert_eq!(json["match_count"], 1);
1136        let get = get_object(router, "replace/nested.md").await;
1137        let body = to_bytes(get.into_body(), 64 * 1024).await.unwrap();
1138        assert_eq!(&body[..], b"foo baz");
1139    }
1140
1141    #[tokio::test]
1142    async fn test_conditional_put_503_then_naive_retry_412_keeps_object_stored() {
1143        let kb = KbSlug::try_new(KB).unwrap();
1144        let object_path = ObjectPath::try_from_str("cond.md").unwrap();
1145        let storage = Arc::new(crate::testing::InMemoryStorage::default());
1146
1147        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1148        indexer_tx
1149            .try_send(IndexEvent::Upsert {
1150                kb: kb.clone(),
1151                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1152                etag: "etag".to_string(),
1153                mtime: 0,
1154            })
1155            .unwrap();
1156
1157        let mut kbs = BTreeMap::new();
1158        kbs.insert(KB.to_string(), kb.clone());
1159        let state = AppState {
1160            storage: storage.clone(),
1161            declared_kbs: Arc::new(kbs),
1162            public_read_policies: Arc::new(BTreeMap::new()),
1163            bearer_token: Arc::new(TOKEN.to_string()),
1164            max_body_size: MAX_BODY_BYTES,
1165            max_patchable_size: MAX_BODY_BYTES,
1166            indexer_tx,
1167            searcher: Arc::new(crate::testing::NoopSearcher),
1168        };
1169        let router = build_router(state);
1170
1171        let response = router
1172            .clone()
1173            .oneshot(
1174                Request::builder()
1175                    .method("PUT")
1176                    .uri(format!("/api/v1/knowledgebases/{KB}/cond.md"))
1177                    .header("authorization", format!("Bearer {TOKEN}"))
1178                    .header("if-none-match", "*")
1179                    .body(Body::from(Bytes::from_static(b"first content")))
1180                    .unwrap(),
1181            )
1182            .await
1183            .unwrap();
1184
1185        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1186        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1187        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1188        let body = String::from_utf8(body.to_vec()).unwrap();
1189        assert!(body.contains("\"error\":\"backend_unavailable\""));
1190        assert!(body.contains("object stored; indexer queue full — retry to re-enqueue"));
1191
1192        let stored = storage
1193            .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1194            .await
1195            .unwrap();
1196        assert_eq!(stored.bytes, Bytes::from_static(b"first content"));
1197
1198        let retry = router
1199            .oneshot(
1200                Request::builder()
1201                    .method("PUT")
1202                    .uri(format!("/api/v1/knowledgebases/{KB}/cond.md"))
1203                    .header("authorization", format!("Bearer {TOKEN}"))
1204                    .header("if-none-match", "*")
1205                    .body(Body::from(Bytes::from_static(b"second content")))
1206                    .unwrap(),
1207            )
1208            .await
1209            .unwrap();
1210
1211        assert_eq!(retry.status(), StatusCode::PRECONDITION_FAILED);
1212        assert!(retry.headers().get("retry-after").is_none());
1213
1214        let stored = storage
1215            .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1216            .await
1217            .unwrap();
1218        assert_eq!(stored.bytes, Bytes::from_static(b"first content"));
1219    }
1220
1221    #[tokio::test]
1222    async fn test_delete_returns_delete_specific_503_body_when_indexer_backpressure() {
1223        let kb = KbSlug::try_new(KB).unwrap();
1224        let object_path = ObjectPath::try_from_str("to-delete.md").unwrap();
1225        let storage = Arc::new(crate::testing::InMemoryStorage::default());
1226        storage
1227            .put_object(
1228                &kb,
1229                &object_path,
1230                Bytes::from_static(b"content"),
1231                Some("text/plain"),
1232                ConditionalHeaders::default(),
1233            )
1234            .await
1235            .unwrap();
1236
1237        let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1238        indexer_tx
1239            .try_send(IndexEvent::Upsert {
1240                kb: kb.clone(),
1241                object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1242                etag: "etag".to_string(),
1243                mtime: 0,
1244            })
1245            .unwrap();
1246
1247        let mut kbs = BTreeMap::new();
1248        kbs.insert(KB.to_string(), kb.clone());
1249        let state = AppState {
1250            storage: storage.clone(),
1251            declared_kbs: Arc::new(kbs),
1252            public_read_policies: Arc::new(BTreeMap::new()),
1253            bearer_token: Arc::new(TOKEN.to_string()),
1254            max_body_size: MAX_BODY_BYTES,
1255            max_patchable_size: MAX_BODY_BYTES,
1256            indexer_tx,
1257            searcher: Arc::new(crate::testing::NoopSearcher),
1258        };
1259        let router = build_router(state);
1260
1261        let response = router
1262            .oneshot(
1263                Request::builder()
1264                    .method("DELETE")
1265                    .uri(format!("/api/v1/knowledgebases/{KB}/to-delete.md"))
1266                    .header("authorization", format!("Bearer {TOKEN}"))
1267                    .body(Body::empty())
1268                    .unwrap(),
1269            )
1270            .await
1271            .unwrap();
1272
1273        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1274        assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1275        let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1276        let body = String::from_utf8(body.to_vec()).unwrap();
1277        assert!(body.contains("\"error\":\"backend_unavailable\""));
1278        assert!(
1279            body.contains("\"message\":\"deleted from storage; retry to clear from search index\"")
1280        );
1281        assert!(!body.contains("object stored; indexer queue full — retry to re-enqueue"));
1282
1283        let deleted = storage
1284            .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1285            .await;
1286        assert!(matches!(deleted, Err(StorageError::NotFound { .. })));
1287    }
1288}