1pub(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
37macro_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
58pub const API_V1_PREFIX: &str = "/api/v1";
60
61pub 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
71pub const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
73
74#[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
86pub 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 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 .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 #[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}