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