Search code examples
springspring-bootjacksonspring-web

Automatically serialize Rest controllers to CSV instead of JSON in Spring Boot


I am looking for a quick/easy solution how to automatically serialize Rest controllers output to CSV instead of JSON. I have the simplest possible Spring boot application:

@SpringBootApplication
public class CsvExportApplication {

    public static void main(String[] args) {
        SpringApplication.run(CsvExportApplication.class, args);
    }
}

class User {
    String name;
    String surname;

    public User(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setSurname(String surname) {
        this.surname = surname;
    }

    public String getName() {
        return name;
    }

    public String getSurname() {
        return surname;
    }
}

@RestController
class UserController {
    @GetMapping(value = "/users")
    List<User> list() {
        return Arrays.asList(new User("adam", "kowalsky"), new User("john", "smith"));
    }
}

I have used jackson-dataformat-csv and came up with the following code that serializes List<User> to String, but ideally I do not want to change the rest controller code:

CsvMapper mapper = new CsvMapper();
CsvSchema schema = mapper.schemaFor(User.class).withHeader();
mapper.writerFor(List.class).with(schema).writeValueAsString(users);

Ideally I would like my controllers to be able to return output in JSON or CSV depending on the Accept header in the request.


Solution

  • I manage to achieve what I want by:

    • defining a custom converter for application/csv
    • the converter can only write CSV (it does not support read)
    • the converter uses Jackon's ObjectMapper (to make sure CSV and JSON outputs use e.g. the same format of the dates)
    • the converter builds the jackson-dateformat-csv schema on-the-fly as currently schema-less writing is not supported: https://github.com/FasterXML/jackson-dataformats-text/issues/114

    code:

    class CsvConverter<T> extends AbstractHttpMessageConverter<T> {
    
        private final ObjectMapper objectMapper;
    
        CsvConverter(ObjectMapper objectMapper) {
            super(new MediaType("application", "csv"));
            this.objectMapper = objectMapper;
        }
    
        @Override
        protected boolean supports(Class<?> clazz) {
            return true;
        }
    
        @Override
        protected T readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage)
                throws IOException, HttpMessageNotReadableException {
            return null;
        }
    
        @Override
        protected void writeInternal(T object, HttpOutputMessage outputMessage) 
               throws IOException, HttpMessageNotWritableException {
            try {
                ObjectWriter objectWriter = getCsvWriter(object);
                try (PrintWriter outputWriter = new PrintWriter(outputMessage.getBody())) {
                    outputWriter.write(objectWriter.writeValueAsString(object));
                }
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    
        ObjectWriter getCsvWriter(T object) {
            Set<String> fields = getUniqueFieldNames(object);
            CsvSchema.Builder schemaBuilder = CsvSchema.builder().setUseHeader(true);
            for (String field : fields) {
                schemaBuilder.addColumn(field);
            }
            return new CsvMapper().writerFor(List.class).with(schemaBuilder.build());
        }
    
        Set<String> getUniqueFieldNames(T object) {
            try {
                JsonNode root = objectMapper.readTree(objectMapper.writeValueAsString(object));
                Set<String> uniqueFieldNames = new LinkedHashSet<>();
                root.forEach(element -> {
                    Iterator<String> it = element.fieldNames();
                    while (it.hasNext()) {
                        String field = it.next();
                        uniqueFieldNames.add(field);
                    }
                });
                return uniqueFieldNames;
            } catch (IOException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
    
    @Configuration
    class AppConfig implements WebMvcConfigurer {
    
        private final ObjectMapper objectMapper;
    
        AppConfig(ObjectMapper objectMapper) {
            this.objectMapper = objectMapper;
        }
    
        @Override
        public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
            converters.add(new CsvConverter<>(objectMapper));
        }
    }