tandem-server 0.5.11

HTTP server for Tandem engine APIs
use reqwest::header::{ACCEPT, AUTHORIZATION};
use serde::Deserialize;
use std::fmt;

const DEFAULT_GOOGLE_API_BASE_URL: &str = "https://www.googleapis.com";
const DRIVE_FILES_PATH: &str = "/drive/v3/files";
const DRIVE_LIST_FIELDS: &str =
    "nextPageToken, files(id,name,mimeType,modifiedTime,md5Checksum,size,webViewLink)";

#[derive(Debug, Clone)]
pub struct GoogleDriveClient {
    http: reqwest::Client,
    api_base_url: String,
}

impl Default for GoogleDriveClient {
    fn default() -> Self {
        Self::new()
    }
}

impl GoogleDriveClient {
    pub fn new() -> Self {
        Self::new_with_base_url(DEFAULT_GOOGLE_API_BASE_URL)
    }

    pub fn new_with_base_url(api_base_url: impl Into<String>) -> Self {
        Self {
            http: reqwest::Client::new(),
            api_base_url: api_base_url.into().trim_end_matches('/').to_string(),
        }
    }

    pub fn new_from_env() -> Self {
        std::env::var("TANDEM_GOOGLE_DRIVE_API_BASE_URL")
            .ok()
            .map(|value| value.trim().to_string())
            .filter(|value| !value.is_empty())
            .map(Self::new_with_base_url)
            .unwrap_or_else(Self::new)
    }

    pub async fn list_folder_children(
        &self,
        bearer_token: &str,
        folder_id: &str,
        page_token: Option<&str>,
    ) -> Result<GoogleDriveListPage, GoogleDriveClientError> {
        let bearer_token = checked_bearer_token(bearer_token)?;
        let folder_id = checked_drive_id("folder_id", folder_id)?;
        let query = format!(
            "'{}' in parents and trashed = false",
            escape_drive_query_value(folder_id)
        );
        let mut request = self
            .http
            .get(format!("{}{}", self.api_base_url, DRIVE_FILES_PATH))
            .header(AUTHORIZATION, format!("Bearer {bearer_token}"))
            .header(ACCEPT, "application/json")
            .query(&[
                ("q", query.as_str()),
                ("fields", DRIVE_LIST_FIELDS),
                ("pageSize", "100"),
                ("supportsAllDrives", "true"),
                ("includeItemsFromAllDrives", "true"),
            ]);
        if let Some(page_token) = page_token.filter(|value| !value.trim().is_empty()) {
            request = request.query(&[("pageToken", page_token.trim())]);
        }
        let response = request
            .send()
            .await
            .map_err(GoogleDriveClientError::Transport)?;
        parse_json_response(response).await
    }

    pub async fn download_file_bytes(
        &self,
        bearer_token: &str,
        file_id: &str,
    ) -> Result<Vec<u8>, GoogleDriveClientError> {
        let bearer_token = checked_bearer_token(bearer_token)?;
        let file_id = checked_drive_id("file_id", file_id)?;
        let response = self
            .http
            .get(format!(
                "{}{}/{}",
                self.api_base_url,
                DRIVE_FILES_PATH,
                urlencoding::encode(file_id)
            ))
            .header(AUTHORIZATION, format!("Bearer {bearer_token}"))
            .query(&[("alt", "media")])
            .send()
            .await
            .map_err(GoogleDriveClientError::Transport)?;
        parse_bytes_response(response).await
    }

