1use crate::error::{ApiError, ApiErrorResponse};
4use crate::middleware::{auth_middleware, extract_request_id};
5use crate::state::AppState;
6use axum::body::Body;
7use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
8use axum::handler::Handler;
9use axum::http::{HeaderName, StatusCode};
10use axum::middleware::from_fn_with_state;
11use axum::response::{IntoResponse, Response};
12use axum::routing::get;
13use axum::{Json, Router};
14use bytes::Bytes;
15use notedthat_core::{
16 ByteRange, ConditionalHeaders, Error as CoreError, KbSlug, LineIndex, ObjectPath, StorageError,
17 parse_line_range_header, parse_range_header,
18};
19use notedthat_write::PatchMode;
20use serde::{Deserialize, Serialize};
21use std::fmt::Write;
22use std::time::{Duration, UNIX_EPOCH};
23use tower::ServiceBuilder;
24use tower_http::request_id::{
25 MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer,
26};
27use tower_http::trace::TraceLayer;
28use uuid::Uuid;
29
30pub const MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
32const REPLACE_IF_MATCH_ERROR: &str =
33 "If-Match is required for POST replace and must be a single strong ETag";
34
35#[derive(Clone, Copy, Default)]
37pub struct MakeRequestUuidV7;
38
39impl MakeRequestId for MakeRequestUuidV7 {
40 fn make_request_id<B>(&mut self, _req: &Request<B>) -> Option<RequestId> {
41 let id = Uuid::now_v7().to_string();
42 let hv = id.parse().ok()?;
43 Some(RequestId::new(hv))
44 }
45}
46
47pub fn build_router(state: AppState) -> Router {
49 let request_id_header = HeaderName::from_static("x-request-id");
50 Router::new()
51 .route("/healthz", get(healthz))
52 .route("/readyz", get(readyz))
53 .route("/v1/knowledgebases", get(list_kbs))
54 .route("/v1/knowledgebases/{kb_slug}", get(list_objects))
55 .route(
56 "/v1/knowledgebases/{kb_slug}/search",
57 axum::routing::post(crate::search_route::search_kb).layer(
58 axum::extract::DefaultBodyLimit::max(crate::search_route::SEARCH_BODY_MAX_BYTES),
59 ),
60 )
61 .route(
62 "/v1/knowledgebases/{kb_slug}/{*object_path}",
63 get(get_object)
64 .head(head_object)
65 .put(put_object)
66 .delete(delete_object)
67 .patch(patch_object.layer(DefaultBodyLimit::disable()))
68 .post(post_object.layer(DefaultBodyLimit::disable())),
69 )
70 .layer(
71 ServiceBuilder::new()
72 .layer(DefaultBodyLimit::max(body_limit_usize(MAX_BODY_BYTES)))
73 .layer(SetRequestIdLayer::new(
74 request_id_header.clone(),
75 MakeRequestUuidV7,
76 ))
77 .layer(PropagateRequestIdLayer::new(request_id_header))
78 .layer(TraceLayer::new_for_http())
79 .layer(from_fn_with_state(state.clone(), auth_middleware)),
80 )
81 .with_state(state)
82}
83
84async fn healthz() -> impl IntoResponse {
88 Json(serde_json::json!({"status": "ok"}))
89}
90
91async fn readyz() -> impl IntoResponse {
93 Json(serde_json::json!({"status": "ok"}))
94}
95
96async fn list_kbs(State(state): State<AppState>) -> impl IntoResponse {
100 let slugs: Vec<&str> = state.declared_kbs.keys().map(String::as_str).collect();
101 Json(serde_json::json!({"knowledgebases": slugs}))
102}
103
104#[derive(Deserialize)]
107struct ListQuery {
108 prefix: Option<String>,
109 limit: Option<u32>,
110 cursor: Option<String>,
111}
112
113#[derive(Deserialize)]
114struct ReplaceBody {
115 old_string: String,
116 new_string: String,
117 #[serde(default)]
118 replace_all: bool,
119}
120
121#[derive(Serialize)]
122struct ReplaceResponse {
123 etag: String,
124 match_count: u64,
125 total_bytes: u64,
126}
127
128async fn list_objects(
130 State(state): State<AppState>,
131 Path(kb_slug): Path<String>,
132 Query(q): Query<ListQuery>,
133 req: Request,
134) -> Result<impl IntoResponse, ApiErrorResponse> {
135 let request_id = extract_request_id(&req);
136 let err = |error: ApiError| ApiErrorResponse {
137 error,
138 request_id: request_id.clone(),
139 };
140
141 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
142 let limit = q.limit.filter(|&limit| limit > 0).unwrap_or(100).min(1000);
143
144 let result = state
145 .storage
146 .list_objects(&kb, q.prefix.as_deref(), limit, q.cursor.as_deref())
147 .await
148 .map_err(|error| err(ApiError::Storage(error)))?;
149
150 Ok(Json(serde_json::json!({
151 "objects": result.objects,
152 "truncated": result.truncated,
153 "next_cursor": result.next_cursor,
154 })))
155}
156
157async fn head_object(
161 State(state): State<AppState>,
162 Path((kb_slug, object_path)): Path<(String, String)>,
163 req: Request,
164) -> Result<Response, ApiErrorResponse> {
165 let request_id = extract_request_id(&req);
166 let err = |error: ApiError| ApiErrorResponse {
167 error,
168 request_id: request_id.clone(),
169 };
170
171 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
172 let path = parse_path(&object_path).map_err(&err)?;
173
174 let conditionals = ConditionalHeaders::from_header_map(req.headers());
183
184 let meta = state
185 .storage
186 .head_object(&kb, &path, conditionals)
187 .await
188 .map_err(|e| err(ApiError::from(e)))?;
189
190 let mut builder = Response::builder().status(StatusCode::OK);
191
192 if let Some(ct) = &meta.content_type {
193 builder = builder.header("content-type", ct.as_str());
194 }
195 if let Some(etag) = &meta.etag {
196 builder = builder.header("etag", etag.as_str());
197 }
198 if let Some(last_modified) = meta
199 .last_modified
200 .and_then(|seconds| u64::try_from(seconds).ok())
201 .map(|seconds| UNIX_EPOCH + Duration::from_secs(seconds))
202 {
203 builder = builder.header("last-modified", httpdate::fmt_http_date(last_modified));
204 }
205 builder = builder.header("content-length", meta.size.to_string());
207
208 Ok(builder
209 .body(Body::empty())
210 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
211}
212
213async fn get_object(
215 State(state): State<AppState>,
216 Path((kb_slug, object_path)): Path<(String, String)>,
217 req: Request,
218) -> Result<Response, ApiErrorResponse> {
219 let request_id = extract_request_id(&req);
220 let err = |error: ApiError| ApiErrorResponse {
221 error,
222 request_id: request_id.clone(),
223 };
224
225 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
226 let path = parse_path(&object_path).map_err(&err)?;
227 let conditionals = ConditionalHeaders::from_header_map(req.headers());
228
229 let range = match req.headers().get(axum::http::header::RANGE) {
230 None => None,
231 Some(raw) => {
232 let raw_str = raw
233 .to_str()
234 .map_err(|_| err(ApiError::MalformedRange("non-UTF-8 Range header".into())))?;
235 let parsed = parse_range_header(raw_str)
236 .map_err(|_| err(ApiError::MalformedRange(raw_str.to_owned())))?;
237 if parsed.unit == "lines" {
238 return serve_line_range_read(
239 &state,
240 &kb,
241 &path,
242 raw_str,
243 conditionals,
244 &request_id,
245 )
246 .await;
247 } else if parsed.unit == "bytes" && !parsed.ranges.is_empty() {
248 Some(parsed.ranges)
249 } else {
250 None
251 }
252 }
253 };
254
255 let read = state
256 .storage
257 .get_object(&kb, &path, range, conditionals)
258 .await
259 .map_err(|error| match error {
260 StorageError::NotFound { .. } => err(ApiError::Core(CoreError::NotFound {
261 resource: path.as_str().to_string(),
262 })),
263 other => err(ApiError::from(other)),
264 })?;
265 let content_type = read
266 .meta
267 .content_type
268 .as_deref()
269 .unwrap_or("application/octet-stream");
270
271 let status = if read.content_range.is_some() {
272 StatusCode::PARTIAL_CONTENT
273 } else {
274 StatusCode::OK
275 };
276 let mut builder = Response::builder()
277 .status(status)
278 .header(axum::http::header::CONTENT_TYPE, content_type)
279 .header(axum::http::header::CONTENT_LENGTH, read.bytes.len());
280
281 if let Some(etag) = &read.meta.etag {
282 builder = builder.header(axum::http::header::ETAG, etag.as_str());
283 }
284 if let Some(last_modified) = read
285 .meta
286 .last_modified
287 .and_then(|seconds| u64::try_from(seconds).ok())
288 .map(|seconds| UNIX_EPOCH + Duration::from_secs(seconds))
289 {
290 builder = builder.header(
291 axum::http::header::LAST_MODIFIED,
292 httpdate::fmt_http_date(last_modified),
293 );
294 }
295 if let Some(content_range) = &read.content_range {
296 builder = builder.header(axum::http::header::CONTENT_RANGE, content_range.as_str());
297 }
298
299 let resp = builder
300 .body(Body::from(read.bytes))
301 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
302
303 Ok(resp)
304}
305
306async fn serve_line_range_read(
307 state: &AppState,
308 kb: &KbSlug,
309 path: &ObjectPath,
310 raw_range: &str,
311 conditionals: ConditionalHeaders,
312 request_id: &str,
313) -> Result<Response, ApiErrorResponse> {
314 let err = |error: ApiError| ApiErrorResponse {
315 error,
316 request_id: request_id.to_string(),
317 };
318
319 let line_range = parse_line_range_header(raw_range)
320 .map_err(|_| err(ApiError::MalformedRange(raw_range.to_owned())))?;
321 let read = state
322 .storage
323 .get_object(kb, path, None, conditionals)
324 .await
325 .map_err(|error| match error {
326 StorageError::NotFound { .. } => err(ApiError::Core(CoreError::NotFound {
327 resource: path.as_str().to_string(),
328 })),
329 other => err(ApiError::from(other)),
330 })?;
331
332 let idx = LineIndex::from_bytes(&read.bytes);
333 let byte_range = idx.byte_range(&line_range).ok_or_else(|| {
334 err(ApiError::LineRangeNotSatisfiable {
335 line_total: idx.total_lines,
336 byte_total: idx.total_bytes,
337 })
338 })?;
339 let range_start = usize::try_from(byte_range.start).map_err(|_| {
340 err(ApiError::Core(CoreError::InvalidInput {
341 message: "line range start does not fit usize".to_string(),
342 }))
343 })?;
344 let range_end = usize::try_from(byte_range.end).map_err(|_| {
345 err(ApiError::Core(CoreError::InvalidInput {
346 message: "line range end does not fit usize".to_string(),
347 }))
348 })?;
349 let sliced = read.bytes.slice(range_start..range_end);
350 let content_type = read
351 .meta
352 .content_type
353 .as_deref()
354 .unwrap_or("application/octet-stream");
355
356 let mut builder = Response::builder()
357 .status(StatusCode::PARTIAL_CONTENT)
358 .header(axum::http::header::CONTENT_TYPE, content_type)
359 .header(axum::http::header::CONTENT_LENGTH, sliced.len());
360
361 if let Some(etag) = &read.meta.etag {
362 builder = builder.header(axum::http::header::ETAG, etag.as_str());
363 }
364 if let Some(last_modified) = read
365 .meta
366 .last_modified
367 .and_then(|seconds| u64::try_from(seconds).ok())
368 .map(|seconds| UNIX_EPOCH + Duration::from_secs(seconds))
369 {
370 builder = builder.header(
371 axum::http::header::LAST_MODIFIED,
372 httpdate::fmt_http_date(last_modified),
373 );
374 }
375
376 let content_range_value = idx.content_range_string(&line_range);
377 builder = builder.header("Content-Range", content_range_value);
378
379 let inclusive_end = byte_range.end.saturating_sub(1);
380 let x_content_range_bytes =
381 format!("{}-{}/{}", byte_range.start, inclusive_end, idx.total_bytes);
382 builder = builder.header("X-Content-Range-Bytes", x_content_range_bytes);
383
384 Ok(builder
385 .body(Body::from(sliced))
386 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
387}
388
389async fn put_object(
391 State(state): State<AppState>,
392 Path((kb_slug, object_path)): Path<(String, String)>,
393 req: Request,
394) -> Result<Response, ApiErrorResponse> {
395 let request_id = extract_request_id(&req);
396 let err = |error: ApiError| ApiErrorResponse {
397 error,
398 request_id: request_id.clone(),
399 };
400
401 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
402 let path = parse_path(&object_path).map_err(&err)?;
403
404 let content_length = req
405 .headers()
406 .get("content-length")
407 .and_then(|value| value.to_str().ok())
408 .and_then(|value| value.parse::<u64>().ok());
409 if let Some(content_length) = content_length
410 && content_length > state.max_body_size
411 {
412 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
413 size: content_length,
414 limit: state.max_body_size,
415 })));
416 }
417
418 let content_type = req
419 .headers()
420 .get("content-type")
421 .and_then(|value| value.to_str().ok())
422 .map(str::to_string);
423 let conditionals = ConditionalHeaders::from_header_map(req.headers());
424
425 let body_bytes: Bytes =
426 axum::body::to_bytes(req.into_body(), body_limit_usize(state.max_body_size))
427 .await
428 .map_err(|_| {
429 err(ApiError::Core(CoreError::PayloadTooLarge {
430 size: state.max_body_size + 1,
431 limit: state.max_body_size,
432 }))
433 })?;
434
435 if body_bytes.len() as u64 > state.max_body_size {
436 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
437 size: body_bytes.len() as u64,
438 limit: state.max_body_size,
439 })));
440 }
441
442 let outcome = notedthat_write::commit(
443 state.storage.as_ref(),
444 &state.indexer_tx,
445 &kb,
446 &path,
447 body_bytes,
448 content_type.as_deref(),
449 conditionals,
450 )
451 .await
452 .map_err(|e| err(ApiError::from(e)))?;
453
454 let location = format!(
455 "/v1/knowledgebases/{kb_slug}/{}",
456 percent_encode_path(path.as_str())
457 );
458 let mut builder = Response::builder()
459 .status(StatusCode::CREATED)
460 .header("location", location);
461 if let Some(etag) = &outcome.etag {
462 builder = builder.header(axum::http::header::ETAG, etag.as_str());
463 }
464 let resp = builder
465 .body(Body::empty())
466 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
467 Ok(resp)
468}
469
470async fn patch_object(
471 State(state): State<AppState>,
472 Path((kb_slug, object_path)): Path<(String, String)>,
473 req: Request,
474) -> Result<Response, ApiErrorResponse> {
475 let request_id = extract_request_id(&req);
476 let err = |error: ApiError| ApiErrorResponse {
477 error,
478 request_id: request_id.clone(),
479 };
480
481 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
482 let path = parse_path(&object_path).map_err(&err)?;
483
484 let content_length = req
485 .headers()
486 .get(axum::http::header::CONTENT_LENGTH)
487 .and_then(|value| value.to_str().ok())
488 .and_then(|value| value.parse::<u64>().ok());
489 if let Some(content_length) = content_length
490 && content_length > state.max_patchable_size
491 {
492 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
493 size: content_length,
494 limit: state.max_patchable_size,
495 })));
496 }
497
498 if let Some(if_match) = req
499 .headers()
500 .get(axum::http::header::IF_MATCH)
501 .and_then(|value| value.to_str().ok())
502 && (if_match == "*" || if_match.contains(','))
503 {
504 return Err(err(ApiError::Core(CoreError::InvalidInput {
505 message: "If-Match: * and multi-value If-Match not supported on PATCH in v1; provide a single strong ETag".into(),
506 })));
507 }
508
509 let content_range_header = req
510 .headers()
511 .get(axum::http::header::CONTENT_RANGE)
512 .and_then(|value| value.to_str().ok())
513 .map(str::to_string);
514 let nt_patch_mode = req
515 .headers()
516 .get(axum::http::HeaderName::from_static("nt-patch-mode"))
517 .and_then(|value| value.to_str().ok())
518 .map(str::to_lowercase);
519 let content_type = req
520 .headers()
521 .get(axum::http::header::CONTENT_TYPE)
522 .and_then(|value| value.to_str().ok())
523 .map(str::to_string);
524 let conditionals = ConditionalHeaders::from_header_map(req.headers());
525
526 let body_bytes: Bytes =
527 axum::body::to_bytes(req.into_body(), body_limit_usize(state.max_patchable_size))
528 .await
529 .map_err(|_| {
530 err(ApiError::Core(CoreError::PayloadTooLarge {
531 size: state.max_patchable_size + 1,
532 limit: state.max_patchable_size,
533 }))
534 })?;
535
536 if body_bytes.len() as u64 > state.max_patchable_size {
537 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
538 size: body_bytes.len() as u64,
539 limit: state.max_patchable_size,
540 })));
541 }
542
543 let patch_mode = patch_mode_from_headers(
544 nt_patch_mode.as_deref(),
545 content_range_header.as_deref(),
546 body_bytes,
547 )
548 .map_err(|error| err(ApiError::Core(error)))?;
549
550 let outcome = notedthat_write::patch(
551 state.storage.as_ref(),
552 &state.indexer_tx,
553 notedthat_write::patch::PatchRequest {
554 kb: &kb,
555 path: &path,
556 patch_mode,
557 caller_conditionals: conditionals,
558 max_patchable_size: state.max_patchable_size,
559 caller_content_type: content_type.as_deref(),
560 },
561 )
562 .await
563 .map_err(|e| err(ApiError::from(e)))?;
564
565 let location = format!(
566 "/v1/knowledgebases/{kb_slug}/{}",
567 percent_encode_path(path.as_str())
568 );
569 let mut builder = Response::builder()
570 .status(StatusCode::OK)
571 .header(axum::http::header::LOCATION, location);
572 if let Some(etag) = &outcome.etag {
573 builder = builder.header(axum::http::header::ETAG, etag.as_str());
574 }
575
576 Ok(builder
577 .body(Body::empty())
578 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()))
579}
580
581async fn post_object(
584 State(state): State<AppState>,
585 Path((kb_slug, object_path)): Path<(String, String)>,
586 req: Request,
587) -> Result<Response, ApiErrorResponse> {
588 let request_id = extract_request_id(&req);
589 let err = |error: ApiError| ApiErrorResponse {
590 error,
591 request_id: request_id.clone(),
592 };
593
594 let Some(target_path) = object_path.strip_prefix("replace/") else {
595 return Err(err(ApiError::Core(CoreError::NotFound {
596 resource: format!(
597 "POST on '{object_path}' is not a defined action (supported actions: 'replace/<path>')"
598 ),
599 })));
600 };
601
602 replace_object(State(state), kb_slug, target_path.to_string(), req).await
603}
604
605async fn replace_object(
606 State(state): State<AppState>,
607 kb_slug: String,
608 object_path: String,
609 req: Request,
610) -> Result<Response, ApiErrorResponse> {
611 let request_id = extract_request_id(&req);
612 let err = |error: ApiError| ApiErrorResponse {
613 error,
614 request_id: request_id.clone(),
615 };
616
617 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
618 let path = parse_path(&object_path).map_err(&err)?;
619 let json_cap_u64 = state
620 .max_patchable_size
621 .saturating_mul(2)
622 .saturating_add(4096);
623
624 let content_length = req
625 .headers()
626 .get(axum::http::header::CONTENT_LENGTH)
627 .and_then(|value| value.to_str().ok())
628 .and_then(|value| value.parse::<u64>().ok());
629 if let Some(content_length) = content_length
630 && content_length > json_cap_u64
631 {
632 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
633 size: content_length,
634 limit: json_cap_u64,
635 })));
636 }
637
638 let conditionals = replace_conditionals(&req).map_err(&err)?;
639 let body_bytes: Bytes = axum::body::to_bytes(req.into_body(), body_limit_usize(json_cap_u64))
640 .await
641 .map_err(|_| {
642 err(ApiError::Core(CoreError::PayloadTooLarge {
643 size: json_cap_u64.saturating_add(1),
644 limit: json_cap_u64,
645 }))
646 })?;
647
648 if body_bytes.len() as u64 > json_cap_u64 {
649 return Err(err(ApiError::Core(CoreError::PayloadTooLarge {
650 size: body_bytes.len() as u64,
651 limit: json_cap_u64,
652 })));
653 }
654
655 let body = serde_json::from_slice::<ReplaceBody>(&body_bytes).map_err(|error| {
656 err(ApiError::Core(CoreError::InvalidInput {
657 message: format!("malformed replace JSON body: {error}"),
658 }))
659 })?;
660 if body.old_string.is_empty() {
661 return Err(err(ApiError::Core(CoreError::InvalidInput {
662 message: "old_string must be non-empty".into(),
663 })));
664 }
665
666 let outcome = notedthat_write::replace(
667 state.storage.as_ref(),
668 &state.indexer_tx,
669 notedthat_write::ReplaceRequest {
670 kb: &kb,
671 path: &path,
672 old_string: &body.old_string,
673 new_string: &body.new_string,
674 replace_all: body.replace_all,
675 caller_conditionals: conditionals,
676 max_patchable_size: state.max_patchable_size,
677 caller_content_type: None,
678 },
679 )
680 .await
681 .map_err(|e| err(ApiError::from(e)))?;
682
683 let meta = state
684 .storage
685 .head_object(&kb, &path, ConditionalHeaders::default())
686 .await
687 .map_err(|e| err(ApiError::from(e)))?;
688 let etag = outcome.put_outcome.etag.or(meta.etag).unwrap_or_default();
689 let response = ReplaceResponse {
690 etag: etag.clone(),
691 match_count: outcome.match_count,
692 total_bytes: meta.size,
693 };
694 let content_location = format!(
695 "/v1/knowledgebases/{kb_slug}/{}",
696 percent_encode_path(path.as_str())
697 );
698
699 Ok((
700 StatusCode::OK,
701 [
702 (axum::http::header::CONTENT_LOCATION, content_location),
703 (axum::http::header::ETAG, etag),
704 ],
705 Json(response),
706 )
707 .into_response())
708}
709
710fn replace_conditionals(req: &Request) -> Result<ConditionalHeaders, ApiError> {
711 let if_match = req
712 .headers()
713 .get(axum::http::header::IF_MATCH)
714 .and_then(|value| value.to_str().ok());
715 if if_match.is_none()
716 || if_match == Some("*")
717 || if_match.is_some_and(|value| value.contains(','))
718 {
719 return Err(ApiError::Core(CoreError::InvalidInput {
720 message: REPLACE_IF_MATCH_ERROR.into(),
721 }));
722 }
723
724 Ok(ConditionalHeaders::from_header_map(req.headers()))
725}
726
727fn patch_mode_from_headers(
728 nt_patch_mode: Option<&str>,
729 content_range_header: Option<&str>,
730 body_bytes: Bytes,
731) -> Result<PatchMode, CoreError> {
732 match (nt_patch_mode, content_range_header) {
733 (Some("append"), None) => Ok(PatchMode::Append { body: body_bytes }),
734 (Some("append"), Some(_)) => Err(CoreError::InvalidInput {
735 message: "NT-Patch-Mode: append is mutually exclusive with Content-Range".into(),
736 }),
737 (None, Some(content_range)) => parse_patch_content_range(content_range, body_bytes)
738 .map_err(|message| CoreError::InvalidInput { message }),
739 (None, None) => Err(CoreError::InvalidInput {
740 message: "PATCH requires either Content-Range or NT-Patch-Mode: append".into(),
741 }),
742 (Some(mode), _) => Err(CoreError::InvalidInput {
743 message: format!("Unknown NT-Patch-Mode value: {mode}; only 'append' is supported"),
744 }),
745 }
746}
747
748async fn delete_object(
750 State(state): State<AppState>,
751 Path((kb_slug, object_path)): Path<(String, String)>,
752 req: Request,
753) -> Result<Response, ApiErrorResponse> {
754 let request_id = extract_request_id(&req);
755 let err = |error: ApiError| ApiErrorResponse {
756 error,
757 request_id: request_id.clone(),
758 };
759
760 let kb = lookup_kb(&state, &kb_slug).map_err(&err)?;
761 let path = parse_path(&object_path).map_err(&err)?;
762 let conditionals = ConditionalHeaders::from_header_map(req.headers());
763
764 notedthat_write::commit_delete(
765 state.storage.as_ref(),
766 &state.indexer_tx,
767 &kb,
768 &path,
769 conditionals,
770 )
771 .await
772 .map_err(|e| err(ApiError::from(e)))?;
773
774 Ok(StatusCode::NO_CONTENT.into_response())
775}
776
777pub(crate) fn lookup_kb(state: &AppState, slug: &str) -> Result<KbSlug, ApiError> {
781 state.declared_kbs.get(slug).cloned().ok_or_else(|| {
782 ApiError::Core(CoreError::NotFound {
783 resource: format!("KB '{slug}' not declared"),
784 })
785 })
786}
787
788fn parse_path(raw: &str) -> Result<ObjectPath, ApiError> {
790 ObjectPath::try_from_str(raw).map_err(ApiError::Core)
791}
792
793fn body_limit_usize(max_body_size: u64) -> usize {
794 usize::try_from(max_body_size.saturating_add(1)).unwrap_or(usize::MAX)
795}
796
797fn parse_patch_content_range(content_range: &str, body: Bytes) -> Result<PatchMode, String> {
798 let (unit, range_part) = content_range
799 .split_once(' ')
800 .ok_or_else(|| format!("malformed Content-Range: {content_range}"))?;
801 let (range_str, _total) = range_part
802 .split_once('/')
803 .ok_or_else(|| format!("malformed Content-Range: {content_range}"))?;
804
805 match unit {
806 "bytes" => {
807 let (start, end) = range_str
808 .split_once('-')
809 .ok_or_else(|| format!("malformed Content-Range bytes range: {range_str}"))?;
810 let first = start
811 .parse::<u64>()
812 .map_err(|_| format!("invalid byte range start: {start}"))?;
813 let last = end
814 .parse::<u64>()
815 .map_err(|_| format!("invalid byte range end: {end}"))?;
816 Ok(PatchMode::Bytes {
817 range: ByteRange::FromStart { first, last },
818 body,
819 })
820 }
821 "lines" => {
822 let line_range = parse_line_range_header(&format!("lines={range_str}"))
823 .map_err(|_| format!("malformed Content-Range lines range: {range_str}"))?;
824 Ok(PatchMode::Lines {
825 range: line_range,
826 body,
827 })
828 }
829 other => Err(format!(
830 "Content-Range unit must be 'bytes' or 'lines', got: {other}"
831 )),
832 }
833}
834
835fn percent_encode_path(path: &str) -> String {
836 let mut encoded = String::with_capacity(path.len());
837 for &byte in path.as_bytes() {
838 match byte {
839 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
840 encoded.push(char::from(byte));
841 }
842 _ => {
843 let _ = write!(&mut encoded, "%{byte:02X}");
844 }
845 }
846 }
847 encoded
848}
849
850#[cfg(test)]
851mod patch_route {
852 use super::*;
853 use async_trait::async_trait;
854 use axum::body::{Body, to_bytes};
855 use notedthat_core::Storage;
856 use notedthat_core::{KbManifest, ListResponse, ObjectMeta, ObjectRead, PutOutcome};
857 use notedthat_indexer::IndexEvent;
858 use std::collections::BTreeMap;
859 use std::sync::Arc;
860 use tower::util::ServiceExt;
861
862 const KB: &str = "notes";
863 const OBJECT_PATH: &str = "patch.md";
864 const TOKEN: &str = "test-token-abc";
865
866 async fn router_with_object(
867 initial_body: &'static [u8],
868 max_patchable_size: u64,
869 ) -> (axum::Router, String) {
870 let kb = KbSlug::try_new(KB).unwrap();
871 let object_path = ObjectPath::try_from_str(OBJECT_PATH).unwrap();
872 let storage = Arc::new(crate::testing::InMemoryStorage::default());
873 let outcome = storage
874 .put_object(
875 &kb,
876 &object_path,
877 Bytes::from_static(initial_body),
878 Some("text/markdown"),
879 ConditionalHeaders::default(),
880 )
881 .await
882 .unwrap();
883
884 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(16);
885 let mut kbs = BTreeMap::new();
886 kbs.insert(KB.to_string(), kb);
887 let router = build_router(AppState {
888 storage,
889 declared_kbs: Arc::new(kbs),
890 bearer_token: Arc::new(TOKEN.to_string()),
891 max_body_size: MAX_BODY_BYTES,
892 max_patchable_size,
893 indexer_tx,
894 searcher: Arc::new(crate::testing::NoopSearcher),
895 });
896
897 (router, outcome.etag.unwrap())
898 }
899
900 async fn object_with_etag(
901 storage: &crate::testing::InMemoryStorage,
902 kb: &KbSlug,
903 body: &'static [u8],
904 ) -> String {
905 storage
906 .put_object(
907 kb,
908 &ObjectPath::try_from_str(OBJECT_PATH).unwrap(),
909 Bytes::from_static(body),
910 Some("text/markdown"),
911 ConditionalHeaders::default(),
912 )
913 .await
914 .unwrap()
915 .etag
916 .unwrap()
917 }
918
919 fn router_with_storage(
920 storage: Arc<dyn Storage>,
921 kb: KbSlug,
922 max_patchable_size: u64,
923 indexer_tx: tokio::sync::mpsc::Sender<IndexEvent>,
924 ) -> axum::Router {
925 let mut kbs = BTreeMap::new();
926 kbs.insert(KB.to_string(), kb);
927 build_router(AppState {
928 storage,
929 declared_kbs: Arc::new(kbs),
930 bearer_token: Arc::new(TOKEN.to_string()),
931 max_body_size: MAX_BODY_BYTES,
932 max_patchable_size,
933 indexer_tx,
934 searcher: Arc::new(crate::testing::NoopSearcher),
935 })
936 }
937
938 async fn patch_request(
939 router: axum::Router,
940 header_name: &'static str,
941 header_value: &str,
942 if_match: Option<&str>,
943 body: Bytes,
944 ) -> Response {
945 let mut builder = Request::builder()
946 .method("PATCH")
947 .uri(format!("/v1/knowledgebases/{KB}/{OBJECT_PATH}"))
948 .header("authorization", format!("Bearer {TOKEN}"))
949 .header(header_name, header_value);
950 if let Some(etag) = if_match {
951 builder = builder.header(axum::http::header::IF_MATCH, etag);
952 }
953
954 router
955 .oneshot(builder.body(Body::from(body)).unwrap())
956 .await
957 .unwrap()
958 }
959
960 async fn assert_error_code(response: Response, expected_status: StatusCode, expected: &str) {
961 assert_eq!(response.status(), expected_status);
962 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
963 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
964 assert_eq!(json["error"], expected);
965 }
966
967 #[tokio::test]
968 async fn bytes_content_range_returns_ok_with_etag_and_location() {
969 let (router, etag) = router_with_object(b"0123456789abcdefghij", MAX_BODY_BYTES).await;
970
971 let response = patch_request(
972 router,
973 "content-range",
974 "bytes 0-9/*",
975 Some(&etag),
976 Bytes::from_static(b"ABCDEFGHIJ"),
977 )
978 .await;
979
980 assert_eq!(response.status(), StatusCode::OK);
981 assert!(response.headers().get(axum::http::header::ETAG).is_some());
982 assert_eq!(
983 response
984 .headers()
985 .get(axum::http::header::LOCATION)
986 .unwrap(),
987 &format!("/v1/knowledgebases/{KB}/{OBJECT_PATH}")
988 );
989 assert!(
990 response
991 .headers()
992 .get(axum::http::header::CONTENT_RANGE)
993 .is_none()
994 );
995 assert!(response.headers().get("nt-patch-mode").is_none());
996 }
997
998 #[tokio::test]
999 async fn lines_content_range_returns_ok() {
1000 let (router, etag) = router_with_object(b"one\ntwo\nthree\nfour\n", MAX_BODY_BYTES).await;
1001
1002 let response = patch_request(
1003 router,
1004 "content-range",
1005 "lines 2-3/*",
1006 Some(&etag),
1007 Bytes::from_static(b"TWO\nTHREE\n"),
1008 )
1009 .await;
1010
1011 assert_eq!(response.status(), StatusCode::OK);
1012 }
1013
1014 #[tokio::test]
1015 async fn append_mode_without_if_match_returns_ok() {
1016 let (router, _etag) = router_with_object(b"one\n", MAX_BODY_BYTES).await;
1017
1018 let response = patch_request(
1019 router,
1020 "nt-patch-mode",
1021 "append",
1022 None,
1023 Bytes::from_static(b"two\n"),
1024 )
1025 .await;
1026
1027 assert_eq!(response.status(), StatusCode::OK);
1028 }
1029
1030 #[tokio::test]
1031 async fn append_mode_with_if_match_returns_ok() {
1032 let (router, etag) = router_with_object(b"one\n", MAX_BODY_BYTES).await;
1033
1034 let response = patch_request(
1035 router,
1036 "nt-patch-mode",
1037 "append",
1038 Some(&etag),
1039 Bytes::from_static(b"two\n"),
1040 )
1041 .await;
1042
1043 assert_eq!(response.status(), StatusCode::OK);
1044 }
1045
1046 #[tokio::test]
1047 async fn bytes_content_range_without_if_match_returns_invalid_request() {
1048 let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
1049
1050 let response = patch_request(
1051 router,
1052 "content-range",
1053 "bytes 0-1/*",
1054 None,
1055 Bytes::from_static(b"AB"),
1056 )
1057 .await;
1058
1059 assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
1060 }
1061
1062 #[tokio::test]
1063 async fn if_match_star_returns_invalid_request() {
1064 let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
1065
1066 let response = patch_request(
1067 router,
1068 "content-range",
1069 "bytes 0-1/*",
1070 Some("*"),
1071 Bytes::from_static(b"AB"),
1072 )
1073 .await;
1074
1075 assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
1076 }
1077
1078 #[tokio::test]
1079 async fn multi_value_if_match_returns_invalid_request() {
1080 let (router, etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
1081
1082 let response = patch_request(
1083 router,
1084 "content-range",
1085 "bytes 0-1/*",
1086 Some(&format!("{etag}, \"other\"")),
1087 Bytes::from_static(b"AB"),
1088 )
1089 .await;
1090
1091 assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
1092 }
1093
1094 #[tokio::test]
1095 async fn nonexistent_object_returns_not_found() {
1096 let (router, etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
1097
1098 let response = router
1099 .oneshot(
1100 Request::builder()
1101 .method("PATCH")
1102 .uri(format!("/v1/knowledgebases/{KB}/missing.md"))
1103 .header("authorization", format!("Bearer {TOKEN}"))
1104 .header("content-range", "bytes 0-1/*")
1105 .header(axum::http::header::IF_MATCH, etag)
1106 .body(Body::from(Bytes::from_static(b"AB")))
1107 .unwrap(),
1108 )
1109 .await
1110 .unwrap();
1111
1112 assert_error_code(response, StatusCode::NOT_FOUND, "not_found").await;
1113 }
1114
1115 #[tokio::test]
1116 async fn body_larger_than_max_patchable_size_returns_payload_too_large() {
1117 let (router, _etag) = router_with_object(b"one\n", 4).await;
1118
1119 let response = patch_request(
1120 router,
1121 "nt-patch-mode",
1122 "append",
1123 None,
1124 Bytes::from_static(b"abcde"),
1125 )
1126 .await;
1127
1128 assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
1129 }
1130
1131 mod errors {
1132 use super::*;
1133
1134 #[derive(Clone)]
1135 struct PutPreconditionFailedStorage {
1136 inner: crate::testing::InMemoryStorage,
1137 }
1138
1139 #[async_trait]
1140 impl Storage for PutPreconditionFailedStorage {
1141 async fn ensure_bucket(&self, kb: &KbSlug) -> Result<(), StorageError> {
1142 self.inner.ensure_bucket(kb).await
1143 }
1144
1145 async fn read_manifest(&self, kb: &KbSlug) -> Result<KbManifest, StorageError> {
1146 self.inner.read_manifest(kb).await
1147 }
1148
1149 async fn write_manifest(
1150 &self,
1151 kb: &KbSlug,
1152 manifest: &KbManifest,
1153 ) -> Result<(), StorageError> {
1154 self.inner.write_manifest(kb, manifest).await
1155 }
1156
1157 async fn head_object(
1158 &self,
1159 kb: &KbSlug,
1160 path: &ObjectPath,
1161 conditionals: ConditionalHeaders,
1162 ) -> Result<ObjectMeta, StorageError> {
1163 self.inner.head_object(kb, path, conditionals).await
1164 }
1165
1166 async fn get_object(
1167 &self,
1168 kb: &KbSlug,
1169 path: &ObjectPath,
1170 range: Option<Vec<ByteRange>>,
1171 conditionals: ConditionalHeaders,
1172 ) -> Result<ObjectRead, StorageError> {
1173 self.inner.get_object(kb, path, range, conditionals).await
1174 }
1175
1176 async fn put_object(
1177 &self,
1178 _kb: &KbSlug,
1179 _path: &ObjectPath,
1180 _bytes: Bytes,
1181 _content_type: Option<&str>,
1182 _conditionals: ConditionalHeaders,
1183 ) -> Result<PutOutcome, StorageError> {
1184 Err(StorageError::PreconditionFailed)
1185 }
1186
1187 async fn delete_object(
1188 &self,
1189 kb: &KbSlug,
1190 path: &ObjectPath,
1191 conditionals: ConditionalHeaders,
1192 ) -> Result<(), StorageError> {
1193 self.inner.delete_object(kb, path, conditionals).await
1194 }
1195
1196 async fn list_objects(
1197 &self,
1198 kb: &KbSlug,
1199 prefix: Option<&str>,
1200 limit: u32,
1201 cursor: Option<&str>,
1202 ) -> Result<ListResponse, StorageError> {
1203 self.inner.list_objects(kb, prefix, limit, cursor).await
1204 }
1205 }
1206
1207 #[tokio::test]
1208 async fn missing_if_match_for_bytes_mode_returns_invalid_request() {
1209 let (router, _etag) = router_with_object(b"0123456789", MAX_BODY_BYTES).await;
1210
1211 let response = patch_request(
1212 router,
1213 "content-range",
1214 "bytes 0-9/*",
1215 None,
1216 Bytes::from_static(b"ABCDEFGHIJ"),
1217 )
1218 .await;
1219
1220 assert_error_code(response, StatusCode::BAD_REQUEST, "invalid_request").await;
1221 }
1222
1223 #[tokio::test]
1224 async fn body_larger_than_max_patchable_size_returns_payload_too_large() {
1225 let (router, _etag) = router_with_object(b"one\n", 10).await;
1226
1227 let response = patch_request(
1228 router,
1229 "nt-patch-mode",
1230 "append",
1231 None,
1232 Bytes::from_static(b"more than ten bytes"),
1233 )
1234 .await;
1235
1236 assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
1237 }
1238
1239 #[tokio::test]
1240 async fn pre_splice_object_larger_than_max_patchable_size_returns_payload_too_large() {
1241 let (router, _etag) = router_with_object(b"already too large", 10).await;
1242
1243 let response = patch_request(
1244 router,
1245 "nt-patch-mode",
1246 "append",
1247 None,
1248 Bytes::from_static(b"!"),
1249 )
1250 .await;
1251
1252 assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
1253 }
1254
1255 #[tokio::test]
1256 async fn post_splice_body_larger_than_max_patchable_size_returns_payload_too_large() {
1257 let (router, _etag) = router_with_object(b"123456", 10).await;
1258
1259 let response = patch_request(
1260 router,
1261 "nt-patch-mode",
1262 "append",
1263 None,
1264 Bytes::from_static(b"78901"),
1265 )
1266 .await;
1267
1268 assert_error_code(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await;
1269 }
1270
1271 #[tokio::test]
1272 async fn line_range_out_of_bounds_returns_dual_416_headers_and_empty_body() {
1273 let (router, etag) =
1274 router_with_object(b"one\ntwo\nthree\nfour\nfive\n", MAX_BODY_BYTES).await;
1275
1276 let response = patch_request(
1277 router,
1278 "content-range",
1279 "lines 100-200/*",
1280 Some(&etag),
1281 Bytes::from_static(b"replacement\n"),
1282 )
1283 .await;
1284
1285 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1286 assert_eq!(
1287 response.headers().get("content-range").unwrap(),
1288 "lines */5"
1289 );
1290 assert_eq!(
1291 response.headers().get("x-content-range-bytes").unwrap(),
1292 "*/24"
1293 );
1294 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1295 assert!(body.is_empty());
1296 }
1297
1298 #[tokio::test]
1299 async fn if_match_mismatch_after_retries_returns_precondition_failed_without_content_range()
1300 {
1301 let kb = KbSlug::try_new(KB).unwrap();
1302 let inner = crate::testing::InMemoryStorage::default();
1303 let etag = object_with_etag(&inner, &kb, b"0123456789").await;
1304 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(16);
1305 let router = router_with_storage(
1306 Arc::new(PutPreconditionFailedStorage { inner }),
1307 kb,
1308 MAX_BODY_BYTES,
1309 indexer_tx,
1310 );
1311
1312 let response = patch_request(
1313 router,
1314 "content-range",
1315 "bytes 0-1/*",
1316 Some(&etag),
1317 Bytes::from_static(b"AB"),
1318 )
1319 .await;
1320
1321 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
1322 assert!(
1323 response
1324 .headers()
1325 .get(axum::http::header::CONTENT_RANGE)
1326 .is_none()
1327 );
1328 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1329 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1330 assert_eq!(json["error"], "precondition_failed");
1331 }
1332
1333 #[tokio::test]
1334 async fn indexer_queue_full_returns_backend_unavailable_with_retry_after() {
1335 let kb = KbSlug::try_new(KB).unwrap();
1336 let storage = crate::testing::InMemoryStorage::default();
1337 let etag = object_with_etag(&storage, &kb, b"one\n").await;
1338 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1339 indexer_tx
1340 .try_send(IndexEvent::Upsert {
1341 kb: kb.clone(),
1342 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1343 etag: "queued".to_string(),
1344 mtime: 0,
1345 })
1346 .unwrap();
1347 let router = router_with_storage(Arc::new(storage), kb, MAX_BODY_BYTES, indexer_tx);
1348
1349 let response = patch_request(
1350 router,
1351 "nt-patch-mode",
1352 "append",
1353 Some(&etag),
1354 Bytes::from_static(b"two\n"),
1355 )
1356 .await;
1357
1358 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1359 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1360 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1361 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1362 assert_eq!(json["error"], "backend_unavailable");
1363 }
1364 }
1365}
1366
1367#[cfg(test)]
1368mod line_range_get {
1369 use super::*;
1370 use axum::body::{Body, to_bytes};
1371 use notedthat_core::Storage;
1372 use std::collections::BTreeMap;
1373 use std::sync::Arc;
1374 use tower::util::ServiceExt;
1375
1376 const KB: &str = "notes";
1377 const TOKEN: &str = "test-token-abc";
1378
1379 fn twenty_line_markdown() -> String {
1380 markdown_lines(1, 20)
1381 }
1382
1383 fn markdown_lines(first: u32, last: u32) -> String {
1384 let mut body = String::new();
1385 for line in first..=last {
1386 std::fmt::Write::write_fmt(&mut body, format_args!("line {line:02}\n")).unwrap();
1387 }
1388 body
1389 }
1390
1391 async fn router_with_markdown_object(body: String) -> axum::Router {
1392 let kb = KbSlug::try_new(KB).unwrap();
1393 let object_path = ObjectPath::try_from_str("ranges.md").unwrap();
1394 let storage = Arc::new(crate::testing::InMemoryStorage::default());
1395 storage
1396 .put_object(
1397 &kb,
1398 &object_path,
1399 Bytes::from(body),
1400 Some("text/markdown"),
1401 ConditionalHeaders::default(),
1402 )
1403 .await
1404 .unwrap();
1405
1406 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1407 let mut kbs = BTreeMap::new();
1408 kbs.insert(KB.to_string(), kb);
1409 build_router(AppState {
1410 storage,
1411 declared_kbs: Arc::new(kbs),
1412 bearer_token: Arc::new(TOKEN.to_string()),
1413 max_body_size: MAX_BODY_BYTES,
1414 max_patchable_size: MAX_BODY_BYTES,
1415 indexer_tx,
1416 searcher: Arc::new(crate::testing::NoopSearcher),
1417 })
1418 }
1419
1420 async fn get_ranges_md(router: axum::Router, range: &str) -> Response {
1421 router
1422 .oneshot(
1423 Request::builder()
1424 .method("GET")
1425 .uri(format!("/v1/knowledgebases/{KB}/ranges.md"))
1426 .header("authorization", format!("Bearer {TOKEN}"))
1427 .header(axum::http::header::RANGE, range)
1428 .body(Body::empty())
1429 .unwrap(),
1430 )
1431 .await
1432 .unwrap()
1433 }
1434
1435 #[tokio::test]
1436 async fn returns_first_five_lines_when_closed_range_requested() {
1437 let router = router_with_markdown_object(twenty_line_markdown()).await;
1438
1439 let response = get_ranges_md(router, "lines=1-5").await;
1440
1441 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1442 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1443 assert_eq!(body, Bytes::from(markdown_lines(1, 5)));
1444 }
1445
1446 #[tokio::test]
1447 async fn returns_last_three_lines_when_suffix_range_requested() {
1448 let router = router_with_markdown_object(twenty_line_markdown()).await;
1449
1450 let response = get_ranges_md(router, "lines=-3").await;
1451
1452 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1453 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1454 assert_eq!(body, Bytes::from(markdown_lines(18, 20)));
1455 }
1456
1457 #[tokio::test]
1458 async fn returns_empty_body_when_insert_range_requested() {
1459 let router = router_with_markdown_object(twenty_line_markdown()).await;
1460
1461 let response = get_ranges_md(router, "lines=5-4").await;
1462
1463 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1464 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1465 assert!(body.is_empty());
1466 }
1467
1468 #[tokio::test]
1469 async fn returns_full_body_when_unknown_range_unit_requested() {
1470 let body = twenty_line_markdown();
1471 let router = router_with_markdown_object(body.clone()).await;
1472
1473 let response = get_ranges_md(router, "items=0-5").await;
1474
1475 assert_eq!(response.status(), StatusCode::OK);
1476 let actual = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1477 assert_eq!(actual, Bytes::from(body));
1478 }
1479
1480 mod headers {
1481 use super::*;
1482
1483 fn ten_line_markdown() -> String {
1484 markdown_lines(1, 10)
1485 }
1486
1487 #[tokio::test]
1488 async fn returns_line_and_byte_content_ranges_when_closed_range_requested() {
1489 let router = router_with_markdown_object(ten_line_markdown()).await;
1490
1491 let response = get_ranges_md(router, "lines=2-4").await;
1492
1493 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1494 assert_eq!(
1495 response.headers().get("Content-Range").unwrap(),
1496 "lines 2-4/10"
1497 );
1498 assert_eq!(
1499 response.headers().get("X-Content-Range-Bytes").unwrap(),
1500 "8-31/80"
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn returns_slice_content_length_when_closed_range_requested() {
1506 let router = router_with_markdown_object(ten_line_markdown()).await;
1507
1508 let response = get_ranges_md(router, "lines=2-4").await;
1509
1510 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1511 assert_eq!(response.headers().get("content-length").unwrap(), "24");
1512 }
1513
1514 #[tokio::test]
1515 async fn returns_zero_length_and_empty_byte_range_when_insert_range_requested() {
1516 let router = router_with_markdown_object(ten_line_markdown()).await;
1517
1518 let response = get_ranges_md(router, "lines=5-4").await;
1519
1520 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1521 assert_eq!(response.headers().get("content-length").unwrap(), "0");
1522 assert_eq!(
1523 response.headers().get("Content-Range").unwrap(),
1524 "lines 5-4/10"
1525 );
1526 assert_eq!(
1527 response.headers().get("X-Content-Range-Bytes").unwrap(),
1528 "32-31/80"
1529 );
1530 }
1531
1532 #[tokio::test]
1533 async fn omits_line_byte_range_header_when_byte_range_requested() {
1534 let router = router_with_markdown_object(ten_line_markdown()).await;
1535
1536 let response = get_ranges_md(router, "bytes=0-9").await;
1537
1538 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
1539 assert!(response.headers().get("X-Content-Range-Bytes").is_none());
1540 }
1541 }
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546 use super::*;
1547 use axum::body::{Body, to_bytes};
1548 use notedthat_core::Storage;
1549 use notedthat_indexer::IndexEvent;
1550 use std::collections::BTreeMap;
1551 use std::sync::Arc;
1552 use tower::util::ServiceExt;
1553
1554 const KB: &str = "notes";
1555 const TOKEN: &str = "test-token-abc";
1556
1557 fn router() -> axum::Router {
1558 let kb = KbSlug::try_new(KB).unwrap();
1559 let mut kbs = BTreeMap::new();
1560 kbs.insert(KB.to_string(), kb);
1561 let (indexer_tx, mut rx) = tokio::sync::mpsc::channel(16);
1562 tokio::spawn(async move { while rx.recv().await.is_some() {} });
1563
1564 build_router(AppState {
1565 storage: Arc::new(crate::testing::InMemoryStorage::default()),
1566 declared_kbs: Arc::new(kbs),
1567 bearer_token: Arc::new(TOKEN.to_string()),
1568 max_body_size: MAX_BODY_BYTES,
1569 max_patchable_size: MAX_BODY_BYTES,
1570 indexer_tx,
1571 searcher: Arc::new(crate::testing::NoopSearcher),
1572 })
1573 }
1574
1575 async fn put_object(router: axum::Router, path: &str, body: &'static [u8]) -> Response {
1576 router
1577 .oneshot(
1578 Request::builder()
1579 .method("PUT")
1580 .uri(format!("/v1/knowledgebases/{KB}/{path}"))
1581 .header("authorization", format!("Bearer {TOKEN}"))
1582 .header(axum::http::header::CONTENT_TYPE, "text/markdown")
1583 .body(Body::from(Bytes::from_static(body)))
1584 .unwrap(),
1585 )
1586 .await
1587 .unwrap()
1588 }
1589
1590 async fn put_object_etag(router: axum::Router, path: &str, body: &'static [u8]) -> String {
1591 let response = put_object(router, path, body).await;
1592 assert_eq!(response.status(), StatusCode::CREATED);
1593 response
1594 .headers()
1595 .get(axum::http::header::ETAG)
1596 .unwrap()
1597 .to_str()
1598 .unwrap()
1599 .to_string()
1600 }
1601
1602 async fn get_object(router: axum::Router, path: &str) -> Response {
1603 router
1604 .oneshot(
1605 Request::builder()
1606 .method("GET")
1607 .uri(format!("/v1/knowledgebases/{KB}/{path}"))
1608 .header("authorization", format!("Bearer {TOKEN}"))
1609 .body(Body::empty())
1610 .unwrap(),
1611 )
1612 .await
1613 .unwrap()
1614 }
1615
1616 async fn post_replace(
1617 router: axum::Router,
1618 path: &str,
1619 if_match: &str,
1620 body: &'static [u8],
1621 ) -> Response {
1622 router
1623 .oneshot(
1624 Request::builder()
1625 .method("POST")
1626 .uri(format!("/v1/knowledgebases/{KB}/replace/{path}"))
1627 .header("authorization", format!("Bearer {TOKEN}"))
1628 .header(axum::http::header::CONTENT_TYPE, "application/json")
1629 .header(axum::http::header::IF_MATCH, if_match)
1630 .body(Body::from(Bytes::from_static(body)))
1631 .unwrap(),
1632 )
1633 .await
1634 .unwrap()
1635 }
1636
1637 #[tokio::test]
1638 async fn get_on_replace_prefixed_path_still_reads_object_via_catch_all() {
1639 let router = router();
1640 put_object_etag(router.clone(), "replace/foo.md", b"hi").await;
1641
1642 let response = get_object(router, "replace/foo.md").await;
1643
1644 assert_eq!(response.status(), StatusCode::OK);
1645 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1646 assert_eq!(&body[..], b"hi");
1647 }
1648
1649 #[tokio::test]
1650 async fn patch_on_replace_prefixed_path_still_reaches_patch_object() {
1651 let router = router();
1652 let etag = put_object_etag(router.clone(), "replace/bar.md", b"old\n").await;
1653
1654 let response = router
1655 .oneshot(
1656 Request::builder()
1657 .method("PATCH")
1658 .uri(format!("/v1/knowledgebases/{KB}/replace/bar.md"))
1659 .header("authorization", format!("Bearer {TOKEN}"))
1660 .header(axum::http::header::CONTENT_RANGE, "lines 1-1/*")
1661 .header(axum::http::header::IF_MATCH, etag)
1662 .body(Body::from(Bytes::from_static(b"new\n")))
1663 .unwrap(),
1664 )
1665 .await
1666 .unwrap();
1667
1668 assert_eq!(response.status(), StatusCode::OK);
1669 }
1670
1671 #[tokio::test]
1672 async fn put_and_delete_on_replace_prefixed_path_still_work() {
1673 let router = router();
1674 let put = put_object(router.clone(), "replace/delete.md", b"gone").await;
1675
1676 assert_eq!(put.status(), StatusCode::CREATED);
1677 let delete = router
1678 .oneshot(
1679 Request::builder()
1680 .method("DELETE")
1681 .uri(format!("/v1/knowledgebases/{KB}/replace/delete.md"))
1682 .header("authorization", format!("Bearer {TOKEN}"))
1683 .body(Body::empty())
1684 .unwrap(),
1685 )
1686 .await
1687 .unwrap();
1688 assert_eq!(delete.status(), StatusCode::NO_CONTENT);
1689 }
1690
1691 #[tokio::test]
1692 async fn post_on_non_replace_path_returns_404_not_found() {
1693 let response = router()
1694 .oneshot(
1695 Request::builder()
1696 .method("POST")
1697 .uri(format!("/v1/knowledgebases/{KB}/foo.md"))
1698 .header("authorization", format!("Bearer {TOKEN}"))
1699 .body(Body::empty())
1700 .unwrap(),
1701 )
1702 .await
1703 .unwrap();
1704
1705 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1706 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1707 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1708 assert_eq!(json["error"], "not_found");
1709 assert!(
1710 json["message"]
1711 .as_str()
1712 .unwrap()
1713 .contains("supported actions: 'replace/<path>'")
1714 );
1715 }
1716
1717 #[tokio::test]
1718 async fn post_on_replace_prefixed_path_dispatches_to_replace_handler() {
1719 let router = router();
1720 let etag = put_object_etag(router.clone(), "target.md", b"hello world").await;
1721
1722 let response = post_replace(
1723 router,
1724 "target.md",
1725 &etag,
1726 br#"{"old_string":"world","new_string":"planet"}"#,
1727 )
1728 .await;
1729
1730 assert_eq!(response.status(), StatusCode::OK);
1731 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1732 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1733 assert_eq!(json["match_count"], 1);
1734 }
1735
1736 #[tokio::test]
1737 async fn post_on_replace_replace_path_targets_the_replace_prefixed_object() {
1738 let router = router();
1739 let etag = put_object_etag(router.clone(), "replace/nested.md", b"foo bar").await;
1740
1741 let response = post_replace(
1742 router.clone(),
1743 "replace/nested.md",
1744 &etag,
1745 br#"{"old_string":"bar","new_string":"baz"}"#,
1746 )
1747 .await;
1748
1749 assert_eq!(response.status(), StatusCode::OK);
1750 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1751 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1752 assert_eq!(json["match_count"], 1);
1753 let get = get_object(router, "replace/nested.md").await;
1754 let body = to_bytes(get.into_body(), 64 * 1024).await.unwrap();
1755 assert_eq!(&body[..], b"foo baz");
1756 }
1757
1758 #[tokio::test]
1759 async fn test_conditional_put_503_then_naive_retry_412_keeps_object_stored() {
1760 let kb = KbSlug::try_new(KB).unwrap();
1761 let object_path = ObjectPath::try_from_str("cond.md").unwrap();
1762 let storage = Arc::new(crate::testing::InMemoryStorage::default());
1763
1764 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1765 indexer_tx
1766 .try_send(IndexEvent::Upsert {
1767 kb: kb.clone(),
1768 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1769 etag: "etag".to_string(),
1770 mtime: 0,
1771 })
1772 .unwrap();
1773
1774 let mut kbs = BTreeMap::new();
1775 kbs.insert(KB.to_string(), kb.clone());
1776 let state = AppState {
1777 storage: storage.clone(),
1778 declared_kbs: Arc::new(kbs),
1779 bearer_token: Arc::new(TOKEN.to_string()),
1780 max_body_size: MAX_BODY_BYTES,
1781 max_patchable_size: MAX_BODY_BYTES,
1782 indexer_tx,
1783 searcher: Arc::new(crate::testing::NoopSearcher),
1784 };
1785 let router = build_router(state);
1786
1787 let response = router
1788 .clone()
1789 .oneshot(
1790 Request::builder()
1791 .method("PUT")
1792 .uri(format!("/v1/knowledgebases/{KB}/cond.md"))
1793 .header("authorization", format!("Bearer {TOKEN}"))
1794 .header("if-none-match", "*")
1795 .body(Body::from(Bytes::from_static(b"first content")))
1796 .unwrap(),
1797 )
1798 .await
1799 .unwrap();
1800
1801 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1802 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1803 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1804 let body = String::from_utf8(body.to_vec()).unwrap();
1805 assert!(body.contains("\"error\":\"backend_unavailable\""));
1806 assert!(body.contains("object stored; indexer queue full — retry to re-enqueue"));
1807
1808 let stored = storage
1809 .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1810 .await
1811 .unwrap();
1812 assert_eq!(stored.bytes, Bytes::from_static(b"first content"));
1813
1814 let retry = router
1815 .oneshot(
1816 Request::builder()
1817 .method("PUT")
1818 .uri(format!("/v1/knowledgebases/{KB}/cond.md"))
1819 .header("authorization", format!("Bearer {TOKEN}"))
1820 .header("if-none-match", "*")
1821 .body(Body::from(Bytes::from_static(b"second content")))
1822 .unwrap(),
1823 )
1824 .await
1825 .unwrap();
1826
1827 assert_eq!(retry.status(), StatusCode::PRECONDITION_FAILED);
1828 assert!(retry.headers().get("retry-after").is_none());
1829
1830 let stored = storage
1831 .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1832 .await
1833 .unwrap();
1834 assert_eq!(stored.bytes, Bytes::from_static(b"first content"));
1835 }
1836
1837 #[tokio::test]
1838 async fn test_delete_returns_delete_specific_503_body_when_indexer_backpressure() {
1839 let kb = KbSlug::try_new(KB).unwrap();
1840 let object_path = ObjectPath::try_from_str("to-delete.md").unwrap();
1841 let storage = Arc::new(crate::testing::InMemoryStorage::default());
1842 storage
1843 .put_object(
1844 &kb,
1845 &object_path,
1846 Bytes::from_static(b"content"),
1847 Some("text/plain"),
1848 ConditionalHeaders::default(),
1849 )
1850 .await
1851 .unwrap();
1852
1853 let (indexer_tx, _rx) = tokio::sync::mpsc::channel(1);
1854 indexer_tx
1855 .try_send(IndexEvent::Upsert {
1856 kb: kb.clone(),
1857 object_key: ObjectPath::try_from_str("queued.md").unwrap(),
1858 etag: "etag".to_string(),
1859 mtime: 0,
1860 })
1861 .unwrap();
1862
1863 let mut kbs = BTreeMap::new();
1864 kbs.insert(KB.to_string(), kb.clone());
1865 let state = AppState {
1866 storage: storage.clone(),
1867 declared_kbs: Arc::new(kbs),
1868 bearer_token: Arc::new(TOKEN.to_string()),
1869 max_body_size: MAX_BODY_BYTES,
1870 max_patchable_size: MAX_BODY_BYTES,
1871 indexer_tx,
1872 searcher: Arc::new(crate::testing::NoopSearcher),
1873 };
1874 let router = build_router(state);
1875
1876 let response = router
1877 .oneshot(
1878 Request::builder()
1879 .method("DELETE")
1880 .uri(format!("/v1/knowledgebases/{KB}/to-delete.md"))
1881 .header("authorization", format!("Bearer {TOKEN}"))
1882 .body(Body::empty())
1883 .unwrap(),
1884 )
1885 .await
1886 .unwrap();
1887
1888 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1889 assert_eq!(response.headers().get("retry-after").unwrap(), "5");
1890 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1891 let body = String::from_utf8(body.to_vec()).unwrap();
1892 assert!(body.contains("\"error\":\"backend_unavailable\""));
1893 assert!(
1894 body.contains("\"message\":\"deleted from storage; retry to clear from search index\"")
1895 );
1896 assert!(!body.contains("object stored; indexer queue full — retry to re-enqueue"));
1897
1898 let deleted = storage
1899 .get_object(&kb, &object_path, None, ConditionalHeaders::default())
1900 .await;
1901 assert!(matches!(deleted, Err(StorageError::NotFound { .. })));
1902 }
1903}