mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
feat(server): add POST /api/archives/:archive_id/captures/text
- Add CaptureTextBody struct for title, body, and optional MIME type - Implement capture_text_handler with validation for empty fields and MIME type - Route text submissions to perform_text_capture() in background - Reuse existing capture job tracking and polling infrastructure - Default MIME type to text/markdown when not specified - Include route tests covering happy path, validation, auth, and error cases Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6c4994ae04
commit
81dc7519a8
1 changed files with 242 additions and 0 deletions
|
|
@ -275,6 +275,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
|
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
|
||||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||||
|
.route("/api/archives/:archive_id/captures/text", post(capture_text_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/uploads",
|
"/api/archives/:archive_id/uploads",
|
||||||
post(upload_handler)
|
post(upload_handler)
|
||||||
|
|
@ -922,6 +923,14 @@ struct CaptureBody {
|
||||||
sync: bool,
|
sync: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct CaptureTextBody {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
/// MIME type: "text/plain" or "text/markdown" (defaults to "text/markdown")
|
||||||
|
mime: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct ProbeQuery {
|
struct ProbeQuery {
|
||||||
locator: String,
|
locator: String,
|
||||||
|
|
@ -1187,6 +1196,102 @@ async fn capture_handler(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn capture_text_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(archive_id): Path<String>,
|
||||||
|
Json(body): Json<CaptureTextBody>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
|
||||||
|
// Validate title and body
|
||||||
|
if body.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("title must not be empty"));
|
||||||
|
}
|
||||||
|
if body.body.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("body must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type (default to markdown)
|
||||||
|
let mime = body.mime.as_deref().unwrap_or("text/markdown");
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
if mime != "text/plain" && mime != "text/markdown" {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"unsupported MIME type: must be 'text/plain' or 'text/markdown'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let archive_paths =
|
||||||
|
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
// Create job record in the archive DB.
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let job_uid = database::create_capture_job(&conn, &archive_id)?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Spawn background text capture.
|
||||||
|
let title = body.title.trim().to_string();
|
||||||
|
let text_body = body.body.trim().to_string();
|
||||||
|
let mime_str = mime.to_string();
|
||||||
|
let job_uid_bg = job_uid.clone();
|
||||||
|
let archive_path = mounted.archive_path.clone();
|
||||||
|
let archive_id_bg = archive_id.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = match database::open_or_initialize(&archive_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("warn: capture job {job_uid_bg}: db open failed: {e:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok();
|
||||||
|
|
||||||
|
match capture::perform_text_capture(
|
||||||
|
&archive_paths,
|
||||||
|
&title,
|
||||||
|
&text_body,
|
||||||
|
&mime_str,
|
||||||
|
Some(&archive_id_bg),
|
||||||
|
) {
|
||||||
|
Ok(result) => {
|
||||||
|
let job_status = if result.status == "completed" {
|
||||||
|
"completed"
|
||||||
|
} else {
|
||||||
|
"failed"
|
||||||
|
};
|
||||||
|
database::update_capture_job_status(
|
||||||
|
&conn,
|
||||||
|
&job_uid_bg,
|
||||||
|
job_status,
|
||||||
|
Some(&result.run_uid),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
database::update_capture_job_status(
|
||||||
|
&conn,
|
||||||
|
&job_uid_bg,
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
Some(&format!("{e:#}")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(serde_json::json!({ "job_uid": job_uid, "status": "pending" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn upload_handler(
|
async fn upload_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -3680,6 +3785,143 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_post_returns_accepted_with_job_uid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"Test content","mime":"text/markdown"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
json["job_uid"].as_str().is_some(),
|
||||||
|
"response must have job_uid"
|
||||||
|
);
|
||||||
|
assert_eq!(json["status"], "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_empty_title() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"","body":"Test content","mime":"text/plain"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("title must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_empty_body() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"","mime":"text/plain"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("body must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_unsupported_mime() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"Test content","mime":"text/html"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("unsupported MIME type")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_requires_auth() {
|
||||||
|
let (test_app, _dir) = make_test_app();
|
||||||
|
let response = test_app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test","body":"Content"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn probe_requires_auth() {
|
async fn probe_requires_auth() {
|
||||||
let (test_app, _dir) = make_test_app();
|
let (test_app, _dir) = make_test_app();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue