Skip to main content

notedthat_api_http/
router.rs

1//! Axum router builder and HTTP handlers for the `NotedThat` API.
2
3use 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
30/// Maximum body size for PUT requests: 16 MiB (D35).
31pub 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/// A [`MakeRequestId`] implementation that generates `UUIDv7` request IDs.
36#[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
47/// Build the complete axum [`Router`] with all routes and middleware.
48pub 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
84// ─── Health probes ──────────────────────────────────────────────────────────
85
86/// GET /healthz — liveness probe (no auth required).
87async fn healthz() -> impl IntoResponse {
88    Json(serde_json::json!({"status": "ok"}))
89}
90
91/// GET /readyz — readiness probe (no auth required, static 200 in M2).
92async fn readyz() -> impl IntoResponse {
93    Json(serde_json::json!({"status": "ok"}))
94}
95
96// ─── KB list ────────────────────────────────────────────────────────────────
97
98/// GET /v1/knowledgebases — list all declared knowledge bases.
99async 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// ─── Object list ────────────────────────────────────────────────────────────
105
106#[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
128/// `GET /v1/knowledgebases/{kb_slug}` — list objects in a KB.
129async 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
157// ─── Object CRUD ────────────────────────────────────────────────────────────
158
159/// `HEAD /v1/knowledgebases/{kb_slug}/{*object_path}` — return metadata, no body.
160async 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    // Range header intentionally NOT forwarded on HEAD (RFC 7233 §3.1).
175    // Scope-OUT: Conditional writes (`If-Match`, `If-None-Match`) that succeed at
176    // S3 but return 503 at the indexer queue leave a naive retry in a state
177    // where S3 may return 412 because the object now exists or its ETag changed.
178    // Clients using conditional headers MUST detect the 503 → 412 sequence and
179    // either accept the ghost state or use a stronger consistency mechanism. v1
180    // does not provide automatic replay/repair for conditional-write ghost
181    // states.
182    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    // Content-Length from metadata size, not body length (HEAD has no body).
206    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
213/// `GET /v1/knowledgebases/{kb_slug}/{*object_path}` — download an object.
214async 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
389/// `PUT /v1/knowledgebases/{kb_slug}/{*object_path}` — upload an object.
390async 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
581/// Dispatcher for POST on the object catch-all. Only `replace/<target-path>` is a defined
582/// action; every other POST returns 404 `not_found`.
583async 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
748/// `DELETE /v1/knowledgebases/{kb_slug}/{*object_path}` — delete an object (idempotent).
749async 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
777// ─── Helpers ────────────────────────────────────────────────────────────────
778
779/// Look up a [`KbSlug`] from `state.declared_kbs`, returning 404 if not found.
780pub(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
788/// Parse an [`ObjectPath`] from the raw path string extracted from the URL.
789fn 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}