Skip to main content

sofie_http/
csv.rs

1//! CSV out of a list a loader answers (`.document(Media::Csv)`) and into an
2//! action's input, row by row (`CsvRows`), as RFC 4180 writes it.
3
4use serde_json::{Map, Value};
5use sofie_core::{Error, FieldError, Result, Shown};
6
7use crate::{Area, RequestContext};
8use sofie_db::{Input, Resolve};
9
10/// A spreadsheet runs a cell starting with one of these as a formula, unless it is a number.
11const FORMULA_STARTS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
12
13/// One row per record of the list, headed by the first record's field names.
14pub(crate) fn render(shown: Shown<'_>) -> Result<String> {
15    let Shown::List(items) = shown else {
16        return Err(Error::internal_message(
17            "a CSV route's loader answered no list of records: an export answers every row as a \
18             `Vec`, not a page such as `Paginated` or `Listed`",
19        ));
20    };
21    let mut csv = String::new();
22    let mut names: Vec<&str> = Vec::new();
23    for index in 0.. {
24        let Some(item) = items.item(index) else { break };
25        let Shown::Record(fields) = item else {
26            return Err(Error::internal_message(
27                "a CSV route's loader answered a list of something other than records",
28            ));
29        };
30        if index == 0 {
31            names = fields.names().collect();
32            push_row(&mut csv, names.iter().map(|name| (*name).to_owned()));
33        }
34        push_row(
35            &mut csv,
36            names
37                .iter()
38                .map(|name| fields.get(name).map(cell).unwrap_or_default()),
39        );
40    }
41    Ok(csv)
42}
43
44fn push_row(csv: &mut String, cells: impl Iterator<Item = String>) {
45    for (index, cell) in cells.enumerate() {
46        if index > 0 {
47            csv.push(',');
48        }
49        push_cell(csv, &cell);
50    }
51    csv.push_str("\r\n");
52}
53
54fn push_cell(csv: &mut String, cell: &str) {
55    let guarded = cell.starts_with(FORMULA_STARTS) && cell.parse::<f64>().is_err();
56    if !guarded && !cell.contains([',', '"', '\n', '\r']) {
57        csv.push_str(cell);
58        return;
59    }
60    csv.push('"');
61    if guarded {
62        csv.push('\'');
63    }
64    csv.push_str(&cell.replace('"', "\"\""));
65    csv.push('"');
66}
67
68fn cell(shown: Shown<'_>) -> String {
69    match shown {
70        Shown::Null => String::new(),
71        Shown::Bool(flag) => flag.to_string(),
72        Shown::Integer(number) => number.to_string(),
73        Shown::Float(number) => number.to_string(),
74        Shown::Number(number) => number.to_string(),
75        Shown::Text(text) => text.to_owned(),
76        Shown::Uuid(id) => id.to_string(),
77        Shown::Instant(instant) => instant.to_string(),
78        Shown::Formatted(value) => value.to_string(),
79        Shown::List(items) => (0..)
80            .map_while(|index| items.item(index))
81            .map(cell)
82            .collect::<Vec<_>>()
83            .join("; "),
84        Shown::Record(fields) => fields
85            .names()
86            .filter_map(|name| fields.get(name))
87            .map(cell)
88            .filter(|text| !text.is_empty())
89            .collect::<Vec<_>>()
90            .join(" "),
91    }
92}
93
94/// The request body's CSV, each row read as its input is from a form; a row
95/// the input refuses refuses them all, its errors named `<row>.<field>`.
96pub struct CsvRows<I>(pub Vec<I>);
97
98impl<A: Area, I: Input + Send + 'static> Resolve<RequestContext<A>> for CsvRows<I> {
99    fn resolve(context: &mut RequestContext<A>) -> impl Future<Output = Result<Self>> + Send {
100        let body = context.matched.body.take();
101        std::future::ready(
102            body.ok_or_else(crate::context::body_already_read)
103                .and_then(|body| {
104                    let text = std::str::from_utf8(&body)
105                        .map_err(|_| Error::BadRequest("the CSV is not UTF-8".to_owned()))?;
106                    rows(text)
107                }),
108        )
109    }
110}
111
112fn rows<I: Input>(text: &str) -> Result<CsvRows<I>> {
113    let mut lines = parse(text)?.into_iter();
114    let header = lines
115        .next()
116        .ok_or_else(|| Error::BadRequest("the CSV has no header row".to_owned()))?;
117    let mut read = Vec::new();
118    let mut refused = Vec::new();
119    for (index, cells) in lines.enumerate() {
120        let row = index + 2;
121        if cells.len() != header.len() {
122            return Err(Error::BadRequest(format!(
123                "row {row} has {} cells; the header names {}",
124                cells.len(),
125                header.len()
126            )));
127        }
128        let fields: Map<String, Value> = header
129            .iter()
130            .cloned()
131            .zip(cells.into_iter().map(Value::String))
132            .collect();
133        match I::from_json(Value::Object(fields)) {
134            Ok(input) => read.push(input),
135            Err(Error::Invalid(errors)) => {
136                refused.extend(errors.into_iter().map(|error| FieldError {
137                    field: format!("{row}.{}", error.field),
138                    ..error
139                }));
140            }
141            Err(error) => return Err(error),
142        }
143    }
144    if refused.is_empty() {
145        Ok(CsvRows(read))
146    } else {
147        Err(Error::Invalid(refused))
148    }
149}
150
151/// Fields quoted with `"` may hold commas, line breaks and `""`; blank lines are skipped.
152fn parse(text: &str) -> Result<Vec<Vec<String>>> {
153    let mut rows = Vec::new();
154    let mut row = Vec::new();
155    let mut cell = String::new();
156    let mut quoted = false;
157    let mut characters = text.trim_start_matches('\u{feff}').chars().peekable();
158    while let Some(character) = characters.next() {
159        match (quoted, character) {
160            (true, '"') if characters.peek() == Some(&'"') => {
161                cell.push('"');
162                characters.next();
163            }
164            (true, '"') => quoted = false,
165            (false, '"') if cell.is_empty() => quoted = true,
166            (false, ',') => row.push(std::mem::take(&mut cell)),
167            (false, '\r') => {}
168            (false, '\n') => end_row(&mut rows, &mut row, &mut cell),
169            _ => cell.push(character),
170        }
171    }
172    if quoted {
173        return Err(Error::BadRequest(
174            "the CSV ends inside a quoted cell".to_owned(),
175        ));
176    }
177    end_row(&mut rows, &mut row, &mut cell);
178    Ok(rows)
179}
180
181fn end_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>, cell: &mut String) {
182    row.push(std::mem::take(cell));
183    let row = std::mem::take(row);
184    if row.len() > 1 || !row[0].is_empty() {
185        rows.push(row);
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use serde_json::json;
192
193    use super::*;
194
195    #[test]
196    fn writes_quoted_cells_and_defuses_formulas() {
197        let list = json!([
198            {"name": "Scarf, blue", "note": "said \"soft\"", "total": 12},
199            {"name": "=HYPERLINK(\"x\")", "note": null, "total": -3},
200        ]);
201
202        let csv = render(sofie_core::Show::show(&list)).unwrap();
203
204        assert_eq!(
205            csv,
206            "name,note,total\r\n\"Scarf, blue\",\"said \"\"soft\"\"\",12\r\n\
207             \"'=HYPERLINK(\"\"x\"\")\",,-3\r\n"
208        );
209    }
210
211    #[test]
212    fn a_page_of_a_list_is_refused_with_what_to_answer_instead() {
213        let page = json!({"items": [{"name": "Scarf"}], "page": 1, "size": 20, "has_next": false});
214
215        let error = render(sofie_core::Show::show(&page))
216            .unwrap_err()
217            .to_string();
218
219        assert!(error.contains("not a page"), "{error}");
220    }
221
222    #[test]
223    fn reads_quoted_cells_line_breaks_and_a_byte_order_mark() {
224        let rows =
225            parse("\u{feff}name,note\r\n\"a, b\",\"two\nlines\"\n\n\"say \"\"hi\"\"\",\n").unwrap();
226
227        assert_eq!(
228            rows,
229            [
230                vec!["name", "note"],
231                vec!["a, b", "two\nlines"],
232                vec!["say \"hi\"", ""],
233            ]
234        );
235        assert!(parse("name\n\"open").is_err());
236    }
237}