1pub(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
35macro_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
56pub const API_V1_PREFIX: &str = "/api/v1";
58
59pub 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
69pub const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
71
72#[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
84pub 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 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 .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 #[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}