Skip to main content

notedthat_api_http/router/
mod.rs

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