    pub async fn export_google_workspace_file(
        &self,
        bearer_token: &str,
        file_id: &str,
        mime_type: &str,
    ) -> Result<Vec<u8>, GoogleDriveClientError> {
        let bearer_token = checked_bearer_token(bearer_token)?;
        let file_id = checked_drive_id("file_id", file_id)?;
        let mime_type = checked_mime_type(mime_type)?;
        let response = self
            .http
            .get(format!(
                "{}{}/{}/export",
                self.api_base_url,
                DRIVE_FILES_PATH,
                urlencoding::encode(file_id)
            ))
            .header(AUTHORIZATION, format!("Bearer {bearer_token}"))
            .query(&[("mimeType", mime_type)])
            .send()
            .await
            .map_err(GoogleDriveClientError::Transport)?;
        parse_bytes_response(response).await
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GoogleDriveListPage {
    #[serde(default)]
    pub files: Vec<GoogleDriveFileMetadata>,
    #[serde(default)]
    pub next_page_token: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GoogleDriveFileMetadata {
    pub id: String,
    pub name: String,
    pub mime_type: String,
    #[serde(default)]
    pub modified_time: Option<String>,
    #[serde(default)]
    pub md5_checksum: Option<String>,
    #[serde(default)]
    pub size: Option<String>,
    #[serde(default)]
    pub web_view_link: Option<String>,
}

#[derive(Debug)]
pub enum GoogleDriveClientError {
    InvalidInput(&'static str),
    Transport(reqwest::Error),
    Http { status: reqwest::StatusCode },
    Decode(reqwest::Error),
}

impl fmt::Display for GoogleDriveClientError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidInput(field) => write!(formatter, "invalid Google Drive {field}"),
            Self::Transport(_) => write!(formatter, "Google Drive request failed"),
            Self::Http { status } => write!(formatter, "Google Drive returned HTTP {status}"),
            Self::Decode(_) => write!(formatter, "Google Drive response decode failed"),
        }
    }
}

impl std::error::Error for GoogleDriveClientError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Transport(error) | Self::Decode(error) => Some(error),
            Self::InvalidInput(_) | Self::Http { .. } => None,
        }
    }
}

async fn parse_json_response<T: for<'de> Deserialize<'de>>(
    response: reqwest::Response,
) -> Result<T, GoogleDriveClientError> {
    let status = response.status();
    if !status.is_success() {
        return Err(GoogleDriveClientError::Http { status });
    }
    response
        .json::<T>()
        .await
        .map_err(GoogleDriveClientError::Decode)
}

async fn parse_bytes_response(
    response: reqwest::Response,
) -> Result<Vec<u8>, GoogleDriveClientError> {
    let status = response.status();
    if !status.is_success() {
        return Err(GoogleDriveClientError::Http { status });
    }
    response
        .bytes()
        .await
        .map(|bytes| bytes.to_vec())
        .map_err(GoogleDriveClientError::Decode)
}

fn checked_bearer_token(value: &str) -> Result<&str, GoogleDriveClientError> {
    let value = value.trim();
    if value.is_empty() {
        Err(GoogleDriveClientError::InvalidInput("bearer_token"))
    } else {
        Ok(value)
    }
}

fn checked_drive_id<'a>(
    field: &'static str,
    value: &'a str,
) -> Result<&'a str, GoogleDriveClientError> {
    let value = value.trim();
    if value.is_empty() || value.contains('/') || value.contains('\\') {
        Err(GoogleDriveClientError::InvalidInput(field))
    } else {
        Ok(value)
    }
}

fn checked_mime_type(value: &str) -> Result<&str, GoogleDriveClientError> {
    let value = value.trim();
    if value.is_empty() || !value.contains('/') {
        Err(GoogleDriveClientError::InvalidInput("mime_type"))
    } else {
        Ok(value)
    }
}

