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