flowscope_cli/server/
api.rs

1//! REST API handlers for serve mode.
2//!
3//! This module provides the API endpoints for the web UI to interact with
4//! the FlowScope analysis engine.
5
6use std::sync::Arc;
7
8use axum::{
9    extract::{Path, State},
10    http::StatusCode,
11    response::IntoResponse,
12    routing::{get, post},
13    Json, Router,
14};
15use serde::{Deserialize, Serialize};
16
17use super::AppState;
18
19/// Build the API router with all endpoints.
20pub fn api_routes() -> Router<Arc<AppState>> {
21    Router::new()
22        .route("/health", get(health))
23        .route("/analyze", post(analyze))
24        .route("/completion", post(completion))
25        .route("/split", post(split))
26        .route("/files", get(files))
27        .route("/schema", get(schema))
28        .route("/export/{format}", post(export))
29        .route("/config", get(config))
30}
31
32// === Request/Response types ===
33
34#[derive(Serialize)]
35struct HealthResponse {
36    status: &'static str,
37    version: &'static str,
38}
39
40#[derive(Deserialize)]
41struct AnalyzeRequest {
42    sql: String,
43    #[serde(default)]
44    files: Option<Vec<flowscope_core::FileSource>>,
45    #[serde(default)]
46    hide_ctes: Option<bool>,
47    #[serde(default)]
48    enable_column_lineage: Option<bool>,
49    #[serde(default)]
50    template_mode: Option<String>,
51}
52
53#[derive(Deserialize)]
54struct CompletionRequest {
55    sql: String,
56    #[serde(alias = "position")]
57    cursor_offset: usize,
58}
59
60#[derive(Deserialize)]
61struct SplitRequest {
62    sql: String,
63}
64
65#[derive(Serialize)]
66struct ConfigResponse {
67    dialect: String,
68    watch_dirs: Vec<String>,
69    has_schema: bool,
70    #[cfg(feature = "templating")]
71    template_mode: Option<String>,
72}
73
74#[derive(Deserialize)]
75struct ExportRequest {
76    sql: String,
77    #[serde(default)]
78    files: Option<Vec<flowscope_core::FileSource>>,
79}
80
81// === Handlers ===
82
83/// GET /api/health - Health check with version
84async fn health() -> Json<HealthResponse> {
85    Json(HealthResponse {
86        status: "ok",
87        version: env!("CARGO_PKG_VERSION"),
88    })
89}
90
91/// POST /api/analyze - Run lineage analysis
92async fn analyze(
93    State(state): State<Arc<AppState>>,
94    Json(payload): Json<AnalyzeRequest>,
95) -> Result<impl IntoResponse, (StatusCode, String)> {
96    let schema = state.schema.read().await.clone();
97
98    // Build analysis options from request
99    let options = if payload.hide_ctes.is_some() || payload.enable_column_lineage.is_some() {
100        Some(flowscope_core::AnalysisOptions {
101            hide_ctes: payload.hide_ctes,
102            enable_column_lineage: payload.enable_column_lineage,
103            ..Default::default()
104        })
105    } else {
106        None
107    };
108
109    // Build template config if template mode is specified
110    #[cfg(feature = "templating")]
111    let template_config = resolve_template_config(payload.template_mode.as_deref(), state.as_ref());
112
113    let request = flowscope_core::AnalyzeRequest {
114        sql: payload.sql,
115        files: payload.files,
116        dialect: state.config.dialect,
117        source_name: None,
118        options,
119        schema,
120        #[cfg(feature = "templating")]
121        template_config,
122    };
123
124    let result = flowscope_core::analyze(&request);
125    Ok(Json(result))
126}
127
128/// POST /api/completion - Get code completion items
129async fn completion(
130    State(state): State<Arc<AppState>>,
131    Json(payload): Json<CompletionRequest>,
132) -> Result<impl IntoResponse, (StatusCode, String)> {
133    let schema = state.schema.read().await.clone();
134
135    let request = flowscope_core::CompletionRequest {
136        sql: payload.sql,
137        cursor_offset: payload.cursor_offset,
138        dialect: state.config.dialect,
139        schema,
140    };
141
142    let result = flowscope_core::completion_items(&request);
143    Ok(Json(result))
144}
145
146/// POST /api/split - Split SQL into statements
147async fn split(
148    State(state): State<Arc<AppState>>,
149    Json(payload): Json<SplitRequest>,
150) -> Result<impl IntoResponse, (StatusCode, String)> {
151    let request = flowscope_core::StatementSplitRequest {
152        sql: payload.sql,
153        dialect: state.config.dialect,
154    };
155
156    let result = flowscope_core::split_statements(&request);
157    Ok(Json(result))
158}
159
160/// GET /api/files - List watched files with content
161async fn files(State(state): State<Arc<AppState>>) -> impl IntoResponse {
162    let files = state.files.read().await;
163    Json(files.clone())
164}
165
166/// GET /api/schema - Get schema metadata
167async fn schema(State(state): State<Arc<AppState>>) -> impl IntoResponse {
168    let schema = state.schema.read().await;
169    Json(schema.clone())
170}
171
172/// POST /api/export/:format - Export to specified format
173async fn export(
174    State(state): State<Arc<AppState>>,
175    Path(format): Path<String>,
176    Json(payload): Json<ExportRequest>,
177) -> Result<impl IntoResponse, (StatusCode, String)> {
178    let schema = state.schema.read().await.clone();
179
180    let request = flowscope_core::AnalyzeRequest {
181        sql: payload.sql,
182        files: payload.files,
183        dialect: state.config.dialect,
184        source_name: None,
185        options: None,
186        schema,
187        #[cfg(feature = "templating")]
188        template_config: state.config.template_config.clone(),
189    };
190
191    let result = flowscope_core::analyze(&request);
192
193    match format.as_str() {
194        "json" => {
195            let output = flowscope_export::export_json(&result, false)
196                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
197            Ok((
198                [(axum::http::header::CONTENT_TYPE, "application/json")],
199                output,
200            )
201                .into_response())
202        }
203        "mermaid" => {
204            let output =
205                flowscope_export::export_mermaid(&result, flowscope_export::MermaidView::Table)
206                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
207            Ok(([(axum::http::header::CONTENT_TYPE, "text/plain")], output).into_response())
208        }
209        "html" => {
210            let output = flowscope_export::export_html(&result, "lineage", chrono::Utc::now())
211                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
212            Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], output).into_response())
213        }
214        "csv" => {
215            let bytes = flowscope_export::export_csv_bundle(&result)
216                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
217            Ok((
218                [(axum::http::header::CONTENT_TYPE, "application/zip")],
219                bytes,
220            )
221                .into_response())
222        }
223        "xlsx" => {
224            let bytes = flowscope_export::export_xlsx(&result)
225                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
226            Ok((
227                [(
228                    axum::http::header::CONTENT_TYPE,
229                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
230                )],
231                bytes,
232            )
233                .into_response())
234        }
235        _ => Err((
236            StatusCode::BAD_REQUEST,
237            format!("Unknown export format: {format}"),
238        )),
239    }
240}
241
242/// GET /api/config - Get server configuration
243async fn config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
244    let has_schema = state.schema.read().await.is_some();
245
246    Json(ConfigResponse {
247        dialect: format!("{:?}", state.config.dialect),
248        watch_dirs: state
249            .config
250            .watch_dirs
251            .iter()
252            .map(|p| p.display().to_string())
253            .collect(),
254        has_schema,
255        #[cfg(feature = "templating")]
256        template_mode: state
257            .config
258            .template_config
259            .as_ref()
260            .map(|cfg| template_mode_to_str(cfg.mode).to_string()),
261    })
262}
263
264#[cfg(feature = "templating")]
265fn resolve_template_config(
266    mode: Option<&str>,
267    state: &AppState,
268) -> Option<flowscope_core::TemplateConfig> {
269    match mode {
270        Some("raw") => None,
271        Some("jinja") => Some(build_template_config(
272            flowscope_core::TemplateMode::Jinja,
273            state,
274        )),
275        Some("dbt") => Some(build_template_config(
276            flowscope_core::TemplateMode::Dbt,
277            state,
278        )),
279        Some(_) => state.config.template_config.clone(),
280        None => state.config.template_config.clone(),
281    }
282}
283
284#[cfg(feature = "templating")]
285fn build_template_config(
286    template_mode: flowscope_core::TemplateMode,
287    state: &AppState,
288) -> flowscope_core::TemplateConfig {
289    let context = state
290        .config
291        .template_config
292        .as_ref()
293        .map(|cfg| cfg.context.clone())
294        .unwrap_or_default();
295
296    flowscope_core::TemplateConfig {
297        mode: template_mode,
298        context,
299    }
300}
301
302#[cfg(feature = "templating")]
303fn template_mode_to_str(mode: flowscope_core::TemplateMode) -> &'static str {
304    match mode {
305        flowscope_core::TemplateMode::Raw => "raw",
306        flowscope_core::TemplateMode::Jinja => "jinja",
307        flowscope_core::TemplateMode::Dbt => "dbt",
308    }
309}