fslite-server 0.1.0

HTTP adapter exposing fslite-core's FileSystem trait as a resource-oriented REST API, gated by a pluggable AuthProvider.
Documentation
mod support;

use axum::body::Body;
use axum::http::{Method, Request};
use fslite_core::{RequestContext, VirtualPath, WriteSource};
use fslite_server::app;
use http_body_util::BodyExt;
use tower::ServiceExt;

fn auth(builder: axum::http::request::Builder) -> axum::http::request::Builder {
    builder.header("authorization", format!("Bearer {}", support::TOKEN))
}

#[tokio::test]
async fn stat_returns_node_json_for_an_existing_file() {
    let (state, workspace_id) = support::fixture().await;
    let ctx = RequestContext::trusted(workspace_id);
    state
        .fs
        .write(
            &ctx,
            &VirtualPath::parse("/a.txt").unwrap(),
            WriteSource::from_bytes(b"hi".to_vec()),
            Default::default(),
        )
        .await
        .unwrap();

    let response = app(state)
        .oneshot(
            auth(Request::builder().uri(format!("/v1/workspaces/{workspace_id}/fs/a.txt")))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), 200);
    let body = response.into_body().collect().await.unwrap().to_bytes();
    let node: fslite_core::Node = serde_json::from_slice(&body).unwrap();
    assert_eq!(node.name, "a.txt");
    assert_eq!(node.logical_size, 2);
}

#[tokio::test]
async fn stat_missing_path_is_404_with_not_found_code() {
    let (state, workspace_id) = support::fixture().await;
    let response = app(state)
        .oneshot(
            auth(Request::builder().uri(format!("/v1/workspaces/{workspace_id}/fs/missing.txt")))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 404);
    let body = response.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["error"]["code"], "not_found");
}

#[tokio::test]
async fn head_reports_existence_with_no_body() {
    let (state, workspace_id) = support::fixture().await;
    let response = app(state)
        .oneshot(
            auth(
                Request::builder()
                    .method(Method::HEAD)
                    .uri(format!("/v1/workspaces/{workspace_id}/fs/missing.txt")),
            )
            .body(Body::empty())
            .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 404);
}

#[tokio::test]
async fn delete_removes_a_file() {
    let (state, workspace_id) = support::fixture().await;
    let ctx = RequestContext::trusted(workspace_id);
    let path = VirtualPath::parse("/a.txt").unwrap();
    state
        .fs
        .write(
            &ctx,
            &path,
            WriteSource::from_bytes(b"hi".to_vec()),
            Default::default(),
        )
        .await
        .unwrap();

    let response = app(state.clone())
        .oneshot(
            auth(
                Request::builder()
                    .method(Method::DELETE)
                    .uri(format!("/v1/workspaces/{workspace_id}/fs/a.txt")),
            )
            .body(Body::empty())
            .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 200);

    assert!(
        !state
            .fs
            .exists(&ctx, &path, Default::default())
            .await
            .unwrap()
    );
}

/// `expected_revision` round-tripping was, until this test, exercised on
/// only one route (`write`) across the entire suite — the exact gap that let
/// `append` silently drop it. This closes that gap for `remove`.
#[tokio::test]
async fn delete_with_stale_expected_revision_is_412() {
    let (state, workspace_id) = support::fixture().await;
    let ctx = RequestContext::trusted(workspace_id);
    let path = VirtualPath::parse("/a.txt").unwrap();
    state
        .fs
        .write(
            &ctx,
            &path,
            WriteSource::from_bytes(b"hi".to_vec()),
            Default::default(),
        )
        .await
        .unwrap();

    let app_router = app(state.clone());
    let revision = support::current_revision(app_router.clone(), workspace_id, "a.txt").await;

    let response = app_router
        .oneshot(
            auth(Request::builder().method(Method::DELETE).uri(format!(
                "/v1/workspaces/{workspace_id}/fs/a.txt?expected_revision={}",
                revision + 1
            )))
            .body(Body::empty())
            .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), 412);
    let body = response.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["error"]["code"], "revision_conflict");

    // The file must still exist — the stale-revision request must not have
    // gone through.
    assert!(
        state
            .fs
            .exists(&ctx, &path, Default::default())
            .await
            .unwrap()
    );
}