1use 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
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("/files", get(files))
27 .route("/schema", get(schema))
28 .route("/export/{format}", post(export))
29 .route("/config", get(config))
30}
31
32#[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
81async fn health() -> Json<HealthResponse> {
85 Json(HealthResponse {
86 status: "ok",
87 version: env!("CARGO_PKG_VERSION"),
88 })
89}
90
91async 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 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 #[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
128async 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
146async 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
160async fn files(State(state): State<Arc<AppState>>) -> impl IntoResponse {
162 let files = state.files.read().await;
163 Json(files.clone())
164}
165
166async fn schema(State(state): State<Arc<AppState>>) -> impl IntoResponse {
168 let schema = state.schema.read().await;
169 Json(schema.clone())
170}
171
172async 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
242async 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}