Skip to main content

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::{collections::BTreeMap, 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("/lint-fix", post(lint_fix))
27        .route("/files", get(files))
28        .route("/schema", get(schema))
29        .route("/export/{format}", post(export))
30        .route("/config", get(config))
31}
32
33// === Request/Response types ===
34
35#[derive(Serialize)]
36struct HealthResponse {
37    status: &'static str,
38    version: &'static str,
39}
40
41#[derive(Deserialize)]
42struct AnalyzeRequest {
43    sql: String,
44    #[serde(default)]
45    files: Option<Vec<flowscope_core::FileSource>>,
46    #[serde(default)]
47    hide_ctes: Option<bool>,
48    #[serde(default)]
49    enable_column_lineage: Option<bool>,
50    #[serde(default)]
51    template_mode: Option<String>,
52}
53
54#[derive(Deserialize)]
55struct CompletionRequest {
56    sql: String,
57    #[serde(alias = "position")]
58    cursor_offset: usize,
59}
60
61#[derive(Deserialize)]
62struct SplitRequest {
63    sql: String,
64}
65
66#[derive(Serialize)]
67struct ConfigResponse {
68    dialect: String,
69    watch_dirs: Vec<String>,
70    has_schema: bool,
71    #[cfg(feature = "templating")]
72    template_mode: Option<String>,
73}
74
75#[derive(Deserialize)]
76struct ExportRequest {
77    sql: String,
78    #[serde(default)]
79    files: Option<Vec<flowscope_core::FileSource>>,
80}
81
82#[derive(Deserialize)]
83struct LintFixRequest {
84    sql: String,
85    #[serde(default, alias = "include_unsafe_fixes")]
86    unsafe_fixes: bool,
87    #[serde(default, alias = "legacyAstFixes")]
88    legacy_ast_fixes: bool,
89    #[serde(default, alias = "exclude_rules")]
90    disabled_rules: Vec<String>,
91    #[serde(default)]
92    rule_configs: BTreeMap<String, serde_json::Value>,
93}
94
95#[derive(Serialize)]
96struct LintFixResponse {
97    sql: String,
98    changed: bool,
99    fix_counts: LintFixCountsResponse,
100    skipped_due_to_comments: bool,
101    skipped_due_to_regression: bool,
102    skipped_counts: LintFixSkippedCountsResponse,
103}
104
105#[derive(Serialize)]
106struct LintFixCountsResponse {
107    total: usize,
108}
109
110#[derive(Serialize)]
111struct LintFixSkippedCountsResponse {
112    unsafe_skipped: usize,
113    protected_range_blocked: usize,
114    overlap_conflict_blocked: usize,
115    display_only: usize,
116    blocked_total: usize,
117}
118
119// === Handlers ===
120
121/// GET /api/health - Health check with version
122async fn health() -> Json<HealthResponse> {
123    Json(HealthResponse {
124        status: "ok",
125        version: env!("CARGO_PKG_VERSION"),
126    })
127}
128
129/// POST /api/analyze - Run lineage analysis
130async fn analyze(
131    State(state): State<Arc<AppState>>,
132    Json(payload): Json<AnalyzeRequest>,
133) -> Result<impl IntoResponse, (StatusCode, String)> {
134    let schema = state.schema.read().await.clone();
135
136    // Build analysis options from request
137    let options = if payload.hide_ctes.is_some() || payload.enable_column_lineage.is_some() {
138        Some(flowscope_core::AnalysisOptions {
139            hide_ctes: payload.hide_ctes,
140            enable_column_lineage: payload.enable_column_lineage,
141            ..Default::default()
142        })
143    } else {
144        None
145    };
146
147    // Build template config if template mode is specified
148    #[cfg(feature = "templating")]
149    let template_config = resolve_template_config(payload.template_mode.as_deref(), state.as_ref());
150
151    let request = flowscope_core::AnalyzeRequest {
152        sql: payload.sql,
153        files: payload.files,
154        dialect: state.config.dialect,
155        source_name: None,
156        options,
157        schema,
158        #[cfg(feature = "templating")]
159        template_config,
160    };
161
162    let result = flowscope_core::analyze(&request);
163    Ok(Json(result))
164}
165
166/// POST /api/completion - Get code completion items
167async fn completion(
168    State(state): State<Arc<AppState>>,
169    Json(payload): Json<CompletionRequest>,
170) -> Result<impl IntoResponse, (StatusCode, String)> {
171    let schema = state.schema.read().await.clone();
172
173    let request = flowscope_core::CompletionRequest {
174        sql: payload.sql,
175        cursor_offset: payload.cursor_offset,
176        dialect: state.config.dialect,
177        schema,
178    };
179
180    let result = flowscope_core::completion_items(&request);
181    Ok(Json(result))
182}
183
184/// POST /api/split - Split SQL into statements
185async fn split(
186    State(state): State<Arc<AppState>>,
187    Json(payload): Json<SplitRequest>,
188) -> Result<impl IntoResponse, (StatusCode, String)> {
189    let request = flowscope_core::StatementSplitRequest {
190        sql: payload.sql,
191        dialect: state.config.dialect,
192    };
193
194    let result = flowscope_core::split_statements(&request);
195    Ok(Json(result))
196}
197
198/// POST /api/lint-fix - Apply deterministic lint fixes to SQL text.
199async fn lint_fix(
200    State(state): State<Arc<AppState>>,
201    Json(payload): Json<LintFixRequest>,
202) -> Result<impl IntoResponse, (StatusCode, String)> {
203    let rule_configs = normalize_rule_configs(payload.rule_configs)
204        .map_err(|err| (StatusCode::BAD_REQUEST, err))?;
205
206    let lint_config = flowscope_core::LintConfig {
207        enabled: true,
208        disabled_rules: payload.disabled_rules,
209        rule_configs,
210    };
211
212    let execution = crate::fix::apply_lint_fixes_with_runtime_options(
213        &payload.sql,
214        state.config.dialect,
215        &lint_config,
216        crate::fix::LintFixRuntimeOptions {
217            include_unsafe_fixes: payload.unsafe_fixes,
218            legacy_ast_fixes: payload.legacy_ast_fixes,
219        },
220    )
221    .map_err(|err| {
222        eprintln!("flowscope: lint-fix failed: {err}");
223        (
224            StatusCode::BAD_REQUEST,
225            "Failed to apply lint fixes".to_string(),
226        )
227    })?;
228    let outcome = execution.outcome;
229    let candidate_stats = execution.candidate_stats;
230
231    let skipped_counts = LintFixSkippedCountsResponse {
232        unsafe_skipped: candidate_stats.blocked_unsafe,
233        protected_range_blocked: candidate_stats.blocked_protected_range,
234        overlap_conflict_blocked: candidate_stats.blocked_overlap_conflict,
235        display_only: candidate_stats.blocked_display_only,
236        blocked_total: candidate_stats.blocked,
237    };
238
239    Ok(Json(LintFixResponse {
240        sql: outcome.sql,
241        changed: outcome.changed,
242        fix_counts: LintFixCountsResponse {
243            total: outcome.counts.total(),
244        },
245        skipped_due_to_comments: outcome.skipped_due_to_comments,
246        skipped_due_to_regression: outcome.skipped_due_to_regression,
247        skipped_counts,
248    }))
249}
250
251/// GET /api/files - List watched files with content
252async fn files(State(state): State<Arc<AppState>>) -> impl IntoResponse {
253    let files = state.files.read().await;
254    Json(files.clone())
255}
256
257/// GET /api/schema - Get schema metadata
258async fn schema(State(state): State<Arc<AppState>>) -> impl IntoResponse {
259    let schema = state.schema.read().await;
260    Json(schema.clone())
261}
262
263/// POST /api/export/:format - Export to specified format
264async fn export(
265    State(state): State<Arc<AppState>>,
266    Path(format): Path<String>,
267    Json(payload): Json<ExportRequest>,
268) -> Result<impl IntoResponse, (StatusCode, String)> {
269    let schema = state.schema.read().await.clone();
270
271    let request = flowscope_core::AnalyzeRequest {
272        sql: payload.sql,
273        files: payload.files,
274        dialect: state.config.dialect,
275        source_name: None,
276        options: None,
277        schema,
278        #[cfg(feature = "templating")]
279        template_config: state.config.template_config.clone(),
280    };
281
282    let result = flowscope_core::analyze(&request);
283
284    match format.as_str() {
285        "json" => {
286            let output = flowscope_export::export_json(&result, false)
287                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
288            Ok((
289                [(axum::http::header::CONTENT_TYPE, "application/json")],
290                output,
291            )
292                .into_response())
293        }
294        "mermaid" => {
295            let output =
296                flowscope_export::export_mermaid(&result, flowscope_export::MermaidView::Table)
297                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
298            Ok(([(axum::http::header::CONTENT_TYPE, "text/plain")], output).into_response())
299        }
300        "html" => {
301            let output = flowscope_export::export_html(&result, "lineage", chrono::Utc::now())
302                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
303            Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], output).into_response())
304        }
305        "csv" => {
306            let bytes = flowscope_export::export_csv_bundle(&result)
307                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
308            Ok((
309                [(axum::http::header::CONTENT_TYPE, "application/zip")],
310                bytes,
311            )
312                .into_response())
313        }
314        "xlsx" => {
315            let bytes = flowscope_export::export_xlsx(&result)
316                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
317            Ok((
318                [(
319                    axum::http::header::CONTENT_TYPE,
320                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
321                )],
322                bytes,
323            )
324                .into_response())
325        }
326        _ => Err((
327            StatusCode::BAD_REQUEST,
328            format!("Unknown export format: {format}"),
329        )),
330    }
331}
332
333/// GET /api/config - Get server configuration
334async fn config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
335    let has_schema = state.schema.read().await.is_some();
336
337    Json(ConfigResponse {
338        dialect: format!("{:?}", state.config.dialect),
339        watch_dirs: state
340            .config
341            .watch_dirs
342            .iter()
343            .map(|p| p.display().to_string())
344            .collect(),
345        has_schema,
346        #[cfg(feature = "templating")]
347        template_mode: state
348            .config
349            .template_config
350            .as_ref()
351            .map(|cfg| template_mode_to_str(cfg.mode).to_string()),
352    })
353}
354
355fn normalize_rule_configs(
356    raw_configs: BTreeMap<String, serde_json::Value>,
357) -> Result<BTreeMap<String, serde_json::Value>, String> {
358    let mut rule_configs = BTreeMap::new();
359    let mut indentation_legacy = serde_json::Map::new();
360
361    for (rule_ref, options) in raw_configs {
362        if options.is_object() {
363            rule_configs.insert(rule_ref, options);
364            continue;
365        }
366
367        // SQLFluff compatibility: support legacy indentation keys at root.
368        if matches!(
369            rule_ref.to_ascii_lowercase().as_str(),
370            "indent_unit" | "tab_space_size" | "indented_joins" | "indented_using_on"
371        ) {
372            indentation_legacy.insert(rule_ref, options);
373            continue;
374        }
375
376        return Err(format!(
377            "'rule_configs' entry for '{rule_ref}' must be a JSON object"
378        ));
379    }
380
381    if !indentation_legacy.is_empty() {
382        let merged = match rule_configs.remove("indentation") {
383            Some(serde_json::Value::Object(existing)) => {
384                let mut merged = existing;
385                for (key, value) in indentation_legacy {
386                    merged.insert(key, value);
387                }
388                merged
389            }
390            Some(other) => {
391                return Err(format!(
392                    "'rule_configs' entry for 'indentation' must be a JSON object, found {other}"
393                ));
394            }
395            None => indentation_legacy,
396        };
397
398        rule_configs.insert("indentation".to_string(), serde_json::Value::Object(merged));
399    }
400
401    Ok(rule_configs)
402}
403
404#[cfg(feature = "templating")]
405fn resolve_template_config(
406    mode: Option<&str>,
407    state: &AppState,
408) -> Option<flowscope_core::TemplateConfig> {
409    match mode {
410        Some("raw") => None,
411        Some("jinja") => Some(build_template_config(
412            flowscope_core::TemplateMode::Jinja,
413            state,
414        )),
415        Some("dbt") => Some(build_template_config(
416            flowscope_core::TemplateMode::Dbt,
417            state,
418        )),
419        Some(_) => state.config.template_config.clone(),
420        None => state.config.template_config.clone(),
421    }
422}
423
424#[cfg(feature = "templating")]
425fn build_template_config(
426    template_mode: flowscope_core::TemplateMode,
427    state: &AppState,
428) -> flowscope_core::TemplateConfig {
429    let context = state
430        .config
431        .template_config
432        .as_ref()
433        .map(|cfg| cfg.context.clone())
434        .unwrap_or_default();
435
436    flowscope_core::TemplateConfig {
437        mode: template_mode,
438        context,
439    }
440}
441
442#[cfg(feature = "templating")]
443fn template_mode_to_str(mode: flowscope_core::TemplateMode) -> &'static str {
444    match mode {
445        flowscope_core::TemplateMode::Raw => "raw",
446        flowscope_core::TemplateMode::Jinja => "jinja",
447        flowscope_core::TemplateMode::Dbt => "dbt",
448    }
449}