fn escape_drive_query_value(value: &str) -> String {
    value.replace('\\', "\\\\").replace('\'', "\\'")
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Bytes;
    use axum::extract::{Path, Query};
    use axum::http::{HeaderMap, StatusCode};
    use axum::routing::get;
    use axum::{Json, Router};
    use serde_json::{json, Value};
    use std::collections::HashMap;
    use tokio::net::TcpListener;

    #[tokio::test]
    async fn list_folder_children_uses_read_only_drive_query() {
        let base_url = spawn_drive_fixture().await;
        let client = GoogleDriveClient::new_with_base_url(base_url);

        let page = client
            .list_folder_children("token-123", "folder-123", Some("next-page"))
            .await
            .expect("list page");

        assert_eq!(page.next_page_token.as_deref(), Some("next-token"));
        assert_eq!(page.files.len(), 1);
        assert_eq!(page.files[0].id, "file-1");
        assert_eq!(page.files[0].mime_type, "text/plain");
    }

    #[tokio::test]
    async fn download_and_export_use_expected_drive_endpoints() {
        let base_url = spawn_drive_fixture().await;
        let client = GoogleDriveClient::new_with_base_url(base_url);

        let bytes = client
            .download_file_bytes("token-123", "file-1")
            .await
            .expect("download bytes");
        assert_eq!(bytes, b"plain file bytes");

        let exported = client
            .export_google_workspace_file("token-123", "doc-1", "text/plain")
            .await
            .expect("export bytes");
        assert_eq!(exported, b"exported workspace doc");
    }

    #[tokio::test]
    async fn rejects_empty_tokens_and_path_like_ids() {
        let client = GoogleDriveClient::new_with_base_url("http://127.0.0.1:9");

        assert!(matches!(
            client.list_folder_children(" ", "folder-123", None).await,
            Err(GoogleDriveClientError::InvalidInput("bearer_token"))
        ));
        assert!(matches!(
            client.download_file_bytes("token-123", "../secret").await,
            Err(GoogleDriveClientError::InvalidInput("file_id"))
        ));
    }

    async fn spawn_drive_fixture() -> String {
        let app = Router::new()
            .route("/drive/v3/files", get(list_files))
            .route("/drive/v3/files/{file_id}", get(download_file))
            .route("/drive/v3/files/{file_id}/export", get(export_file));
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind fixture");
        let addr = listener.local_addr().expect("fixture addr");
        tokio::spawn(async move {
            axum::serve(listener, app).await.expect("fixture server");
        });
        format!("http://{addr}")
    }

    async fn list_files(
        headers: HeaderMap,
        Query(query): Query<HashMap<String, String>>,
    ) -> Json<Value> {
        assert_eq!(
            headers
                .get(AUTHORIZATION)
                .and_then(|value| value.to_str().ok()),
            Some("Bearer token-123")
        );
        assert_eq!(
            query.get("q").map(String::as_str),
            Some("'folder-123' in parents and trashed = false")
        );
        assert_eq!(
            query.get("pageToken").map(String::as_str),
            Some("next-page")
        );
        assert!(query
            .get("fields")
            .is_some_and(|fields| fields.contains("files(id,name,mimeType")));
        Json(json!({
            "nextPageToken": "next-token",
            "files": [{
                "id": "file-1",
                "name": "Note.txt",
                "mimeType": "text/plain",
                "modifiedTime": "2026-05-22T00:00:00Z",
                "md5Checksum": "abc123",
                "size": "16",
                "webViewLink": "https://drive.google.com/file/d/file-1/view"
            }]
        }))
    }

    async fn download_file(
        headers: HeaderMap,
        Path(file_id): Path<String>,
        Query(query): Query<HashMap<String, String>>,
    ) -> Result<Bytes, StatusCode> {
        assert_eq!(
            headers
                .get(AUTHORIZATION)
                .and_then(|value| value.to_str().ok()),
            Some("Bearer token-123")
        );
        assert_eq!(file_id, "file-1");
        assert_eq!(query.get("alt").map(String::as_str), Some("media"));
        Ok(Bytes::from_static(b"plain file bytes"))
    }

    async fn export_file(
        headers: HeaderMap,
        Path(file_id): Path<String>,
        Query(query): Query<HashMap<String, String>>,
    ) -> Result<Bytes, StatusCode> {
        assert_eq!(
            headers
                .get(AUTHORIZATION)
                .and_then(|value| value.to_str().ok()),
            Some("Bearer token-123")
        );
        assert_eq!(file_id, "doc-1");
        assert_eq!(
            query.get("mimeType").map(String::as_str),
            Some("text/plain")
        );
        Ok(Bytes::from_static(b"exported workspace doc"))
    }
}