1use 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
19pub 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#[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
119async fn health() -> Json<HealthResponse> {
123 Json(HealthResponse {
124 status: "ok",
125 version: env!("CARGO_PKG_VERSION"),
126 })
127}
128
129async 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 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 #[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
166async 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
184async 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
198async 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
251async fn files(State(state): State<Arc<AppState>>) -> impl IntoResponse {
253 let files = state.files.read().await;
254 Json(files.clone())
255}
256
257async fn schema(State(state): State<Arc<AppState>>) -> impl IntoResponse {
259 let schema = state.schema.read().await;
260 Json(schema.clone())
261}
262
263async 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
333async 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 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}