Skip to main content

spring_batch_rs/item/json/
json_writer.rs

1use std::{
2    cell::{Cell, RefCell},
3    fs::File,
4    io::{BufWriter, Write},
5    marker::PhantomData,
6    path::Path,
7};
8
9use crate::{
10    BatchError,
11    core::item::{ItemWriter, ItemWriterResult},
12};
13
14/// A writer that writes items to a JSON output.
15///
16/// The writer serializes items to JSON format and writes them as an array to the output.
17/// It handles proper JSON formatting, including opening and closing brackets for the array
18/// and separating items with commas.
19///
20/// # Examples
21///
22/// ```
23/// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
24/// use spring_batch_rs::core::item::ItemWriter;
25/// use serde::Serialize;
26/// use std::io::Cursor;
27///
28/// // Define a data structure
29/// #[derive(Serialize)]
30/// struct Product {
31///     id: u32,
32///     name: String,
33///     price: f64,
34/// }
35///
36/// // Create some products to write
37/// let products = vec![
38///     Product { id: 1, name: "Widget".to_string(), price: 19.99 },
39///     Product { id: 2, name: "Gadget".to_string(), price: 24.99 },
40/// ];
41///
42/// // Create a writer to an in-memory buffer
43/// let buffer = Cursor::new(Vec::new());
44/// let writer = JsonItemWriterBuilder::<Product>::new()
45///     .from_writer(buffer);
46///
47/// // Write the products to JSON
48/// let writer_ref = &writer as &dyn ItemWriter<Product>;
49/// writer_ref.open().unwrap();
50/// writer_ref.write(&products).unwrap();
51/// writer_ref.close().unwrap();
52/// ```
53pub struct JsonItemWriter<O, W: Write> {
54    /// The buffered writer for the output stream
55    stream: RefCell<BufWriter<W>>,
56    /// Whether to use pretty formatting (indentation and newlines)
57    use_pretty_formatter: bool,
58    /// Custom indentation to use when pretty formatting
59    indent: Box<[u8]>,
60    /// Tracks whether we're writing the first element (to handle commas between items)
61    is_first_element: Cell<bool>,
62    _phantom: PhantomData<O>,
63}
64
65impl<O: serde::Serialize, W: Write> ItemWriter<O> for JsonItemWriter<O, W> {
66    /// Writes a batch of items to the JSON output.
67    ///
68    /// This method serializes each item to JSON, adds commas between items,
69    /// and writes the result to the output stream. It keeps track of whether
70    /// it's writing the first element to properly format the array.
71    ///
72    /// # Parameters
73    /// - `items`: A slice of items to be serialized and written
74    ///
75    /// # Returns
76    /// - `Ok(())` if successful
77    /// - `Err(BatchError)` if writing fails
78    fn write(&self, items: &[O]) -> ItemWriterResult {
79        let mut json_chunk = String::new();
80
81        for item in items.iter() {
82            if !self.is_first_element.get() {
83                json_chunk.push(',');
84            } else {
85                self.is_first_element.set(false);
86            }
87
88            let result = if self.use_pretty_formatter {
89                // Use custom indentation if pretty formatting is enabled
90                let mut buf = Vec::new();
91                let formatter = serde_json::ser::PrettyFormatter::with_indent(&self.indent);
92                let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
93                match item.serialize(&mut ser) {
94                    Ok(_) => match String::from_utf8(buf) {
95                        Ok(s) => Ok(s),
96                        Err(e) => Err(BatchError::ItemWriter(e.to_string())),
97                    },
98                    Err(e) => Err(BatchError::ItemWriter(e.to_string())),
99                }
100            } else {
101                serde_json::to_string(item).map_err(|e| BatchError::ItemWriter(e.to_string()))
102            };
103
104            match result {
105                Ok(json_str) => json_chunk.push_str(&json_str),
106                Err(e) => return Err(e),
107            }
108
109            if self.use_pretty_formatter {
110                json_chunk.push('\n');
111            }
112        }
113
114        let result = self.stream.borrow_mut().write_all(json_chunk.as_bytes());
115
116        match result {
117            Ok(_ser) => Ok(()),
118            Err(error) => Err(BatchError::ItemWriter(error.to_string())),
119        }
120    }
121
122    /// Flushes the output buffer to ensure all data is written to the underlying stream.
123    ///
124    /// # Returns
125    /// - `Ok(())` if successful
126    /// - `Err(BatchError)` if flushing fails
127    fn flush(&self) -> ItemWriterResult {
128        let result = self.stream.borrow_mut().flush();
129
130        match result {
131            Ok(()) => Ok(()),
132            Err(error) => Err(BatchError::ItemWriter(error.to_string())),
133        }
134    }
135
136    /// Opens the JSON writer and writes the opening array bracket.
137    ///
138    /// This method should be called before any calls to write().
139    ///
140    /// # Returns
141    /// - `Ok(())` if successful
142    /// - `Err(BatchError)` if writing fails
143    fn open(&self) -> ItemWriterResult {
144        let begin_array = if self.use_pretty_formatter {
145            b"[\n".to_vec()
146        } else {
147            b"[".to_vec()
148        };
149
150        let result = self.stream.borrow_mut().write_all(&begin_array);
151
152        match result {
153            Ok(()) => Ok(()),
154            Err(error) => Err(BatchError::ItemWriter(error.to_string())),
155        }
156    }
157
158    /// Closes the JSON writer and writes the closing array bracket.
159    ///
160    /// This method should be called after all items have been written.
161    /// It also flushes the buffer to ensure all data is written.
162    ///
163    /// # Returns
164    /// - `Ok(())` if successful
165    /// - `Err(BatchError)` if writing fails
166    fn close(&self) -> ItemWriterResult {
167        let end_array = if self.use_pretty_formatter {
168            b"\n]\n".to_vec()
169        } else {
170            b"]\n".to_vec()
171        };
172
173        let result = self.stream.borrow_mut().write_all(&end_array);
174        let _ = self.stream.borrow_mut().flush();
175
176        match result {
177            Ok(()) => Ok(()),
178            Err(error) => Err(BatchError::ItemWriter(error.to_string())),
179        }
180    }
181}
182
183/// A builder for creating JSON item writers.
184///
185/// This builder provides a convenient way to configure and create a `JsonItemWriter`
186/// with options like pretty formatting and custom indentation.
187///
188/// # Examples
189///
190/// ```
191/// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
192/// use spring_batch_rs::core::item::ItemWriter;
193/// use serde::Serialize;
194/// use std::io::Cursor;
195///
196/// // Define a data structure
197/// #[derive(Serialize)]
198/// struct Person {
199///     id: u32,
200///     name: String,
201///     email: String,
202/// }
203///
204/// // Create a writer with pretty formatting
205/// let buffer = Cursor::new(Vec::new());
206/// let writer = JsonItemWriterBuilder::<Person>::new()
207///     .pretty_formatter(true)
208///     .from_writer(buffer);
209///
210/// // Use the writer to serialize a person
211/// let person = Person {
212///     id: 1,
213///     name: "Alice".to_string(),
214///     email: "alice@example.com".to_string(),
215/// };
216///
217/// let writer_ref = &writer as &dyn ItemWriter<Person>;
218/// writer_ref.open().unwrap();
219/// writer_ref.write(&[person]).unwrap();
220/// writer_ref.close().unwrap();
221/// ```
222pub struct JsonItemWriterBuilder<O> {
223    /// Indentation to use when pretty-printing (default is two spaces)
224    indent: Box<[u8]>,
225    /// Whether to use pretty formatting with indentation and newlines
226    pretty_formatter: bool,
227    /// Phantom data for the output type
228    _pd: PhantomData<O>,
229}
230
231impl<O> Default for JsonItemWriterBuilder<O> {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl<O> JsonItemWriterBuilder<O> {
238    /// Creates a new JSON item writer builder with default settings.
239    ///
240    /// By default, the writer uses compact formatting (not pretty-printed)
241    /// and a standard indentation of two spaces.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
247    ///
248    /// let builder = JsonItemWriterBuilder::<String>::new();
249    /// ```
250    pub fn new() -> Self {
251        Self {
252            indent: Box::from(b"  ".to_vec()),
253            pretty_formatter: false,
254            _pd: PhantomData,
255        }
256    }
257
258    /// Sets the indentation to use when pretty-printing JSON.
259    ///
260    /// This setting only has an effect if pretty formatting is enabled.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
266    ///
267    /// // Use 4 spaces for indentation
268    /// let builder = JsonItemWriterBuilder::<String>::new()
269    ///     .indent(b"    ")
270    ///     .pretty_formatter(true);
271    /// ```
272    pub fn indent(mut self, indent: &[u8]) -> Self {
273        self.indent = Box::from(indent);
274        self
275    }
276
277    /// Enables or disables pretty formatting of the JSON output.
278    ///
279    /// When enabled, the JSON output will include newlines and indentation
280    /// to make it more human-readable. This is useful for debugging or
281    /// when the output will be read by humans.
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
287    ///
288    /// // Enable pretty printing
289    /// let pretty_builder = JsonItemWriterBuilder::<String>::new()
290    ///     .pretty_formatter(true);
291    ///
292    /// // Disable pretty printing for compact output
293    /// let compact_builder = JsonItemWriterBuilder::<String>::new()
294    ///     .pretty_formatter(false);
295    /// ```
296    pub fn pretty_formatter(mut self, yes: bool) -> Self {
297        self.pretty_formatter = yes;
298        self
299    }
300
301    /// Creates a JSON item writer that writes to a file at the specified path.
302    ///
303    /// This method creates a new file (or truncates an existing one) and
304    /// configures a JsonItemWriter to write to it.
305    ///
306    /// # Parameters
307    /// - `path`: The path where the output file will be created
308    ///
309    /// # Returns
310    /// A configured JsonItemWriter instance
311    ///
312    /// # Panics
313    /// Panics if the file cannot be created
314    ///
315    /// # Examples
316    ///
317    /// ```no_run
318    /// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
319    /// use spring_batch_rs::core::item::ItemWriter;
320    /// use serde::Serialize;
321    /// use std::path::Path;
322    ///
323    /// #[derive(Serialize)]
324    /// struct Record {
325    ///     id: u32,
326    ///     data: String,
327    /// }
328    ///
329    /// // Create a writer to a file
330    /// let writer = JsonItemWriterBuilder::<Record>::new()
331    ///     .pretty_formatter(true)
332    ///     .from_path("output.json");
333    ///
334    /// // Generate some data
335    /// let records = vec![
336    ///     Record { id: 1, data: "First record".to_string() },
337    ///     Record { id: 2, data: "Second record".to_string() },
338    /// ];
339    ///
340    /// // Write the data to the file
341    /// let writer_ref = &writer as &dyn ItemWriter<Record>;
342    /// writer_ref.open().unwrap();
343    /// writer_ref.write(&records).unwrap();
344    /// writer_ref.close().unwrap();
345    /// ```
346    pub fn from_path<W: AsRef<Path>>(self, path: W) -> JsonItemWriter<O, File> {
347        let file = File::create(path).expect("Unable to open file");
348
349        let buf_writer = BufWriter::new(file);
350
351        JsonItemWriter {
352            stream: RefCell::new(buf_writer),
353            use_pretty_formatter: self.pretty_formatter,
354            indent: self.indent.clone(),
355            is_first_element: Cell::new(true),
356            _phantom: PhantomData,
357        }
358    }
359
360    /// Creates a JSON item writer that writes to any destination implementing the `Write` trait.
361    ///
362    /// This allows writing to in-memory buffers, network connections, or other custom destinations.
363    ///
364    /// # Parameters
365    /// - `wtr`: The writer instance to use for output
366    ///
367    /// # Returns
368    /// A configured JsonItemWriter instance
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// use spring_batch_rs::item::json::json_writer::JsonItemWriterBuilder;
374    /// use spring_batch_rs::core::item::ItemWriter;
375    /// use serde::Serialize;
376    /// use std::io::Cursor;
377    ///
378    /// #[derive(Serialize)]
379    /// struct Event {
380    ///     timestamp: u64,
381    ///     message: String,
382    /// }
383    ///
384    /// // Create a writer to an in-memory buffer
385    /// let buffer = Cursor::new(Vec::new());
386    /// let writer = JsonItemWriterBuilder::<Event>::new()
387    ///     .from_writer(buffer);
388    ///
389    /// // Generate some data
390    /// let events = vec![
391    ///     Event { timestamp: 1620000000, message: "Server started".to_string() },
392    ///     Event { timestamp: 1620000060, message: "Connected to database".to_string() },
393    /// ];
394    ///
395    /// // Write the data
396    /// let writer_ref = &writer as &dyn ItemWriter<Event>;
397    /// writer_ref.open().unwrap();
398    /// writer_ref.write(&events).unwrap();
399    /// writer_ref.close().unwrap();
400    /// ```
401    pub fn from_writer<W: Write>(self, wtr: W) -> JsonItemWriter<O, W> {
402        let buf_writer = BufWriter::new(wtr);
403
404        JsonItemWriter {
405            stream: RefCell::new(buf_writer),
406            use_pretty_formatter: self.pretty_formatter,
407            indent: self.indent,
408            is_first_element: Cell::new(true),
409            _phantom: PhantomData,
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::core::item::ItemWriter;
418    use serde::Serialize;
419    use std::fs;
420    use tempfile::tempdir;
421
422    #[derive(Serialize, Debug, PartialEq)]
423    struct TestItem {
424        id: u32,
425        name: String,
426        value: f64,
427    }
428
429    #[test]
430    fn json_writer_builder_should_create_with_defaults() {
431        let builder = JsonItemWriterBuilder::<TestItem>::new();
432        assert!(!builder.pretty_formatter);
433        assert_eq!(builder.indent, b"  ".to_vec().into_boxed_slice());
434    }
435
436    #[test]
437    fn json_writer_builder_should_set_pretty_formatter() {
438        let builder = JsonItemWriterBuilder::<TestItem>::new().pretty_formatter(true);
439        assert!(builder.pretty_formatter);
440    }
441
442    #[test]
443    fn json_writer_builder_should_set_custom_indent() {
444        let custom_indent = b"    ";
445        let builder = JsonItemWriterBuilder::<TestItem>::new().indent(custom_indent);
446        assert_eq!(builder.indent, custom_indent.to_vec().into_boxed_slice());
447    }
448
449    #[test]
450    fn json_writer_builder_should_implement_default() {
451        let builder1 = JsonItemWriterBuilder::<TestItem>::new();
452        let builder2 = JsonItemWriterBuilder::<TestItem>::default();
453
454        assert_eq!(builder1.pretty_formatter, builder2.pretty_formatter);
455        // Both should have the same default indent
456        assert_eq!(builder1.indent, builder2.indent);
457    }
458
459    #[test]
460    fn json_writer_builder_should_support_generic_type() {
461        let _builder = JsonItemWriterBuilder::<TestItem>::new();
462        let _builder_default = JsonItemWriterBuilder::<TestItem>::default();
463    }
464
465    #[test]
466    fn json_writer_from_path_should_create_file_writer() {
467        let temp_dir = tempdir().unwrap();
468        let file_path = temp_dir.path().join("test_output.json");
469
470        let writer: JsonItemWriter<TestItem, File> =
471            JsonItemWriterBuilder::new().from_path(&file_path);
472
473        let item = TestItem {
474            id: 1,
475            name: "test".to_string(),
476            value: 42.5,
477        };
478
479        writer.open().unwrap();
480        writer.write(&[item]).unwrap();
481        writer.close().unwrap();
482
483        // Verify file was created and contains expected content
484        let content = fs::read_to_string(&file_path).unwrap();
485        assert!(content.contains(r#"{"id":1,"name":"test","value":42.5}"#));
486    }
487
488    #[test]
489    fn json_writer_should_handle_custom_indent() {
490        let temp_dir = tempdir().unwrap();
491        let file_path = temp_dir.path().join("indent_test.json");
492
493        let writer = JsonItemWriterBuilder::new()
494            .pretty_formatter(true)
495            .indent(b"\t")
496            .from_path(&file_path);
497
498        let item = TestItem {
499            id: 1,
500            name: "test".to_string(),
501            value: 42.5,
502        };
503
504        writer.open().unwrap();
505        writer.write(&[item]).unwrap();
506        writer.close().unwrap();
507
508        let content = fs::read_to_string(&file_path).unwrap();
509        // Check that the content uses tab indentation in pretty format
510        // The JSON should contain tab characters for indentation
511        assert!(content.contains('\t'));
512    }
513
514    #[test]
515    fn json_writer_should_handle_pretty_formatting() {
516        let temp_dir = tempdir().unwrap();
517        let file_path = temp_dir.path().join("pretty_test.json");
518
519        let writer = JsonItemWriterBuilder::new()
520            .pretty_formatter(true)
521            .from_path(&file_path);
522
523        let item = TestItem {
524            id: 1,
525            name: "test".to_string(),
526            value: 42.5,
527        };
528
529        writer.open().unwrap();
530        writer.write(&[item]).unwrap();
531        writer.close().unwrap();
532
533        let content = fs::read_to_string(&file_path).unwrap();
534        assert!(content.contains("[\n"));
535        assert!(content.contains("\n]\n"));
536        assert!(content.contains("  \"id\": 1"));
537    }
538
539    #[test]
540    fn json_writer_should_handle_empty_items() {
541        let temp_dir = tempdir().unwrap();
542        let file_path = temp_dir.path().join("empty_test.json");
543
544        let writer = JsonItemWriterBuilder::new().from_path(&file_path);
545        let empty_items: Vec<TestItem> = vec![];
546
547        writer.open().unwrap();
548        writer.write(&empty_items).unwrap();
549        writer.close().unwrap();
550
551        let content = fs::read_to_string(&file_path).unwrap();
552        assert_eq!(content, "[]\n");
553    }
554
555    #[test]
556    fn json_writer_should_handle_multiple_writes() {
557        let temp_dir = tempdir().unwrap();
558        let file_path = temp_dir.path().join("multi_test.json");
559
560        let writer = JsonItemWriterBuilder::new().from_path(&file_path);
561
562        let item1 = TestItem {
563            id: 1,
564            name: "first".to_string(),
565            value: 10.0,
566        };
567        let item2 = TestItem {
568            id: 2,
569            name: "second".to_string(),
570            value: 20.0,
571        };
572
573        writer.open().unwrap();
574        writer.write(&[item1]).unwrap();
575        writer.write(&[item2]).unwrap();
576        writer.close().unwrap();
577
578        let content = fs::read_to_string(&file_path).unwrap();
579        assert!(content.contains(r#"{"id":1,"name":"first","value":10.0}"#));
580        assert!(content.contains(r#"{"id":2,"name":"second","value":20.0}"#));
581        assert!(content.contains(','));
582    }
583
584    #[test]
585    fn json_writer_should_write_to_in_memory_buffer() {
586        use std::io::Cursor;
587
588        let buf = Cursor::new(Vec::new());
589        let writer = JsonItemWriterBuilder::<TestItem>::new().from_writer(buf);
590
591        let item = TestItem {
592            id: 7,
593            name: "cursor".to_string(),
594            value: 0.5,
595        };
596        writer.open().unwrap();
597        writer.write(&[item]).unwrap();
598        writer.close().unwrap();
599        // Reaching here without panic confirms from_writer + write + close work
600    }
601
602    #[test]
603    fn json_writer_should_flush_without_error() {
604        let temp_dir = tempdir().unwrap();
605        let file_path = temp_dir.path().join("flush_test.json");
606
607        let writer = JsonItemWriterBuilder::<TestItem>::new().from_path(&file_path);
608        writer.open().unwrap();
609        writer.flush().unwrap();
610        writer.close().unwrap();
611    }
612
613    #[test]
614    fn json_writer_compact_open_writes_bracket_without_newline() {
615        let temp_dir = tempdir().unwrap();
616        let file_path = temp_dir.path().join("compact_open.json");
617
618        let writer = JsonItemWriterBuilder::<TestItem>::new()
619            .pretty_formatter(false)
620            .from_path(&file_path);
621        writer.open().unwrap();
622        writer.close().unwrap();
623
624        let content = fs::read_to_string(&file_path).unwrap();
625        assert_eq!(
626            content, "[]\n",
627            "compact format should produce []\\n, got: {content:?}"
628        );
629    }
630
631    // A writer that always fails on any write or flush
632    struct FailWriter;
633    impl std::io::Write for FailWriter {
634        fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
635            Err(std::io::Error::new(
636                std::io::ErrorKind::Other,
637                "write failed",
638            ))
639        }
640        fn flush(&mut self) -> std::io::Result<()> {
641            Err(std::io::Error::new(
642                std::io::ErrorKind::Other,
643                "flush failed",
644            ))
645        }
646    }
647
648    fn fail_json_writer<O: Serialize>() -> JsonItemWriter<O, FailWriter> {
649        JsonItemWriter {
650            stream: RefCell::new(BufWriter::with_capacity(0, FailWriter)),
651            use_pretty_formatter: false,
652            indent: Box::from(b"  ".as_slice()),
653            is_first_element: Cell::new(true),
654            _phantom: PhantomData,
655        }
656    }
657
658    #[test]
659    fn should_return_error_when_open_fails_on_io() {
660        let writer = fail_json_writer::<String>();
661        let result = ((&writer) as &dyn ItemWriter<String>).open();
662        assert!(result.is_err(), "open should fail when writer fails");
663    }
664
665    #[test]
666    fn should_return_error_when_close_fails_on_io() {
667        let writer = fail_json_writer::<String>();
668        let result = ((&writer) as &dyn ItemWriter<String>).close();
669        assert!(result.is_err(), "close should fail when writer fails");
670    }
671
672    #[test]
673    fn should_return_error_when_flush_fails_on_io() {
674        let writer = fail_json_writer::<String>();
675        let result = ((&writer) as &dyn ItemWriter<String>).flush();
676        assert!(result.is_err(), "flush should fail when writer fails");
677    }
678
679    #[test]
680    fn should_return_error_when_write_fails_on_io() {
681        let writer = fail_json_writer::<String>();
682        // write() serializes first (into a String), then writes to stream
683        // With capacity-0 BufWriter, write_all on the stream fails
684        let result = ((&writer) as &dyn ItemWriter<String>).write(&["hello".to_string()]);
685        assert!(
686            result.is_err(),
687            "write should fail when underlying IO fails"
688        );
689    }
690
691    #[test]
692    fn should_return_error_when_serialization_fails_with_pretty_formatter() {
693        use crate::BatchError;
694
695        struct NonSerializable;
696        impl Serialize for NonSerializable {
697            fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
698                Err(serde::ser::Error::custom(
699                    "intentional serialization failure",
700                ))
701            }
702        }
703
704        let buf = std::io::Cursor::new(Vec::new());
705        let writer = JsonItemWriterBuilder::<NonSerializable>::new()
706            .pretty_formatter(true)
707            .from_writer(buf);
708        let result = ((&writer) as &dyn ItemWriter<NonSerializable>).write(&[NonSerializable]);
709        match result.err().unwrap() {
710            BatchError::ItemWriter(msg) => assert!(
711                msg.contains("intentional"),
712                "error should contain serialization message, got: {msg}"
713            ),
714            e => panic!("expected ItemWriter error, got {e:?}"),
715        }
716    }
717}