linkrs_server/http/handlers/
export.rs1use axum::body::{Body, Bytes};
4use axum::http::{header, StatusCode};
5use axum::{
6 extract::{Extension, Query, State},
7 response::Response,
8};
9use serde::Deserialize;
10use tokio_stream::wrappers::ReceiverStream;
11
12use super::query::result_size_limit;
13use crate::http::{error::HttpError, state::AppState};
14use crate::storage::{
15 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
16};
17use crate::value::to_json as value_to_json;
18
19#[derive(Debug, Deserialize, utoipa::ToSchema)]
20pub struct ExportQuery {
21 pub space: Option<String>,
22 pub format: Option<String>,
23 pub query: Option<String>,
24 pub all: Option<String>,
25 pub session_id: Option<i64>,
26}
27
28#[utoipa::path(
29 get,
30 path = "/v1/export",
31 tag = "Export",
32 params(
33 ("space" = Option<String>, Query, description = "Space to export (reserved, session space applies)"),
34 ("format" = Option<String>, Query, description = "Export format: csv or jsonl"),
35 ("query" = Option<String>, Query, description = "Query selecting rows to export"),
36 ("all" = Option<String>, Query, description = "Reserved"),
37 ("session_id" = Option<i64>, Query, description = "Session id executing the export query")
38 ),
39 responses(
40 (status = 200, description = "Export file download"),
41 (status = 400, description = "Invalid export request"),
42 (status = 500, description = "Internal error")
43 )
44)]
45pub async fn export_data<
60 S: StorageClient
61 + StorageSchemaContextOps
62 + StorageSyncContextOps
63 + StorageOperationContextOps
64 + Clone
65 + Send
66 + Sync
67 + 'static,
68>(
69 State(state): State<AppState<S>>,
70 Extension(caller_session_id): Extension<i64>,
71 Query(params): Query<ExportQuery>,
72) -> Result<Response, HttpError> {
73 let format = params.format.as_deref().unwrap_or("csv");
74 let (content_type, file_ext) = match format {
75 "csv" => ("text/csv", "csv"),
76 "jsonl" => ("application/x-ndjson", "jsonl"),
77 other => {
78 return Err(HttpError::bad_request(format!(
79 "Unsupported export format '{other}'; use csv or jsonl"
80 )));
81 }
82 };
83 let query = params
84 .query
85 .as_deref()
86 .map(str::trim)
87 .filter(|q| !q.is_empty())
88 .ok_or_else(|| HttpError::bad_request("Export requires a non-empty `query` parameter"))?;
89 let session_id = params
90 .session_id
91 .ok_or_else(|| HttpError::bad_request("Export requires a `session_id` parameter"))?;
92 super::authz::require_session_owner_or_admin(&state, caller_session_id, session_id)?;
93
94 let server = state.server.clone();
95 let row_limit = result_size_limit(&state);
96
97 let stream_result = server
100 .get_graph_service()
101 .execute_stream(session_id, query)
102 .await
103 .map_err(|e| {
104 if e.message().contains("Invalid session ID") {
105 HttpError::bad_request(e.message().to_string())
106 } else {
107 HttpError::InternalError(e.message().to_string())
108 }
109 })?;
110
111 let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, std::io::Error>>(100);
112 let as_csv = format == "csv";
113 tokio::task::spawn_blocking(move || {
114 let mut columns: Option<Vec<String>> = stream_result.column_names();
115 let mut header_sent = false;
116 let mut exported_rows: usize = 0;
117
118 let send_failure = |tx: &tokio::sync::mpsc::Sender<Result<Bytes, std::io::Error>>,
121 message: String| {
122 stream_result.cancel();
123 let _ = tx.blocking_send(Err(std::io::Error::other(message)));
124 };
125
126 loop {
127 match stream_result.next_chunk() {
128 Ok(Some(chunk)) => {
129 let names = chunk.col_names();
130 if columns.is_none() {
131 columns = Some(names.clone());
132 }
133 if as_csv && !header_sent {
134 let header = csv_header_line(columns.as_ref());
135 if tx
136 .blocking_send(Ok(Bytes::from(format!("{header}\n"))))
137 .is_err()
138 {
139 stream_result.cancel();
140 return;
141 }
142 header_sent = true;
143 }
144 for row in chunk.rows {
145 if let Some(limit) = row_limit {
146 if exported_rows >= limit {
147 send_failure(
148 &tx,
149 format!("Result size limit of {limit} rows exceeded"),
150 );
151 return;
152 }
153 }
154 let values: Vec<serde_json::Value> =
155 row.into_iter().map(value_to_json).collect();
156 let line = if as_csv {
157 encode_csv_line(&names, &values)
158 } else {
159 encode_jsonl_line(&names, &values)
160 };
161 exported_rows += 1;
162 if tx.blocking_send(Ok(Bytes::from(line))).is_err() {
163 stream_result.cancel();
164 return;
165 }
166 }
167 }
168 Ok(None) => {
169 if as_csv && !header_sent {
171 let header = csv_header_line(columns.as_ref());
172 let _ = tx.blocking_send(Ok(Bytes::from(format!("{header}\n"))));
173 }
174 return;
175 }
176 Err(e) => {
177 send_failure(&tx, format!("Export query failed: {e}"));
178 return;
179 }
180 }
181 }
182 });
183
184 let response = Response::builder()
185 .status(StatusCode::OK)
186 .header(header::CONTENT_TYPE, content_type)
187 .header(
188 header::CONTENT_DISPOSITION,
189 format!("attachment; filename=\"export.{file_ext}\""),
190 )
191 .body(Body::from_stream(ReceiverStream::new(rx)))
192 .map_err(|e| HttpError::InternalError(format!("Failed to build response: {}", e)))?;
193
194 Ok(response)
195}
196
197fn csv_header_line(columns: Option<&Vec<String>>) -> String {
199 columns
200 .map(|cols| {
201 cols.iter()
202 .map(|c| csv_escape(c))
203 .collect::<Vec<_>>()
204 .join(",")
205 })
206 .unwrap_or_default()
207}
208
209fn encode_csv_line(columns: &[String], values: &[serde_json::Value]) -> String {
211 let mut cells = Vec::with_capacity(columns.len());
212 for (i, _col) in columns.iter().enumerate() {
213 let cell = values.get(i).map(cell_text).unwrap_or_default();
214 cells.push(csv_escape(&cell));
215 }
216 cells.join(",") + "\n"
217}
218
219fn encode_jsonl_line(columns: &[String], values: &[serde_json::Value]) -> String {
221 let obj: serde_json::Map<String, serde_json::Value> = columns
222 .iter()
223 .enumerate()
224 .map(|(i, col)| {
225 (
226 col.clone(),
227 values.get(i).cloned().unwrap_or(serde_json::Value::Null),
228 )
229 })
230 .collect();
231 serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
232 + "\n"
233}
234
235fn cell_text(value: &serde_json::Value) -> String {
237 match value {
238 serde_json::Value::Null => String::new(),
239 serde_json::Value::String(s) => s.clone(),
240 serde_json::Value::Bool(b) => b.to_string(),
241 serde_json::Value::Number(n) => n.to_string(),
242 serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
243 }
244}
245
246fn csv_escape(field: &str) -> String {
248 if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
249 format!("\"{}\"", field.replace('"', "\"\""))
250 } else {
251 field.to_string()
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 #[test]
261 fn csv_escapes_delimiters_quotes_and_line_breaks() {
262 assert_eq!(csv_escape("plain"), "plain");
263 assert_eq!(csv_escape("a,b"), "\"a,b\"");
264 assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\"");
265 assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\"");
266 assert_eq!(csv_escape("line1\r\nline2"), "\"line1\r\nline2\"");
267 assert_eq!(csv_escape(""), "");
268 }
269
270 #[test]
271 fn csv_header_renders_once_for_empty_results() {
272 assert_eq!(
273 csv_header_line(Some(&vec!["a".to_string(), "b,c".to_string()])),
274 "a,\"b,c\""
275 );
276 assert_eq!(csv_header_line(None), "");
277 }
278
279 #[test]
280 fn csv_line_orders_cells_and_stringifies_objects() {
281 let columns = vec![
282 "name".to_string(),
283 "meta".to_string(),
284 "missing".to_string(),
285 ];
286 let values = vec![json!("a,b"), json!({"k": 1})];
287 assert_eq!(
288 encode_csv_line(&columns, &values),
289 "\"a,b\",\"{\"\"k\"\":1}\",\n"
290 );
291 }
292
293 #[test]
294 fn jsonl_line_maps_columns_to_values() {
295 let columns = vec!["a".to_string(), "b".to_string()];
296 let values = vec![json!(1), json!("x")];
297 assert_eq!(
298 encode_jsonl_line(&columns, &values),
299 "{\"a\":1,\"b\":\"x\"}\n"
300 );
301 }
302}