From 389991cff37c15d21e6b475e6a09960b029ed38b Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 8 Apr 2026 08:48:24 +0100 Subject: [PATCH] Remove ggsql REST binary --- CLAUDE.md | 97 +------ Cargo.lock | 113 +------- Cargo.toml | 4 - INSTALLERS.md | 52 ++-- src/Cargo.toml | 13 - src/rest.rs | 696 ------------------------------------------------- 6 files changed, 26 insertions(+), 949 deletions(-) delete mode 100644 src/rest.rs diff --git a/CLAUDE.md b/CLAUDE.md index 79552592..d9667ddd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -643,81 +643,6 @@ impl Writer for VegaLiteWriter { --- -### 4. REST API (`src/rest.rs`) - -**Responsibility**: HTTP interface for executing ggsql queries. - -**Technology**: Axum web framework with CORS support - -**Endpoints**: - -```rust -// POST /api/v1/query - Execute ggsql query -// Request: -{ - "query": "SELECT ... VISUALISE ...", - "reader": "duckdb://memory", // optional, default - "writer": "vegalite" // optional, default -} - -// Response (success): -{ - "status": "success", - "data": { - "spec": { /* Vega-Lite JSON */ }, - "metadata": { - "rows": 100, - "columns": ["date", "revenue", "region", "..."], - "global_mapping": "Mappings", - "layers": 2 - } - } -} - -// Response (error): -{ - "status": "error", - "error": { - "type": "ParseError", - "message": "..." - } -} -``` - -**Additional Endpoints**: - -- `GET /` - Root endpoint (returns API information and status) -- `POST /api/v1/parse` - Parse query and return AST (debugging) -- `GET /api/v1/health` - Health check -- `GET /api/v1/version` - Version info - -**CORS Configuration**: Allows cross-origin requests for web frontends - -**CLI Options**: - -```bash -# Basic usage -ggsql-rest --host 127.0.0.1 --port 3334 - -# With sample data (pre-loaded products, sales, employees tables) -ggsql-rest --load-sample-data - -# Load custom data files (CSV, Parquet, JSON) -ggsql-rest --load-data data.csv --load-data other.parquet - -# Configure CORS origins -ggsql-rest --cors-origin "http://localhost:5173,http://localhost:3000" -``` - -**Sample Data Loading**: - -- `--load-sample-data`: Loads built-in sample data (products, sales, employees) -- `--load-data `: Loads data from CSV, Parquet, or JSON files into in-memory database -- Multiple `--load-data` flags can be used to load multiple files -- Pre-loaded data persists for the lifetime of the server session - ---- - ### 5. CLI (`src/cli.rs`) **Responsibility**: Command-line interface for local query execution. @@ -1059,12 +984,6 @@ ggsql uses Cargo feature flags to enable optional functionality and reduce binar - `plotters` - Enable plotters-based rendering (planned, not implemented) - `all-writers` - Enable all writer implementations -**API features**: - -- `rest-api` - Enable REST API server (`ggsql-rest` binary) - - Includes: `axum`, `tokio`, `tower-http`, `tracing`, `duckdb`, `vegalite` - - Required for building `ggsql-rest` server - **Python bindings**: - `ggsql-python` - Python bindings via PyO3 (separate crate, not a feature flag) @@ -1078,9 +997,6 @@ cargo build --no-default-features # With specific features cargo build --features "duckdb,vegalite" -# REST API server -cargo build --bin ggsql-rest --features rest-api - # All features cargo build --all-features ``` @@ -1092,7 +1008,6 @@ cargo build --all-features - `duckdb` → `duckdb` crate - `postgres` → `postgres` crate (future) - `sqlite` → `rusqlite` crate (future) -- `rest-api` → `axum`, `tokio`, `tower-http`, `tracing`, `tracing-subscriber` - `ggsql-python` → `pyo3`, `narwhals`, `altair` (separate workspace crate) --- @@ -1112,7 +1027,6 @@ ggsql uses [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) to **What Gets Packaged**: - ✅ `ggsql` CLI binary only -- ❌ `ggsql-rest` API server (install separately with `cargo install ggsql --features rest-api`) - ❌ `ggsql-jupyter` kernel (install separately with `cargo install ggsql-jupyter`) ### Release Process @@ -1140,6 +1054,7 @@ ggsql uses [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) to - Generate release notes **Manual Workflow Trigger** (for testing): + ```bash gh workflow run release-installers.yml ``` @@ -1199,7 +1114,7 @@ Where `` can be: | `PLACE` | ✅ Yes | Annotation layers | `PLACE point SETTING x => 5, y => 10` | | `SCALE` | ✅ Yes | Configure scales | `SCALE x VIA date` | | `FACET` | ❌ No | Small multiples | `FACET region` | -| `PROJECT` | ❌ No | Coordinate system | `PROJECT TO cartesian` | +| `PROJECT` | ❌ No | Coordinate system | `PROJECT TO cartesian` | | `LABEL` | ❌ No | Text labels | `LABEL title => 'My Chart', x => 'Date'` | | `THEME` | ❌ No | Visual styling | `THEME minimal` | @@ -1488,10 +1403,10 @@ PROJECT [, ...] TO **Coordinate Types**: -| Coord Type | Default Aesthetics | Description | -|------------|-------------------|-------------| -| `cartesian` | `x`, `y` | Standard x/y Cartesian coordinates | -| `polar` | `angle`, `radius` | Polar coordinates (for pie charts, rose plots) | +| Coord Type | Default Aesthetics | Description | +| ----------- | ------------------ | ---------------------------------------------- | +| `cartesian` | `x`, `y` | Standard x/y Cartesian coordinates | +| `polar` | `angle`, `radius` | Polar coordinates (for pie charts, rose plots) | **Flipping Axes**: diff --git a/Cargo.lock b/Cargo.lock index b4828bb5..32029591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -466,61 +466,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.22.1" @@ -1710,7 +1655,6 @@ version = "0.1.9" dependencies = [ "anyhow", "arrow", - "axum", "chrono", "clap", "const_format", @@ -1731,10 +1675,6 @@ dependencies = [ "serde_json", "sprintf", "thiserror 1.0.69", - "tokio", - "tower-http 0.5.2", - "tracing", - "tracing-subscriber", "tree-sitter", "tree-sitter-ggsql", "ureq", @@ -1974,12 +1914,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "humantime" version = "2.3.0" @@ -2000,7 +1934,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2523,12 +2456,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "md-5" version = "0.10.6" @@ -2563,12 +2490,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4162,7 +4083,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.8", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -4203,7 +4124,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower", - "tower-http 0.6.8", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -4547,17 +4468,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_stacker" version = "0.1.14" @@ -5141,24 +5051,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", ] [[package]] @@ -5197,7 +5089,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 9b4f3eea..bba38f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,11 +70,7 @@ chrono = "0.4" rand = "0.8" const_format = "0.2" uuid = { version = "1.0", features = ["v4"] } - -# Web server -axum = "0.7" tokio = { version = "1.35", default-features = false } -tower-http = { version = "0.5", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/INSTALLERS.md b/INSTALLERS.md index ca9f4938..d018ca2b 100644 --- a/INSTALLERS.md +++ b/INSTALLERS.md @@ -7,6 +7,7 @@ ggsql uses [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) to ### Prerequisites 1. **Install cargo-packager**: + ```bash cargo install cargo-packager --locked ``` @@ -39,23 +40,20 @@ Output location: `src/target/release/packager/` ## Available Formats -| Platform | Format | Command | Output | -|----------|--------|---------|--------| -| **Windows** | NSIS | `--formats nsis` | `ggsql_0.1.0_x64-setup.exe` (12MB) | -| **Windows** | MSI | `--formats wix` | `ggsql_0.1.0_x64_en-US.msi` (15MB) | -| **macOS** | DMG | `--formats dmg` | `ggsql_0.1.0_x64.dmg` | -| **macOS** | App Bundle | `--formats app` | `ggsql.app` | -| **Linux** | Debian | `--formats deb` | `ggsql_0.1.0_amd64.deb` | +| Platform | Format | Command | Output | +| ----------- | ---------- | ---------------- | ---------------------------------- | +| **Windows** | NSIS | `--formats nsis` | `ggsql_0.1.0_x64-setup.exe` (12MB) | +| **Windows** | MSI | `--formats wix` | `ggsql_0.1.0_x64_en-US.msi` (15MB) | +| **macOS** | DMG | `--formats dmg` | `ggsql_0.1.0_x64.dmg` | +| **macOS** | App Bundle | `--formats app` | `ggsql.app` | +| **Linux** | Debian | `--formats deb` | `ggsql_0.1.0_amd64.deb` | ## What Gets Packaged The installers include: - ✅ **ggsql** - Main CLI binary - -**Not included** (install separately if needed): -- **ggsql-rest** - REST API server: `cargo install ggsql --features rest-api` -- **ggsql-jupyter** - Jupyter kernel: `cargo install ggsql-jupyter` +- ✅ **ggsql-jupyter** - ggsql Jupyter kernel ## Configuration @@ -71,7 +69,6 @@ icons = ["../doc/assets/icon.svg", "../doc/assets/logo.png"] license-file = "../LICENSE.md" binaries = [ { path = "ggsql", main = true }, - { path = "ggsql-rest", main = false }, ] ``` @@ -97,11 +94,12 @@ You can also trigger builds manually from the Actions tab. ### Windows **Option 1: NSIS Installer (Recommended for users)** + - Double-click `ggsql_0.1.0_x64-setup.exe` -- Choose components (CLI only, or CLI + REST API) - Automatically adds to PATH **Option 2: MSI Installer (Recommended for enterprises)** + - Double-click `ggsql_0.1.0_x64_en-US.msi` - Follows Windows Installer standards - Supports silent installation: `msiexec /i ggsql_0.1.0_x64_en-US.msi /quiet` @@ -109,6 +107,7 @@ You can also trigger builds manually from the Actions tab. ### macOS **DMG Installer** + - Open `ggsql_0.1.0_x64.dmg` - Drag `ggsql.app` to Applications folder - Or copy binaries directly to `/usr/local/bin` @@ -116,6 +115,7 @@ You can also trigger builds manually from the Actions tab. ### Linux **Debian/Ubuntu** (.deb): + ```bash sudo dpkg -i ggsql_0.1.0_amd64.deb ``` @@ -125,19 +125,20 @@ sudo dpkg -i ggsql_0.1.0_amd64.deb After building an installer, test it: ### Windows + ```powershell # Install .\ggsql_0.1.0_x64-setup.exe # Verify ggsql --version -ggsql-rest --version # if installed # Uninstall # Settings → Apps → Find "ggsql" → Uninstall ``` ### macOS + ```bash # Install hdiutil attach ggsql_0.1.0_x64.dmg @@ -148,6 +149,7 @@ cp -r /Volumes/ggsql/ggsql.app /Applications/ ``` ### Linux + ```bash # Debian sudo dpkg -i ggsql_0.1.0_amd64.deb @@ -166,6 +168,7 @@ This happens with unsigned installers. Click "More info" → "Run anyway". For p ### macOS: "ggsql.app is damaged" This happens with unsigned apps. Run: + ```bash xattr -cr /Applications/ggsql.app ``` @@ -173,6 +176,7 @@ xattr -cr /Applications/ggsql.app ### Linux: Missing dependencies If the Deb/RPM package fails to install, ensure you have the required system libraries: + ```bash sudo apt-get install -f # Debian/Ubuntu sudo dnf install # Fedora @@ -180,26 +184,6 @@ sudo dnf install # Fedora ## Advanced: Custom Builds -### Include Jupyter kernel - -By default, ggsql-jupyter is not included (it requires Python). To package it: - -1. Build ggsql-jupyter: - ```bash - cargo build --release --package ggsql-jupyter - ``` - -2. Update `src/Cargo.toml`: - ```toml - binaries = [ - { path = "ggsql", main = true }, - { path = "ggsql-rest", main = false }, - { path = "../target/release/ggsql-jupyter", main = false }, - ] - ``` - -3. Rebuild the installer - ### Cross-compilation Build for different architectures: diff --git a/src/Cargo.toml b/src/Cargo.toml index 4f45fea2..38dc04e6 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -16,11 +16,6 @@ path = "lib.rs" name = "ggsql" path = "cli.rs" -[[bin]] -name = "ggsql-rest" -path = "rest.rs" -required-features = ["rest-api"] - [dependencies] # Parsing tree-sitter.workspace = true @@ -60,13 +55,6 @@ sprintf = "0.4" const_format.workspace = true uuid.workspace = true -# Web server (optional) -axum = { workspace = true, optional = true } -tokio = { workspace = true, optional = true, features = ["full"] } -tower-http = { workspace = true, optional = true } -tracing = { workspace = true, optional = true } -tracing-subscriber = { workspace = true, optional = true } - # Python bindings (future) pyo3 = { workspace = true, optional = true } @@ -86,7 +74,6 @@ vegalite = [] ggplot2 = [] builtin-data = [] python = ["dep:pyo3"] -rest-api = ["dep:axum", "dep:tokio", "dep:tower-http", "dep:tracing", "dep:tracing-subscriber", "duckdb", "vegalite"] all-readers = ["duckdb", "postgres", "sqlite"] all-writers = ["vegalite", "ggplot2", "plotters"] diff --git a/src/rest.rs b/src/rest.rs deleted file mode 100644 index c3086607..00000000 --- a/src/rest.rs +++ /dev/null @@ -1,696 +0,0 @@ -/*! -ggsql REST API Server - -Provides HTTP endpoints for executing ggsql queries and returning visualization outputs. - -## Usage - -```bash -ggsql-rest --host 127.0.0.1 --port 3000 -``` - -## Endpoints - -- `POST /api/v1/query` - Execute a ggsql query -- `POST /api/v1/parse` - Parse a ggsql query (debugging) -- `GET /api/v1/health` - Health check -- `GET /api/v1/version` - Version information -*/ - -use axum::{ - extract::State, - http::{header, StatusCode}, - response::{IntoResponse, Json, Response}, - routing::{get, post}, - Router, -}; -use clap::Parser; -use serde::{Deserialize, Serialize}; -use std::net::SocketAddr; -use tower_http::cors::{Any, CorsLayer}; -use tracing::info; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use ggsql::{parser, validate::validate, GgsqlError, VERSION}; - -#[cfg(feature = "duckdb")] -use ggsql::reader::{DuckDBReader, Reader}; - -#[cfg(feature = "vegalite")] -use ggsql::writer::{VegaLiteWriter, Writer}; - -/// CLI arguments for the REST API server -#[derive(Parser)] -#[command(name = "ggsql-rest")] -#[command(about = "ggsql REST API Server")] -#[command(version = VERSION)] -struct Cli { - /// Host address to bind to - #[arg(long, default_value = "127.0.0.1")] - host: String, - - /// Port number to bind to - #[arg(long, default_value = "3334")] - port: u16, - - /// CORS allowed origins (comma-separated) - #[arg(long, default_value = "*")] - cors_origin: String, - - /// Load sample data into in-memory database - #[arg(long, default_value = "false")] - load_sample_data: bool, - - /// Load data from file(s) into in-memory database - /// Supports: CSV, Parquet, JSON - /// Example: --load-data data.csv --load-data other.parquet - #[arg(long = "load-data")] - load_data_files: Vec, -} - -/// Shared application state -#[derive(Clone)] -struct AppState { - /// Pre-initialized DuckDB reader with loaded data - /// Wrapped in Arc since DuckDB Connection is not Sync - #[cfg(feature = "duckdb")] - reader: Option>>, -} - -// ============================================================================ -// Request/Response Types -// ============================================================================ - -/// Request body for /api/v1/query endpoint -#[derive(Debug, Deserialize)] -struct QueryRequest { - /// ggsql query to execute - query: String, - /// Data source connection string (optional, default: duckdb://memory) - #[serde(default = "default_reader")] - reader: String, - /// Output writer format (optional, default: vegalite) - #[serde(default = "default_writer")] - writer: String, -} - -fn default_reader() -> String { - "duckdb://memory".to_string() -} - -fn default_writer() -> String { - "vegalite".to_string() -} - -/// Request body for /api/v1/parse endpoint -#[derive(Debug, Deserialize)] -struct ParseRequest { - /// ggsql query to parse - query: String, -} - -/// Successful API response -#[derive(Debug, Serialize)] -struct ApiSuccess { - status: String, - data: T, -} - -/// Error API response -#[derive(Debug, Serialize)] -struct ApiError { - status: String, - error: ErrorDetails, -} - -#[derive(Debug, Serialize)] -struct ErrorDetails { - message: String, - #[serde(rename = "type")] - error_type: String, -} - -/// Query execution result data -#[derive(Debug, Serialize)] -struct QueryResult { - /// The visualization specification (Vega-Lite JSON, etc.) - spec: serde_json::Value, - /// Metadata about the query execution - metadata: QueryMetadata, -} - -#[derive(Debug, Serialize)] -struct QueryMetadata { - rows: usize, - columns: Vec, - global_mappings: String, - layers: usize, -} - -/// Parse result data -#[derive(Debug, Serialize)] -struct ParseResult { - sql_portion: String, - viz_portion: String, - specs: Vec, -} - -/// Health check response -#[derive(Debug, Serialize)] -struct HealthResponse { - status: String, - version: String, -} - -/// Version response -#[derive(Debug, Serialize)] -struct VersionResponse { - version: String, - features: Vec, -} - -// ============================================================================ -// Error Handling -// ============================================================================ - -/// Custom error type for API responses -struct ApiErrorResponse { - status: StatusCode, - error: ApiError, -} - -impl IntoResponse for ApiErrorResponse { - fn into_response(self) -> Response { - let json = Json(self.error); - (self.status, json).into_response() - } -} - -impl From for ApiErrorResponse { - fn from(err: GgsqlError) -> Self { - let (status, error_type) = match &err { - GgsqlError::ParseError(_) => (StatusCode::BAD_REQUEST, "ParseError"), - GgsqlError::ValidationError(_) => (StatusCode::BAD_REQUEST, "ValidationError"), - GgsqlError::ReaderError(_) => (StatusCode::BAD_REQUEST, "ReaderError"), - GgsqlError::WriterError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "WriterError"), - GgsqlError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), - }; - - ApiErrorResponse { - status, - error: ApiError { - status: "error".to_string(), - error: ErrorDetails { - message: err.to_string(), - error_type: error_type.to_string(), - }, - }, - } - } -} - -impl From for ApiErrorResponse { - fn from(msg: String) -> Self { - ApiErrorResponse { - status: StatusCode::BAD_REQUEST, - error: ApiError { - status: "error".to_string(), - error: ErrorDetails { - message: msg, - error_type: "BadRequest".to_string(), - }, - }, - } - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -#[cfg(feature = "duckdb")] -fn load_data_files(reader: &DuckDBReader, files: &[String]) -> Result<(), GgsqlError> { - use duckdb::params; - use std::path::Path; - - let conn = reader.connection(); - - for file_path in files { - let path = Path::new(file_path); - - if !path.exists() { - return Err(GgsqlError::ReaderError(format!( - "File not found: {}", - file_path - ))); - } - - let extension = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - // Derive table name from filename (without extension) - let table_name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("data") - .replace('-', "_") - .replace(' ', "_"); - - info!("Loading {} into table '{}'", file_path, table_name); - - match extension.as_str() { - "csv" => { - // DuckDB can read CSV directly - let sql = format!( - "CREATE TABLE {} AS SELECT * FROM read_csv_auto('{}')", - table_name, file_path - ); - conn.execute(&sql, params![]).map_err(|e| { - GgsqlError::ReaderError(format!("Failed to load CSV {}: {}", file_path, e)) - })?; - } - "parquet" => { - // DuckDB can read Parquet directly - let sql = format!( - "CREATE TABLE {} AS SELECT * FROM read_parquet('{}')", - table_name, file_path - ); - conn.execute(&sql, params![]).map_err(|e| { - GgsqlError::ReaderError(format!("Failed to load Parquet {}: {}", file_path, e)) - })?; - } - "json" | "jsonl" | "ndjson" => { - // DuckDB can read JSON directly - let sql = format!( - "CREATE TABLE {} AS SELECT * FROM read_json_auto('{}')", - table_name, file_path - ); - conn.execute(&sql, params![]).map_err(|e| { - GgsqlError::ReaderError(format!("Failed to load JSON {}: {}", file_path, e)) - })?; - } - _ => { - return Err(GgsqlError::ReaderError(format!( - "Unsupported file format: {} (supported: csv, parquet, json, jsonl, ndjson)", - extension - ))); - } - } - - info!( - "Successfully loaded {} as table '{}'", - file_path, table_name - ); - } - - Ok(()) -} - -#[cfg(feature = "duckdb")] -fn load_sample_data(reader: &DuckDBReader) -> Result<(), GgsqlError> { - use duckdb::params; - - let conn = reader.connection(); - - // Create sample products table - conn.execute( - "CREATE TABLE products ( - product_id INTEGER, - product_name VARCHAR, - category VARCHAR, - price DECIMAL(10,2) - )", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to create products table: {}", e)))?; - - conn.execute( - "INSERT INTO products VALUES - (1, 'Laptop', 'Electronics', 999.99), - (2, 'Mouse', 'Electronics', 25.50), - (3, 'Keyboard', 'Electronics', 75.00), - (4, 'Desk', 'Furniture', 299.99), - (5, 'Chair', 'Furniture', 199.99), - (6, 'Monitor', 'Electronics', 349.99), - (7, 'Lamp', 'Furniture', 45.00)", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to insert products: {}", e)))?; - - // Create sample sales table with more temporal data - conn.execute( - "CREATE TABLE sales ( - sale_id INTEGER, - product_id INTEGER, - quantity INTEGER, - sale_date DATE, - region VARCHAR - )", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to create sales table: {}", e)))?; - - conn.execute( - "INSERT INTO sales VALUES - -- January 2024 - (1, 1, 2, '2024-01-05', 'US'), - (2, 2, 5, '2024-01-05', 'EU'), - (3, 3, 3, '2024-01-05', 'APAC'), - (4, 1, 3, '2024-01-12', 'US'), - (5, 2, 4, '2024-01-12', 'EU'), - (6, 3, 2, '2024-01-12', 'APAC'), - (7, 4, 2, '2024-01-19', 'US'), - (8, 5, 1, '2024-01-19', 'EU'), - (9, 6, 2, '2024-01-19', 'APAC'), - (10, 1, 4, '2024-01-26', 'US'), - (11, 2, 3, '2024-01-26', 'EU'), - (12, 3, 5, '2024-01-26', 'APAC'), - -- February 2024 - (13, 4, 3, '2024-02-02', 'US'), - (14, 5, 2, '2024-02-02', 'EU'), - (15, 6, 1, '2024-02-02', 'APAC'), - (16, 1, 5, '2024-02-09', 'US'), - (17, 2, 6, '2024-02-09', 'EU'), - (18, 3, 4, '2024-02-09', 'APAC'), - (19, 7, 2, '2024-02-16', 'US'), - (20, 4, 3, '2024-02-16', 'EU'), - (21, 5, 2, '2024-02-16', 'APAC'), - (22, 1, 6, '2024-02-23', 'US'), - (23, 2, 5, '2024-02-23', 'EU'), - (24, 6, 3, '2024-02-23', 'APAC'), - -- March 2024 - (25, 3, 4, '2024-03-01', 'US'), - (26, 4, 5, '2024-03-01', 'EU'), - (27, 5, 3, '2024-03-01', 'APAC'), - (28, 1, 7, '2024-03-08', 'US'), - (29, 2, 6, '2024-03-08', 'EU'), - (30, 3, 5, '2024-03-08', 'APAC'), - (31, 6, 2, '2024-03-15', 'US'), - (32, 7, 3, '2024-03-15', 'EU'), - (33, 4, 4, '2024-03-15', 'APAC'), - (34, 1, 8, '2024-03-22', 'US'), - (35, 2, 7, '2024-03-22', 'EU'), - (36, 5, 6, '2024-03-22', 'APAC')", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to insert sales: {}", e)))?; - - // Create sample employees table - conn.execute( - "CREATE TABLE employees ( - employee_id INTEGER, - employee_name VARCHAR, - department VARCHAR, - salary INTEGER, - hire_date DATE - )", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to create employees table: {}", e)))?; - - conn.execute( - "INSERT INTO employees VALUES - (1, 'Alice Johnson', 'Engineering', 95000, '2022-01-15'), - (2, 'Bob Smith', 'Engineering', 85000, '2022-03-20'), - (3, 'Carol Williams', 'Sales', 70000, '2022-06-10'), - (4, 'David Brown', 'Sales', 75000, '2023-01-05'), - (5, 'Eve Davis', 'Marketing', 65000, '2023-03-15'), - (6, 'Frank Miller', 'Engineering', 105000, '2021-09-01')", - params![], - ) - .map_err(|e| GgsqlError::ReaderError(format!("Failed to insert employees: {}", e)))?; - - Ok(()) -} - -// ============================================================================ -// Handler Functions -// ============================================================================ - -/// POST /api/v1/query - Execute a ggsql query -async fn query_handler( - State(state): State, - Json(request): Json, -) -> Result>, ApiErrorResponse> { - info!("Executing query: {} chars", request.query.len()); - info!("Reader: {}, Writer: {}", request.reader, request.writer); - - #[cfg(feature = "duckdb")] - if request.reader.starts_with("duckdb://") { - // Use shared reader or create new one - let spec = if request.reader == "duckdb://memory" && state.reader.is_some() { - let reader_mutex = state.reader.as_ref().unwrap(); - let reader = reader_mutex - .lock() - .map_err(|e| GgsqlError::InternalError(format!("Failed to lock reader: {}", e)))?; - reader.execute(&request.query)? - } else { - let reader = DuckDBReader::from_connection_string(&request.reader)?; - reader.execute(&request.query)? - }; - - // Get metadata - let metadata = spec.metadata(); - - // Generate visualization output using writer - #[cfg(feature = "vegalite")] - if request.writer == "vegalite" { - let writer = VegaLiteWriter::new(); - let json_output = writer.render(&spec)?; - let spec_value: serde_json::Value = serde_json::from_str(&json_output) - .map_err(|e| GgsqlError::WriterError(format!("Failed to parse JSON: {}", e)))?; - - let plot = spec.plot(); - - let result = QueryResult { - spec: spec_value, - metadata: QueryMetadata { - rows: metadata.rows, - columns: metadata.columns.clone(), - global_mappings: format!("{:?}", plot.global_mappings), - layers: plot.layers.len(), - }, - }; - - return Ok(Json(ApiSuccess { - status: "success".to_string(), - data: result, - })); - } - - #[cfg(not(feature = "vegalite"))] - return Err(ApiErrorResponse::from( - "VegaLite writer not available".to_string(), - )); - } - - #[cfg(not(feature = "duckdb"))] - return Err(ApiErrorResponse::from( - "DuckDB reader not available".to_string(), - )); - - #[cfg(feature = "duckdb")] - Err(ApiErrorResponse::from(format!( - "Unsupported reader: {}", - request.reader - ))) -} - -/// POST /api/v1/parse - Parse a ggsql query -#[cfg(feature = "duckdb")] -async fn parse_handler( - Json(request): Json, -) -> Result>, ApiErrorResponse> { - info!("Parsing query: {} chars", request.query.len()); - - // Validate query to get sql/viz portions - let validated = validate(&request.query)?; - - // Parse ggsql portion - let specs = parser::parse_query(&request.query)?; - - // Convert specs to JSON - let specs_json: Vec = specs - .iter() - .map(|spec| serde_json::to_value(spec).unwrap_or(serde_json::Value::Null)) - .collect(); - - let result = ParseResult { - sql_portion: validated.sql().to_string(), - viz_portion: validated.visual().to_string(), - specs: specs_json, - }; - - Ok(Json(ApiSuccess { - status: "success".to_string(), - data: result, - })) -} - -/// POST /api/v1/parse - Parse a ggsql query -#[cfg(not(feature = "duckdb"))] -async fn parse_handler( - Json(request): Json, -) -> Result>, ApiErrorResponse> { - info!("Parsing query: {} chars", request.query.len()); - - // Validate query to get sql/viz portions - let validated = validate(&request.query)?; - - // Parse ggsql portion - let specs = parser::parse_query(&request.query)?; - - // Convert specs to JSON - let specs_json: Vec = specs - .iter() - .map(|spec| serde_json::to_value(spec).unwrap_or(serde_json::Value::Null)) - .collect(); - - let result = ParseResult { - sql_portion: validated.sql().to_string(), - viz_portion: validated.visual().to_string(), - specs: specs_json, - }; - - Ok(Json(ApiSuccess { - status: "success".to_string(), - data: result, - })) -} - -/// GET /api/v1/health - Health check -async fn health_handler() -> Json { - Json(HealthResponse { - status: "healthy".to_string(), - version: VERSION.to_string(), - }) -} - -/// GET /api/v1/version - Version information -async fn version_handler() -> Json { - let mut features = Vec::new(); - - #[cfg(feature = "duckdb")] - features.push("duckdb".to_string()); - - #[cfg(feature = "vegalite")] - features.push("vegalite".to_string()); - - #[cfg(feature = "sqlite")] - features.push("sqlite".to_string()); - - #[cfg(feature = "postgres")] - features.push("postgres".to_string()); - - Json(VersionResponse { - version: VERSION.to_string(), - features, - }) -} - -/// Root handler -async fn root_handler() -> &'static str { - "ggsql REST API Server - See /api/v1/health for status" -} - -// ============================================================================ -// Main Server -// ============================================================================ - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Initialize tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "ggsql_rest=info,tower_http=info".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - // Parse CLI arguments - let cli = Cli::parse(); - - // Initialize DuckDB reader with data if requested - #[cfg(feature = "duckdb")] - let reader = if cli.load_sample_data || !cli.load_data_files.is_empty() { - info!("Initializing in-memory DuckDB database"); - let reader = DuckDBReader::from_connection_string("duckdb://memory")?; - - // Load sample data if requested - if cli.load_sample_data { - info!("Loading sample data (products, sales, employees tables)"); - load_sample_data(&reader)?; - } - - // Load data files if provided - if !cli.load_data_files.is_empty() { - info!("Loading {} data file(s)", cli.load_data_files.len()); - load_data_files(&reader, &cli.load_data_files)?; - } - - Some(std::sync::Arc::new(std::sync::Mutex::new(reader))) - } else { - info!("Starting with empty in-memory database (no data pre-loaded)"); - None - }; - - #[cfg(not(feature = "duckdb"))] - let reader = None; - - // Create application state - let state = AppState { - #[cfg(feature = "duckdb")] - reader, - }; - - // Configure CORS - let cors = if cli.cors_origin == "*" { - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(vec![header::CONTENT_TYPE]) - } else { - let origins: Vec<_> = cli - .cors_origin - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - CorsLayer::new() - .allow_origin(origins) - .allow_methods(Any) - .allow_headers(vec![header::CONTENT_TYPE]) - }; - - // Build router - let app = Router::new() - .route("/", get(root_handler)) - .route("/api/v1/query", post(query_handler)) - .route("/api/v1/parse", post(parse_handler)) - .route("/api/v1/health", get(health_handler)) - .route("/api/v1/version", get(version_handler)) - .layer(cors) - .layer(tower_http::trace::TraceLayer::new_for_http()) - .with_state(state); - - // Parse bind address - let addr: SocketAddr = format!("{}:{}", cli.host, cli.port) - .parse() - .expect("Invalid host or port"); - - info!("Starting ggsql REST API server on {}", addr); - info!("API documentation:"); - info!(" POST /api/v1/query - Execute ggsql query"); - info!(" POST /api/v1/parse - Parse ggsql query"); - info!(" GET /api/v1/health - Health check"); - info!(" GET /api/v1/version - Version info"); - - // Start server - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - - Ok(()) -}