use axum::http::{HeaderMap, HeaderValue, StatusCode};
use bytes::Bytes;
use fraiseql_core::{runtime::JsonRowStream, security::SecurityContext};
use futures::StreamExt as _;
use rust_xlsxwriter::Workbook;
use tempfile::NamedTempFile;
use super::{
super::{
export_config::ExportConfig,
handler::{ResolvedGetQuery, RestError, RestHandler, set_request_id},
},
helpers::determine_columns,
};
pub const XLSX_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const XLSX_MAX_CELL_CHARS: usize = 32_767;
#[must_use]
pub fn accepts_xlsx(headers: &HeaderMap) -> bool {
headers.get("accept").and_then(|v| v.to_str().ok()).is_some_and(|accept| {
accept.split(',').any(|part| {
let media = part.split(';').next().unwrap_or(part).trim();
media.eq_ignore_ascii_case(XLSX_CONTENT_TYPE)
})
})
}
pub async fn handle_xlsx_get(
handler: &RestHandler<'_>,
export_config: &ExportConfig,
relative_path: &str,
query_pairs: &[(&str, &str)],
headers: &HeaderMap,
security_context: Option<&SecurityContext>,
) -> Result<XlsxResponse, RestError> {
let resolved = handler.resolve_streaming_get_query(
relative_path,
query_pairs,
headers,
security_context,
)?;
let ResolvedGetQuery {
query_name,
query_match,
variables,
params,
..
} = resolved;
let mut response_headers = HeaderMap::new();
set_request_id(headers, &mut response_headers);
response_headers.insert("content-type", HeaderValue::from_static(XLSX_CONTENT_TYPE));
let filename = sanitize_filename(&query_name);
let disposition = if filename.is_empty() {
"attachment; filename=\"export.xlsx\"".to_string()
} else {
format!("attachment; filename=\"{filename}.xlsx\"")
};
response_headers.insert(
"content-disposition",
HeaderValue::from_str(&disposition)
.unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"export.xlsx\"")),
);
let select_columns = super::helpers::export_columns(&query_match);
let rows = super::helpers::export_rows(
handler.executor(),
query_match,
variables,
security_context.cloned(),
params.requested_pagination.export_total(),
)
.await?;
let bytes = build_workbook(BuildContext {
rows,
max_rows: export_config.xlsx_max_rows,
select_columns,
temp_dir: export_config.xlsx_temp_dir.clone(),
})
.await?;
Ok(XlsxResponse {
headers: response_headers,
body: XlsxBody::Bytes(bytes),
})
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
}
pub struct XlsxResponse {
pub headers: HeaderMap,
pub body: XlsxBody,
}
#[non_exhaustive]
pub enum XlsxBody {
Bytes(Bytes),
}
impl XlsxBody {
pub fn into_body(self) -> axum::body::Body {
match self {
Self::Bytes(bytes) => axum::body::Body::from(bytes),
}
}
}
struct BuildContext {
rows: JsonRowStream,
max_rows: u64,
select_columns: Option<Vec<String>>,
temp_dir: Option<std::path::PathBuf>,
}
async fn build_workbook(ctx: BuildContext) -> Result<Bytes, RestError> {
let temp_file = create_temp_file(ctx.temp_dir.as_deref())?;
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet_with_constant_memory();
let mut columns: Option<Vec<String>> = None;
let mut rows_written: u64 = 0;
let mut rows = ctx.rows;
while let Some(row) = rows.next().await {
let row = row.map_err(|e| RestError::internal(format!("XLSX export failed: {e}")))?;
if columns.is_none() {
let cols = determine_columns(ctx.select_columns.as_deref(), std::slice::from_ref(&row));
write_header_row(worksheet, &cols)?;
columns = Some(cols);
}
let active_columns = columns.as_ref().expect("columns initialised on the first row above");
if rows_written >= ctx.max_rows {
return Err(too_many_rows_error(ctx.max_rows));
}
let row_idx = u32::try_from(rows_written + 1)
.map_err(|_| RestError::internal("XLSX row index overflow"))?;
write_data_row(worksheet, row_idx, active_columns, &row)?;
rows_written += 1;
}
workbook
.save(temp_file.path())
.map_err(|e| RestError::internal(format!("XLSX save failed: {e}")))?;
let bytes = tokio::fs::read(temp_file.path())
.await
.map_err(|e| RestError::internal(format!("XLSX temp-file read failed: {e}")))?;
drop(temp_file);
Ok(Bytes::from(bytes))
}
fn create_temp_file(dir: Option<&std::path::Path>) -> Result<NamedTempFile, RestError> {
let mut builder = tempfile::Builder::new();
builder.prefix("fraiseql-xlsx-").suffix(".xlsx");
let file = match dir {
Some(d) => builder.tempfile_in(d),
None => builder.tempfile(),
};
file.map_err(|e| RestError::internal(format!("XLSX temp-file create failed: {e}")))
}
fn write_header_row(
worksheet: &mut rust_xlsxwriter::Worksheet,
columns: &[String],
) -> Result<(), RestError> {
for (col_idx, name) in columns.iter().enumerate() {
let col = u16::try_from(col_idx)
.map_err(|_| RestError::internal("XLSX column index overflow"))?;
worksheet
.write_string(0, col, truncate_for_xlsx(name))
.map_err(|e| RestError::internal(format!("XLSX header write failed: {e}")))?;
}
Ok(())
}
fn write_data_row(
worksheet: &mut rust_xlsxwriter::Worksheet,
row_idx: u32,
columns: &[String],
row: &serde_json::Value,
) -> Result<(), RestError> {
for (col_idx, col_name) in columns.iter().enumerate() {
let col = u16::try_from(col_idx)
.map_err(|_| RestError::internal("XLSX column index overflow"))?;
let value = row.get(col_name).unwrap_or(&serde_json::Value::Null);
write_cell(worksheet, row_idx, col, value)?;
}
Ok(())
}
fn write_cell(
worksheet: &mut rust_xlsxwriter::Worksheet,
row: u32,
col: u16,
value: &serde_json::Value,
) -> Result<(), RestError> {
use super::guard_formula_injection;
match value {
serde_json::Value::Null => Ok(()),
serde_json::Value::Bool(b) => worksheet.write_boolean(row, col, *b).map(|_| ()),
serde_json::Value::Number(n) => match n.as_f64() {
Some(f) => worksheet.write_number(row, col, f).map(|_| ()),
None => worksheet
.write_string(row, col, truncate_for_xlsx(&guard_formula_injection(&n.to_string())))
.map(|_| ()),
},
serde_json::Value::String(s) => worksheet
.write_string(row, col, truncate_for_xlsx(&guard_formula_injection(s)))
.map(|_| ()),
other => worksheet
.write_string(
row,
col,
truncate_for_xlsx(&guard_formula_injection(
&serde_json::to_string(other).unwrap_or_default(),
)),
)
.map(|_| ()),
}
.map_err(|e| RestError::internal(format!("XLSX cell write failed: {e}")))
}
fn truncate_for_xlsx(s: &str) -> String {
if s.chars().count() <= XLSX_MAX_CELL_CHARS {
return s.to_string();
}
let mut out: String = s.chars().take(XLSX_MAX_CELL_CHARS - 1).collect();
out.push('…');
out
}
fn too_many_rows_error(max_rows: u64) -> RestError {
RestError {
status: StatusCode::PAYLOAD_TOO_LARGE,
code: "XLSX_ROW_LIMIT_EXCEEDED",
message: format!(
"XLSX export exceeds the {max_rows}-row cap; request `Accept: text/csv` for larger \
result sets"
),
details: None,
}
}
#[cfg(test)]
mod tests;