use crate::server::app::AppState;
use crate::server::body::{parse_form, parse_query, percent_decode};
use crate::server::errors::{config_failure, error, method_not_allowed};
use crate::server::query::{js_number, js_number_or};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{header, HeaderName, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use nomoreide_core::config::{Config, DatabaseDef};
use nomoreide_core::db;
use nomoreide_core::db::{RowBrowseQuery, RowFilter, RowSort};
use nomoreide_core::js_json;
use serde_json::{json, Value};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/databases/:name/catalog/capabilities",
get(capabilities).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/catalog/schemas",
get(schemas).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/catalog/objects",
get(objects).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/catalog/details",
get(details).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/catalog/rows",
get(catalog_rows).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/catalog/export",
get(export).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/tables",
get(tables).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/rows",
get(rows).fallback(method_not_allowed),
)
.route(
"/api/databases/:name/query",
post(query).fallback(method_not_allowed),
)
}
async fn capabilities(State(state): State<AppState>, uri: Uri) -> Response {
with_connection(&state, &uri, |database| async move {
db::capabilities(&database.engine)
.map(|capabilities| json!({ "ok": true, "capabilities": capabilities }))
})
.await
}
async fn schemas(State(state): State<AppState>, uri: Uri) -> Response {
with_connection(&state, &uri, |database| async move {
db::peek_schemas(&database).await.map(|schemas| {
json!({
"ok": true,
"schemas": schemas
.into_iter()
.map(|name| json!({ "name": name }))
.collect::<Vec<_>>(),
})
})
})
.await
}
async fn objects(State(state): State<AppState>, uri: Uri) -> Response {
let schema = match require_param(&uri, "schema") {
Ok(schema) => schema,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
with_connection(&state, &uri, |database| async move {
db::peek_objects(&database, &schema)
.await
.map(|objects| json!({ "ok": true, "objects": objects }))
})
.await
}
async fn details(State(state): State<AppState>, uri: Uri) -> Response {
let key = match require_param(&uri, "key") {
Ok(key) => key,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
with_connection(&state, &uri, |database| async move {
db::peek_details(&database, &key)
.await
.map(|details| json!({ "ok": true, "details": details }))
})
.await
}
async fn catalog_rows(State(state): State<AppState>, uri: Uri) -> Response {
let key = match require_param(&uri, "key") {
Ok(key) => key,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
let params = parse_query(&uri);
let limit = js_number_or(params.get("limit").map(String::as_str), 100.0) as i64;
let offset = js_number_or(params.get("offset").map(String::as_str), 0.0) as i64;
let browse = match row_browse_query(¶ms) {
Ok(browse) => browse,
Err(reason) => return throw(&reason),
};
with_connection(&state, &uri, |database| async move {
db::sample_object(&database, &key, Some(limit), Some(offset), Some(browse))
.await
.map(|rows| merge(json!({ "ok": true }), json!(rows)))
})
.await
}
async fn export(State(state): State<AppState>, uri: Uri) -> Response {
let key = match require_param(&uri, "key") {
Ok(key) => key,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
let format = match parse_query(&uri)
.get("format")
.and_then(|value| db::ExportFormat::parse(value))
{
Some(format) => format,
None => return error(StatusCode::BAD_REQUEST, "format must be csv or json"),
};
let Some(name) = name_from(&uri) else {
return error(StatusCode::NOT_FOUND, "Not found");
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return config_failure(&reason),
};
let database = match connection(&config, &name) {
Ok(database) => database,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason),
};
let body = match render_export(&database, &key, format).await {
Ok(body) => body,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason),
};
let filename = db::export_filename(&name, &body.object, format, &today());
(
[
(header::CONTENT_TYPE, format.content_type().to_string()),
(
header::CONTENT_DISPOSITION,
db::content_disposition(&filename),
),
(header::CACHE_CONTROL, "no-store".to_string()),
(
HeaderName::from_static("x-content-type-options"),
"nosniff".to_string(),
),
],
body.text,
)
.into_response()
}
struct Export {
object: String,
text: String,
}
async fn render_export(
database: &nomoreide_core::config::DatabaseDef,
key: &str,
format: db::ExportFormat,
) -> Result<Export, String> {
let object = db::resolve_object(database, key).await?;
if !matches!(object.kind.as_str(), "table" | "view" | "materializedView") {
return Err("This database object cannot be exported.".to_string());
}
let columns = db::columns_for(database, &object).await?;
let sql = db::export_sql(database, &object, &columns);
let result = db::run_query(&database.engine, &database.url, &sql).await?;
let (mut writer, mut text) = db::ExportWriter::new(format, &columns);
for row in result.rows {
text.push_str(&writer.row(row));
}
text.push_str(&writer.finish());
Ok(Export {
object: object.qualified_name,
text,
})
}
fn today() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
async fn tables(State(state): State<AppState>, uri: Uri) -> Response {
with_connection(&state, &uri, |database| async move {
db::peek_tables(&database)
.await
.map(|tables| json!({ "ok": true, "tables": tables }))
})
.await
}
async fn rows(State(state): State<AppState>, uri: Uri) -> Response {
let table = match require_param(&uri, "table") {
Ok(table) => table,
Err(message) => return error(StatusCode::BAD_REQUEST, &message),
};
let params = parse_query(&uri);
let limit = positive_or(params.get("limit").map(String::as_str), 100.0);
let offset = positive_or(params.get("offset").map(String::as_str), 0.0);
with_connection(&state, &uri, |database| async move {
db::peek_sample(&database, &table, limit, offset)
.await
.map(|sample| merge(json!({ "ok": true }), sample))
})
.await
}
async fn query(State(state): State<AppState>, uri: Uri, body: Bytes) -> Response {
let form = parse_form(&body);
let sql = match form
.get("sql")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(sql) => sql.to_string(),
None => return throw("sql is required"),
};
let limit = positive_or(form.get("limit").map(String::as_str), 100.0);
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return config_failure(&reason),
};
let Some(name) = name_from(&uri) else {
return error(StatusCode::NOT_FOUND, "Not found");
};
let outcome = match connection(&config, &name) {
Ok(database) => db::run_capped_query(&database, &sql, limit).await,
Err(reason) => Err(reason),
};
match outcome {
Ok(result) => Json(merge(json!({ "ok": true }), result)).into_response(),
Err(reason) => error(StatusCode::BAD_REQUEST, &reason),
}
}
async fn with_connection<F, Fut>(state: &AppState, uri: &Uri, answer: F) -> Response
where
F: FnOnce(DatabaseDef) -> Fut,
Fut: std::future::Future<Output = Result<Value, String>>,
{
let Some(name) = name_from(uri) else {
return error(StatusCode::NOT_FOUND, "Not found");
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(reason) => return config_failure(&reason),
};
let database = match connection(&config, &name) {
Ok(database) => database,
Err(reason) => return throw(&reason),
};
match answer(database).await {
Ok(body) => Json(body).into_response(),
Err(reason) => throw(&reason),
}
}
fn connection(config: &Config, name: &str) -> Result<DatabaseDef, String> {
db::peek_connection(&config.databases, name).cloned()
}
fn row_browse_query(
params: &std::collections::HashMap<String, String>,
) -> Result<RowBrowseQuery, String> {
let filters = match params.get("filters").filter(|raw| !raw.is_empty()) {
Some(raw) => {
let parsed = js_json::parse(raw)?;
if !parsed.is_array() {
return Err("filters must be a JSON array".to_string());
}
serde_json::from_value::<Vec<RowFilter>>(parsed).map_err(|error| error.to_string())?
}
None => Vec::new(),
};
let direction = params
.get("sortDirection")
.filter(|value| !value.is_empty());
if let Some(direction) = direction {
if direction != "asc" && direction != "desc" {
return Err("sortDirection must be asc or desc".to_string());
}
}
let sort = params
.get("sortColumn")
.filter(|column| !column.is_empty())
.map(|column| RowSort {
column: column.clone(),
direction: if direction.map(String::as_str) == Some("desc") {
"desc".to_string()
} else {
"asc".to_string()
},
});
Ok(RowBrowseQuery { filters, sort })
}
fn positive_or(value: Option<&str>, fallback: f64) -> i64 {
let parsed = js_number(value);
if parsed.is_finite() && parsed > 0.0 {
parsed as i64
} else {
fallback as i64
}
}
fn require_param(uri: &Uri, key: &str) -> Result<String, String> {
parse_query(uri)
.remove(key)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("{key} query param is required"))
}
fn name_from(uri: &Uri) -> Option<String> {
let path = uri.path().strip_prefix("/api/databases/")?;
let segment = path.split('/').next()?;
if segment.is_empty() {
return None;
}
Some(percent_decode(segment))
}
fn merge(mut head: Value, tail: Value) -> Value {
if let (Some(head), Value::Object(tail)) = (head.as_object_mut(), tail) {
for (key, value) in tail {
head.insert(key, value);
}
}
head
}
fn throw(message: &str) -> Response {
error(StatusCode::INTERNAL_SERVER_ERROR, message)
}