deckle-desktop 0.5.0

Deckle — open-source UI design tool with MCP server for AI agents
use std::time::Duration;

use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{header, header::HeaderName, HeaderValue, Method, Request, StatusCode};
use axum::middleware;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{patch, post, put};
use axum::Router;
use indexmap::IndexMap;
use tower_http::cors::CorsLayer;

use crate::file_store::FileStore;
use crate::types::{
    ArchiveResourceRequest, ArchiveResourceResponse, CreateFileRequest, CreateFileResponse,
    CreateFolderRequest, CreateFolderResponse, PatchResourceResponse, ResourcesBatchGetRequest,
    ResourcesBatchGetResponse, ALLOWED_ORIGIN,
};

fn cors_layer() -> CorsLayer {
    CorsLayer::new()
        .allow_origin(
            ALLOWED_ORIGIN
                .parse::<HeaderValue>()
                .expect("Invalid CORS origin"),
        )
        .allow_credentials(true)
        .allow_methods([
            Method::GET,
            Method::POST,
            Method::PUT,
            Method::PATCH,
            Method::DELETE,
            Method::OPTIONS,
        ])
        .allow_headers([
            "Content-Type".parse::<HeaderName>().unwrap(),
            "If-None-Match".parse::<HeaderName>().unwrap(),
            "X-FileId".parse::<HeaderName>().unwrap(),
            "X-ImageId".parse::<HeaderName>().unwrap(),
            "X-SourcePath".parse::<HeaderName>().unwrap(),
            "X-DestPath".parse::<HeaderName>().unwrap(),
        ])
        .max_age(Duration::from_secs(86400))
}

async fn patch_preflight_status(req: Request<Body>, next: middleware::Next) -> Response {
    let is_preflight = req.method() == Method::OPTIONS
        && req.headers().contains_key(header::ORIGIN)
        && req.headers().contains_key("access-control-request-method");

    let mut res = next.run(req).await;

    if is_preflight && res.status() == StatusCode::OK {
        *res.status_mut() = StatusCode::NO_CONTENT;
    }

    res
}

async fn user_settings() -> StatusCode {
    tracing::info!("[API] PUT /user/settings -> 204");
    StatusCode::NO_CONTENT
}

async fn feedback() -> StatusCode {
    tracing::info!("[API] POST /feedback -> 204");
    StatusCode::NO_CONTENT
}

async fn resources_batch_get(
    State(store): State<FileStore>,
    Json(body): Json<ResourcesBatchGetRequest>,
) -> Json<ResourcesBatchGetResponse> {
    tracing::info!("[API] POST /resources/batch-get -> 200");
    let mut changed = IndexMap::new();
    for resource in &body.resources {
        changed.insert(
            resource.id.clone(),
            store.build_resources_response(&resource.id),
        );
    }
    Json(ResourcesBatchGetResponse { changed })
}

async fn create_file(
    State(store): State<FileStore>,
    Json(body): Json<CreateFileRequest>,
) -> Json<CreateFileResponse> {
    let file_id = uuid::Uuid::new_v4().to_string();
    store.create_file(
        &file_id,
        body.file_name.as_deref(),
        body.folder_id.as_deref(),
        body.resources_id.as_deref(),
    );
    tracing::info!(
        "[API] POST /files -> 200 (id={}, name={:?})",
        file_id,
        body.file_name
    );
    Json(CreateFileResponse { id: file_id })
}

async fn create_folder(
    State(store): State<FileStore>,
    Json(body): Json<CreateFolderRequest>,
) -> Json<CreateFolderResponse> {
    let folder_id = uuid::Uuid::new_v4().to_string();
    let resource = store.create_folder(
        &folder_id,
        body.folder_name.as_deref(),
        body.parent_id.as_deref(),
    );
    tracing::info!(
        "[API] POST /folders -> 200 (id={}, name={:?})",
        folder_id,
        body.folder_name
    );
    Json(CreateFolderResponse {
        id: folder_id,
        resource,
    })
}

async fn resource_update(
    State(store): State<FileStore>,
    Path((resources_id, item_id)): Path<(String, String)>,
    Json(body): Json<serde_json::Value>,
) -> Response {
    tracing::info!(
        "[API] PATCH /resources/{}/items/{} -> ...",
        resources_id,
        item_id
    );

    let updated = store.update_resource(&item_id, |resource_val| {
        if let Some(resource_obj) = resource_val.as_object_mut() {
            if let Some(body_obj) = body.as_object() {
                for (key, value) in body_obj {
                    resource_obj.insert(key.clone(), value.clone());
                }
            }
        }
    });

    match updated {
        Some(resource) => {
            tracing::info!("[API]   -> 200");
            Json(PatchResourceResponse { resource }).into_response()
        }
        None => {
            tracing::info!("[API]   -> 404");
            (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({"error": "Resource not found"})),
            )
                .into_response()
        }
    }
}

async fn resource_archive(
    State(store): State<FileStore>,
    Path((resources_id, item_id)): Path<(String, String)>,
    Json(body): Json<ArchiveResourceRequest>,
) -> Json<ArchiveResourceResponse> {
    tracing::info!(
        "[API] POST /resources/{}/items/{}/archive -> 200",
        resources_id,
        item_id
    );

    let mut affected = vec![];
    let updated = store.update_resource(&item_id, |resource_val| {
        if let Some(obj) = resource_val.as_object_mut() {
            obj.insert("archived".to_string(), serde_json::json!(body.archived));
        }
    });
    if let Some(val) = updated {
        affected.push(val);
    }

    Json(ArchiveResourceResponse {
        resources: affected,
    })
}

async fn fallback_404(req: axum::extract::Request) -> impl IntoResponse {
    let method = req.method().clone();
    let uri = req.uri().clone();
    tracing::warn!("[API] 404 {} {}", method, uri);
    (
        StatusCode::NOT_FOUND,
        Json(serde_json::json!({"error": "Not found"})),
    )
}

pub fn router(store: FileStore) -> Router {
    Router::new()
        .route("/resources/batch-get", post(resources_batch_get))
        .route(
            "/resources/{resourcesId}/items/{itemId}",
            patch(resource_update),
        )
        .route(
            "/resources/{resourcesId}/items/{itemId}/archive",
            post(resource_archive),
        )
        .route("/files", post(create_file))
        .route("/folders", post(create_folder))
        .route("/user/settings", put(user_settings))
        .route("/feedback", post(feedback))
        .fallback(fallback_404)
        .layer(cors_layer())
        .layer(middleware::from_fn(patch_preflight_status))
        .with_state(store)
}