diff --git a/bom/pom.xml b/bom/pom.xml index eb9f84792..6137c9ec6 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -22,6 +22,8 @@ 3.1.8 7.3.0 bom + 2.5 + 2.6.4 @@ -101,6 +103,17 @@ commons-collections4 4.1 + + + org.apache.commons + commons-lang3 + 3.0 + + + commons-io + commons-io + ${commons-io.version} + org.javamoney @@ -207,6 +220,12 @@ jackson-core 2.3.3 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + 2.6.4 + net.sf.dozer @@ -274,6 +293,11 @@ oasp4j-basic ${project.version} + + io.oasp.java.modules + oasp4j-csv + ${project.version} + io.oasp.java.modules oasp4j-batch @@ -314,6 +338,35 @@ oasp4j-web ${project.version} + + + + com.fasterxml.jackson.core + jackson-core + ${jackson.core.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.core.version} + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.core.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.core.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + ${jackson.core.version} + + diff --git a/modules/csv/pom.xml b/modules/csv/pom.xml new file mode 100644 index 000000000..38b3d1040 --- /dev/null +++ b/modules/csv/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + io.oasp.java.dev + oasp4j-modules + dev-SNAPSHOT + + + io.oasp.java.modules + oasp4j-csv + ${oasp4j.version} + jar + ${project.artifactId} + Code for manipulating CSV of the Open Application Standard Platform for Java (OASP4J). + + + + ${project.groupId} + oasp4j-test + test + + + org.apache.commons + commons-lang3 + + + commons-io + commons-io + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + + + \ No newline at end of file diff --git a/modules/csv/src/main/java/io/oasp/module/basic/csv/CsvFormat.java b/modules/csv/src/main/java/io/oasp/module/basic/csv/CsvFormat.java new file mode 100644 index 000000000..b6164f5c6 --- /dev/null +++ b/modules/csv/src/main/java/io/oasp/module/basic/csv/CsvFormat.java @@ -0,0 +1,800 @@ +package io.oasp.module.basic.csv; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.io.Writer; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerationException; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.FilterProvider; +import com.fasterxml.jackson.databind.ser.PropertyFilter; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.fasterxml.jackson.dataformat.csv.CsvSchema; +import com.fasterxml.jackson.dataformat.csv.CsvSchema.Builder; + +/** + * Immutable object + * + * @author MLAVIGNE + * @see RFC-4180 + */ +public class CsvFormat { + + public static char DEFAULT_COLUMN_SEPARATOR = ','; + + // TODO : option to skip last line feed + // check this method com.fasterxml.jackson.dataformat.csv.impl.CsvEncoder.endRow() line 434 + // System.arraycopy(_cfgLineSeparator, 0, _outputBuffer, _outputTail, _cfgLineSeparatorLength); + + /** + * Valeur à utiliser dans les ETO/CTO annotés {@link JsonFilter}. + * + *
+   * @JsonFilter(CsvFormat.FILTER)
+   * public class Eto {
+   *   // ...
+   * }
+   * 
+ * + * To avoid : com.fasterxml.jackson.core.JsonGenerationException Unrecognized column '*' (when it happens for + * undesired columns). Jackson parses every columns and sort them afterward (too late). + * + * @see CsvProvider + */ + public static final String FILTER = "CsvFormat.FILTER"; + + private static final PropertyFilter DEFAULT_PROPERTY_FILTER = buildPropertyFilter(null); + + /** Filtre par défaut si le client ne demande pas de colonnes particulières en CSV */ + public static final SimpleFilterProvider SIMPLE_FILTER_PROVIDER = + new SimpleFilterProvider().addFilter(FILTER, DEFAULT_PROPERTY_FILTER); + + /** key : Colonnes jointes par hashcode */ + private static final Map filterProviders = new HashMap<>(); + + /** may be null */ + protected final List columns; + + protected final CsvMapper mapper; + + // should never be publicly accessible because of #withColumns() + protected final CsvSchema schema; + + /** may be null */ + protected final String charset; + + protected final DateFormat[] dateFormats; + + @Deprecated // delete when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + private boolean endingLineSeparator = true; + + /** + * The constructor. + */ + public CsvFormat() { + this(null, buildMapper(null, null), buildSchema(null), null, null, true); + } + + /** + * The constructor. columns must be exactly the same through mapper.filter and schema + */ + // uncomment when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + // protected CsvFormat(final List columns, final CsvMapper mapper, final CsvSchema schema, final String + // charset, + // final DateFormat dateFormat) { + // Objects.requireNonNull(mapper); + // Objects.requireNonNull(schema); + // this.columns = columns; + // this.mapper = mapper; + // this.schema = schema; + // this.charset = charset; + // this.dateFormat = dateFormat; + // } + + /** + * The constructor. columns must be exactly the same through mapper.filter and schema + * + * @deprecated delete when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + */ + @Deprecated // delete when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + protected CsvFormat(final List columns, final CsvMapper mapper, final CsvSchema schema, final String charset, + final DateFormat[] dateFormats, final boolean endingLineSeparator) { + Objects.requireNonNull(mapper); + Objects.requireNonNull(schema); + this.columns = columns; + this.mapper = mapper; + this.schema = schema; + this.charset = charset; + this.dateFormats = dateFormats; + this.endingLineSeparator = endingLineSeparator; + } + + /** + * The constructor. + * + * @param csvFormat + * @param withNullValue + */ + // uncomment when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + // public CsvFormat(CsvFormat base, CsvSchema newSchema) { + // this(base.columns, base.mapper, newSchema, base.charset, base.dateFormat); + // } + + /** + * The constructor. + * + * @param csvFormat + * @param schema2 + * @param b + */ + @Deprecated // delete when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + public CsvFormat(CsvFormat base, CsvSchema newSchema, final boolean endingLineSeparator) { + this(base.columns, base.mapper, newSchema, base.charset, base.dateFormats, endingLineSeparator); + } + + /** + * @param columns + * @return + */ + public static CsvMapper buildMapper(final List columns, final DateFormat[] dateFormats) { + + final CsvMapper mapper = new CsvMapper(); + + // To avoid : com.fasterxml.jackson.core.JsonGenerationException: + // "Unrecognized column" or "CSV generator does not support Object values for properties" + // Solution #1 : use JsonGenerator.Feature.IGNORE_UNKNOWN + // Solution #2 : use FilterProvider + if (columns != null) { + mapper.setFilterProvider(getFilterProvider(columns)); + } + // mapper.configure(JsonGenerator.Feature.IGNORE_UNKNOWN, true); + + if (dateFormats != null && dateFormats.length > 0) { + + // Multiple formats ? + if (dateFormats.length > 1) { + SimpleModule module = new SimpleModule(); + module.addDeserializer(Date.class, new MultiDateDeserializer(dateFormats)); // TODO : withDateFormats + mapper.registerModule(module); + } + + // We also set default date format which is required for writing + mapper.setDateFormat(dateFormats[0]); + } + + // mapper.configure(Feature.STRICT_CHECK_FOR_QUOTING, true); + + return mapper; + } + + /** + * Handle multiple DateFormat + * + * @author MLAVIGNE + */ + public static final class MultiDateDeserializer extends StdDeserializer { + + private static final long serialVersionUID = 837636597643725045L; + + private DateFormat[] dateFormats; + + /** + * The constructor. + */ + public MultiDateDeserializer(DateFormat[] dateFormats) { + super(Date.class); + this.dateFormats = dateFormats; + } + + @Override + public Date deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException { + + final String date = jp.getText(); + for (DateFormat dateFormat : this.dateFormats) { + try { + return dateFormat.parse(date); + } catch (ParseException e) { + // TODO : check associated regex instead of throwing/catching Exception + } + } + throw new JsonParseException(jp, + String.format("Unparseable date: \"%s\". Supported formats: %s", date, Arrays.toString(this.dateFormats))); + } + } + + /** + * @param columns if null create an {@link CsvSchema#emptySchema() emptySchema} with header + * @return + */ + public static CsvSchema buildSchema(final List columns) { + + // TODO : test with accented columns + + final CsvSchema schema; + if (columns != null && !columns.isEmpty()) { + // Schéma CSV (ordre des 1ères colonnes + reste des colonnes en fonction de l'ETO sauf colonnes annotées + // JsonIgnore) + final Builder builder = CsvSchema.builder(); + for (final String column : columns) { + if (column != null) { + builder.addColumn(column); // TODO : use CsvSchema.ColumnType type if possible + } + } + schema = builder.build(); + } else { + schema = CsvSchema.emptySchema(); + } + + return schema; + } + + /** + * @param header + * @return + */ + public static List getColumns(String header, char columnSeparator) { + + return StringUtils.isNotBlank(header) ? Arrays.asList(header.split("" + columnSeparator)) : null; + } + + /** + * @return columns + */ + public List getColumns() { + + return this.columns; + } + + /** + * @return + */ + public String getCharset() { + + return this.charset; + } + + /** + * Filtrage en amont des colonnes pour Jackson. C'est pour cette raison que l'annotation @@Filter + * + * @param columns, if null return default filter + * + * @see "http://www.baeldung.com/jackson-serialize-field-custom-criteria" + */ + protected static FilterProvider getFilterProvider(final List columns) { + + final int cacheKey = columns.hashCode(); + if (!filterProviders.containsKey(cacheKey)) { + final PropertyFilter theFilter = buildPropertyFilter(columns); + filterProviders.put(cacheKey, new SimpleFilterProvider().addFilter(FILTER, theFilter)); + } + + return filterProviders.get(cacheKey); + } + + /** + * @param columns + * @return + */ + protected static SimpleBeanPropertyFilter buildPropertyFilter(final List columns) { + + return new SimpleBeanPropertyFilter() { + @Override + public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) + throws Exception { + + if (include(writer)) { + if (columns == null || columns.contains(writer.getName())) { + writer.serializeAsField(pojo, jgen, provider); + } + } else if (!jgen.canOmitFields()) { // since 2.3 + writer.serializeAsOmittedField(pojo, jgen, provider); + } + } + }; + } + + /** + * Must be the name of the {@link JsonProperty} (if field/getter is annotated). Do not use when you want to read CSV + * and automatically get columns from the first line + * + * @param columns + * @return + * + * @see #withoutColumns() + */ + public CsvFormat withColumns(final List columns) { + + final CsvMapper mapper = buildMapper(columns, this.dateFormats); + CsvSchema schema = buildSchema(columns) // TODO : use copy constructor + .withColumnSeparator(this.schema.getColumnSeparator()) + .withLineSeparator(new String(this.schema.getLineSeparator())).withQuoteChar((char) this.schema.getQuoteChar()) + .withNullValue(this.schema.getNullValueString()); + if (this.schema.usesHeader()) { + schema = schema.withHeader(); + } else { + schema = schema.withoutHeader(); + } + /* + * FIXME uncomment when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 if + * (this.schema.getEndingLineSeparator()) { schema = schema.withEndingLineSeparator(); } else { schema = + * schema.withoutEndingLineSeparator(); } + */ + // FIXME keep header + return new CsvFormat(columns, mapper, schema, this.charset, this.dateFormats, this.endingLineSeparator); + } + + /** + * Useful when you want to read CSV and automatically get columns from the first line (it must be the header). The + * returned format can not be used for writing until you call {@link #withColumns(List)} + * + * @return the format to read CSV and detect columns from the header (first line) + */ + public CsvFormat withoutColumns() { + + return withColumns((List) null).withHeader(); + } + + public CsvFormat withColumns(final String header) { + + return withColumns(getColumns(header, this.schema.getColumnSeparator())); + } + + /** + * @param empty + * @return + */ + public CsvFormat withNullValue(String nvl) { + + return new CsvFormat(this, this.schema.withNullValue(nvl), this.endingLineSeparator); + } + + public CsvFormat withHeader() { + + return new CsvFormat(this, this.schema.withHeader(), this.endingLineSeparator); + } + + /** + * Shortcut for {@link #withHeader()} and {@link #withColumns(String)} + * + * @param header + * @return + */ + public CsvFormat withHeader(final String header) { + + return withHeader().withColumns(header); + } + + public CsvFormat withoutHeader() { + + return new CsvFormat(this, this.schema.withoutHeader(), this.endingLineSeparator); + } + + public CsvFormat withColumnSeparator(final char sep) { + + return new CsvFormat(this, this.schema.withColumnSeparator(sep), this.endingLineSeparator); + } + + public char getColumnSeparator() { + + return this.schema.getColumnSeparator(); + } + + public CsvFormat withLineSeparator(final String sep) { + + return new CsvFormat(this, this.schema.withLineSeparator(sep), this.endingLineSeparator); + } + + public char[] getLineSeparator() { + + return this.schema.getLineSeparator(); + } + + public String getLineSeparatorString() { + + return new String(this.schema.getLineSeparator()); + } + + public CsvFormat withEndingLineSeparator() { + + // FIXME uncomment when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + // return new CsvFormat(this, this.schema.withEndingLineSeparator()); + return new CsvFormat(this, this.schema, true); + } + + public CsvFormat withoutEndingLineSeparator() { + + // FIXME uncomment when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + // return new CsvFormat(this, this.schema.withoutEndingLineSeparator()); + return new CsvFormat(this, this.schema, false); + } + + public CsvFormat withQuoteChar(final char c) { + + return new CsvFormat(this, this.schema.withQuoteChar(c), this.endingLineSeparator); + } + + public CsvFormat withoutQuoteChar() { + + return new CsvFormat(this, this.schema.withoutQuoteChar(), this.endingLineSeparator); + } + + public CsvFormat withCharset(final String charset) { + + return new CsvFormat(this.columns, this.mapper, this.schema, charset, this.dateFormats, this.endingLineSeparator); + } + + public CsvFormat withEncoding(final String encoding) { + + return withCharset(encoding); + } + + /** + * @param dateFormat example : {@link SimpleDateFormat} + * @return this + */ + public CsvFormat withDateFormat(DateFormat dateFormat) { + + return withDateFormats(dateFormat); + } + + /** + * @param patterns {@link SimpleDateFormat} pattern + * @return this + */ + public CsvFormat withDateFormat(String patterns) { + + return withDateFormats(patterns); + } + + /** + * Jackson does not allow multiple DateFormats. This method will use a MultiDateDeserializer to allow them. + * + * @param patterns {@link SimpleDateFormat} patterns + * @return this CsvFormat instance + */ + public CsvFormat withDateFormats(String... patterns) { + + final DateFormat[] dateFormats = new DateFormat[patterns.length]; + for (int i = 0; i < patterns.length; i++) { + dateFormats[i] = new SimpleDateFormat(patterns[i]); + } + return withDateFormats(dateFormats); + } + + /** + * Jackson does not allow multiple DateFormats. This method will use a MultiDateDeserializer to allow them. + * + * @param dateFormats example : {@link SimpleDateFormat} + * @return this CsvFormat instance + */ + public CsvFormat withDateFormats(DateFormat... dateFormats) { + + return new CsvFormat(this.columns, buildMapper(this.columns, dateFormats), this.schema, this.charset, dateFormats, + this.endingLineSeparator); + } + + /** + * @param simpleDateFormat + * @return + */ + public CsvFormat withoutDateFormat() { + + return new CsvFormat(this.columns, buildMapper(this.columns, null), this.schema, this.charset, null, + this.endingLineSeparator); + } + + /** + * @param + * @param type + * @return + */ + public ObjectReader readerFor(Class type) { // TODO save readers in CsvFormat fields (cache) + + return this.mapper.readerFor(type).with(this.schema); + } + + /** + * @param src + * @param type + * @return + * @throws IOException + * @throws JsonProcessingException + */ + public E readValue(InputStream src, Class type) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(type).readValue(encoded); + } + + /** + * @param src + * @param class1 + * @param class2 + * @return + * @throws IOException + * @throws IllegalAccessException + * @throws InstantiationException + * + * @see {@link #readAllValues(String, Class)} + */ + public MappingIterator readValues(InputStream src, Class itemsType) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(itemsType).readValues(encoded); + } + + /** + * @param out + * @return + * @throws UnsupportedEncodingException + */ + public Reader getEncodedReader(InputStream out) throws UnsupportedEncodingException { + + return this.charset != null ? new InputStreamReader(out, this.charset) : new InputStreamReader(out); + } + + /** + * @param src + * @param type + * @return + * @throws IOException + * @throws JsonProcessingException + */ + public E readValue(File src, Class type) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(type).readValue(encoded); + } + + /** + * @param src + * @param class1 + * @param class2 + * @return + * @throws IOException + * @throws IllegalAccessException + * @throws InstantiationException + * + * @see {@link #readAllValues(String, Class)} + */ + public MappingIterator readValues(File src, Class itemsType) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(itemsType).readValues(encoded); + } + + /** + * @param out + * @return + * @throws FileNotFoundException + * @throws UnsupportedEncodingException + */ + public Reader getEncodedReader(final File src) throws IOException { + + return getEncodedReader(new FileInputStream(src)); + } + + /** + * @param src + * @param type + * @return + * @throws IOException + * @throws JsonProcessingException + */ + public E readValue(String src, Class type) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(type).readValue(encoded); + } + + /** + * @param src + * @param class1 + * @param class2 + * @return + * @throws IOException + * @throws IllegalAccessException + * @throws InstantiationException + * + * @see {@link #readAllValues(String, Class)} + */ + public MappingIterator readValues(String src, Class itemsType) throws IOException { + + final Reader encoded = getEncodedReader(src); + return readerFor(itemsType).readValues(encoded); + } + + /** + * @param out + * @return + * @throws FileNotFoundException + * @throws UnsupportedEncodingException + */ + public Reader getEncodedReader(final String src) throws UnsupportedEncodingException, FileNotFoundException { + + return getEncodedReader(new ByteArrayInputStream(src.getBytes())); + } + + /** + * Don't forget to annotate your ETO with @JsonFilter(CsvFormat.FILTER) to avoid + * "JsonGenerationException: Unrecognized column" exception. + * + * @param out + * @param type + * @return + * @return + */ + public ObjectWriter writerFor(E value) { // TODO save writers in CsvFormat fields (cache) + + // FIXME : handle null value + return this.mapper.writerFor(value.getClass()).with(this.schema); + } + + /** + * Shortcut for : + * + *
+   * this.writerFor(value).writeValue(this.getWriterForCharset(out), value)
+   * 
+ * + * Don't forget to annotate your ETO with @JsonFilter(CsvFormat.FILTER) to avoid + * "JsonGenerationException: Unrecognized column" exception. + * + * @param out + * @param value + * @return + * @throws IOException + * @throws JsonMappingException + * @throws JsonGenerationException + */ + public void writeValue(OutputStream out, E value) throws IOException { + + if (this.schema.getColumnDesc().equals("]")) { + throw new JsonGenerationException("No columns to write"); + } + + // FIXME delete if/then when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + if (!this.endingLineSeparator) { + final String data = writeValueAsString(value); + IOUtils.write(data, out, this.charset); + } else { + final Writer encoded = getEncodedWriter(out); + this.writerFor(value).writeValue(encoded, value); + } + } + + /** + * @param out + * @return + * @throws UnsupportedEncodingException + */ + public Writer getEncodedWriter(OutputStream out) throws UnsupportedEncodingException { + + return this.charset != null ? new OutputStreamWriter(out, this.charset) : new OutputStreamWriter(out); + } + + /** + * Shortcut for : + * + *
+   * this.writerFor(value).writeValue(resultFile, value)
+   * 
+ * + * Don't forget to annotate your ETO with @JsonFilter(CsvFormat.FILTER) to avoid + * "JsonGenerationException: Unrecognized column" exception. + * + * @param value + * @return + * @throws IOException + * @throws JsonMappingException + * @throws JsonGenerationException + */ + public void writeValue(final File resultFile, E value) throws IOException { + + if (this.schema.getColumnDesc().equals("]")) { + throw new JsonGenerationException("No columns to write"); + } + if (resultFile.getParentFile() != null) { + resultFile.getParentFile().mkdirs(); + } + // FIXME delete if/then when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + if (!this.endingLineSeparator) { + final String data = writeValueAsString(value); + FileUtils.writeStringToFile(resultFile, data, this.charset); // TODO may be replaced by java.nio.Files + } else { + final Writer encoded = getEncodedWriter(resultFile); + this.writerFor(value).writeValue(encoded, value); + } + } + + /** + * @param resultFile + * @return + * @throws FileNotFoundException + * @throws UnsupportedEncodingException + */ + public Writer getEncodedWriter(final File resultFile) throws IOException { + + return getEncodedWriter(new FileOutputStream(resultFile)); + } + + /** + * Shortcut for : + * + *
+   * this.writerFor(value).writeValueAsString(value)
+   * 
+ * + * Don't forget to annotate your ETO with @JsonFilter(CsvFormat.FILTER) to avoid + * "JsonGenerationException: Unrecognized column" exception. + * + * @param value + * @return + * @throws IOException + * @throws JsonMappingException + * @throws JsonGenerationException + */ + public String writeValueAsString(E value) throws IOException { + + if (this.schema.getColumnDesc().equals("]")) { + throw new JsonGenerationException("No columns to write"); + } + + // FIXME delete var when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + String str = this.writerFor(value).writeValueAsString(value); + + // FIXME delete when Pull Request is merged : https://github.com/FasterXML/jackson-dataformats-text/pull/46 + if (!this.endingLineSeparator) { + if (str.endsWith(getLineSeparatorString())) { + str = str.substring(0, str.length() - this.schema.getLineSeparator().length); + } + } + + // if (this.charset != null) { + // final Charset charset = Charset.forName(this.charset); + // return charset.encode(str).toString(); // FIXME : we only want to encode string to the target charset => OK ? + // } else { + return str; + // } + } + +} \ No newline at end of file diff --git a/modules/csv/src/test/java/io/oasp/module/basic/csv/CsvFormatTest.java b/modules/csv/src/test/java/io/oasp/module/basic/csv/CsvFormatTest.java new file mode 100644 index 000000000..a824bdd70 --- /dev/null +++ b/modules/csv/src/test/java/io/oasp/module/basic/csv/CsvFormatTest.java @@ -0,0 +1,719 @@ +package io.oasp.module.basic.csv; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import org.apache.commons.io.FileUtils; +import org.assertj.core.api.AbstractCharSequenceAssert; +import org.junit.Ignore; +import org.junit.Test; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerationException; +import com.fasterxml.jackson.databind.JsonMappingException; + +import io.oasp.module.test.common.base.ModuleTest; + +/** + * @author MLAVIGNE + * + */ +@SuppressWarnings("javadoc") +public class CsvFormatTest extends ModuleTest { + + private CsvFormat base = new CsvFormat(); + + private CsvFormat twoColumns = this.base.withColumns("code,comment").withNullValue(""); + + private static final String defaultLineSeparator = "\n"; + + private static final String windowsLineSeparator = "\r\n"; + + @JsonFilter(CsvFormat.FILTER) + public static class Eto { + private Long id; + + private String code; + + private String comment; + + private String other; + + public Date date; + + public Long getId() { + + return this.id; + } + + public void setId(Long id) { + + this.id = id; + } + + public String getCode() { + + return this.code; + } + + public void setCode(String code) { + + this.code = code; + } + + public String getComment() { + + return this.comment; + } + + public void setComment(String comment) { + + this.comment = comment; + } + + public String getOther() { + + return this.other; + } + + public void setOther(String other) { + + this.other = other; + } + + public Date getDate() { + + return this.date; + } + + public void setDate(Date date) { + + this.date = date; + } + + } + + @JsonInclude(JsonInclude.Include.NON_NULL) // on ne renvoie pas de propriété null (NON_EMPTY => suppr "", 0, 0L) + public static class EtoSkipNull extends Eto { + + } + + @JsonFilter(CsvFormat.FILTER) + public static class Cto { + private String title; + + public Date date; + + private Eto eto1; + + private Eto eto2; + + public String getTitle() { + + return this.title; + } + + public void setTitle(String title) { + + this.title = title; + } + + public Eto getEto1() { + + return this.eto1; + } + + public void setEto1(Eto eto1) { + + this.eto1 = eto1; + } + + public Eto getEto2() { + + return this.eto2; + } + + public void setEto2(Eto eto2) { + + this.eto2 = eto2; + } + + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumns(java.lang.String)}. + */ + @Test(expected = JsonGenerationException.class) + public void testImmutableFormatNoColumnsException() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + this.base.withColumns("code,comment"); // a new format is returned and base is unchanged (like my heart) + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + this.base.writeValue(out, eto); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumns(java.lang.String)}. + */ + @Test + public void testWithColumnsString() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumns("code,comment"); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumns(java.lang.String)}. + */ + @Test + public void testWithANullColumn() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumns(Arrays.asList("code", null, "comment")); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumns(java.lang.String)}. + */ + @Test(expected = JsonGenerationException.class) + public void testWithNullColumns() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumns((List) null); + + final StringBuilder sb = new StringBuilder(); + sb.append("").append(defaultLineSeparator); + format.writeValueAsString(eto); + } + + /** + * @param eto + * @param format + * @return + * @throws JsonGenerationException + * @throws JsonMappingException + * @throws IOException + */ + private AbstractCharSequenceAssert assertThatWrittenContent(final Eto eto, final CsvFormat format) + throws Exception { + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + format.writeValue(out, eto); + final AbstractCharSequenceAssert assertThatWrittenContent = assertThat(out.toString()); + return assertThatWrittenContent; + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withNullValue(java.lang.String)}. + */ + @Test + public void testWithNullValue() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumns("code,comment").withNullValue("NULL"); + + final StringBuilder sb = new StringBuilder(); + sb.append("NULL,comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#readValue(java.io.InputStream, java.lang.Class)}. + */ + @Test + public void testWithNullValueSkipEto() throws Exception { + + final StringBuilder sb = new StringBuilder(); + sb.append(",23/10/2017").append(defaultLineSeparator); + + final CsvFormat format = + this.twoColumns.withNullValue("").withColumns("code,date").withDateFormat(new SimpleDateFormat("dd/MM/yyyy")); + + final ByteArrayInputStream src = new ByteArrayInputStream(sb.toString().getBytes("UTF-8")); + final EtoSkipNull eto = format.readValue(src, EtoSkipNull.class); + + assertThat(eto).isNotNull(); + assertThat(eto.getCode()).isNull(); + assertThat(eto.getComment()).isNull(); + assertThat(eto.getDate()).isEqualTo("2017-10-23"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withHeader(java.lang.String)}. + */ + @Test + public void testWithHeaderString() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withHeader("code,comment"); + + final StringBuilder sb = new StringBuilder(); + sb.append("code,comment").append(defaultLineSeparator); + sb.append(",comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + + final CsvFormat format2 = format.withoutHeader(); + + final StringBuilder sb2 = new StringBuilder(); + sb2.append(",comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format2).isEqualTo(sb2.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumnSeparator(char)}. + */ + @Test + public void testWithColumnSeparator() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumnSeparator(';').withHeader("code;comment"); + + final StringBuilder sb = new StringBuilder(); + sb.append("code;comment").append(defaultLineSeparator); + sb.append(";comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withLineSeparator(java.lang.String)}. + */ + @Test + public void testWithLineSeparator() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.twoColumns.withLineSeparator("\r\n"); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value").append(windowsLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withEndingLineSeparator()}. + */ + @Test + public void testWithEndingLineSeparator() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.twoColumns.withLineSeparator("\r\n").withoutEndingLineSeparator(); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value"); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withEndingLineSeparator()}. + */ + @Test + public void testWithEndingLineSeparator4Coverage() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.twoColumns.withoutEndingLineSeparator().withColumns("code,comment"); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value"); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + + final CsvFormat format2 = + this.twoColumns.withoutEndingLineSeparator().withColumns("code,comment").withEndingLineSeparator(); + + final StringBuilder sb2 = new StringBuilder(); + sb2.append(",comment_value").append(defaultLineSeparator); + assertThatWrittenContent(eto, format2).isEqualTo(sb2.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withQuoteChar(char)}. + */ + @Test + public void testWithQuoteChar() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value1"); + + final CsvFormat format = this.twoColumns.withQuoteChar('Q'); + + final StringBuilder sb = new StringBuilder(); + sb.append(",Qcomment_value1Q").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withQuoteChar(char)}. + */ + @Test + public void testWithoutQuoteChar() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value1"); + + final CsvFormat format = this.twoColumns.withoutQuoteChar(); + + final StringBuilder sb = new StringBuilder(); + sb.append(",comment_value1").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withCharset(java.lang.String)}. + */ + @Test + @Ignore // TODO + public void testWithCharset() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("連"); + + final CsvFormat format = this.twoColumns.withCharset("ISO-8859-15"); + + final StringBuilder sb = new StringBuilder(); + fail("Not yet implemented"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withDateFormat(java.text.DateFormat)}. + */ + @Test + public void testWithDateFormatWrite() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value1"); + final Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(2017, 9, 23); + eto.setDate(cal.getTime()); + + final CsvFormat format = + this.twoColumns.withColumns("code,date").withDateFormat(new SimpleDateFormat("dd/MM/yyyy")); + + final StringBuilder sb = new StringBuilder(); + sb.append(",23/10/2017").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withDateFormat(java.text.DateFormat)}. + */ + @Test + public void testWithDateFormat() throws Exception { + + final CsvFormat format = this.twoColumns.withColumns("code,date").withDateFormats("dd/MM/yyyy"); + + assertThat(format.readValue("CODE1,22/05/1987", Eto.class).getDate()).isEqualTo("1987-05-22"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withDateFormat(java.text.DateFormat)}. + */ + @Test + public void testWithDateFormats() throws Exception { + + final CsvFormat format = this.twoColumns.withColumns("code,date").withDateFormats("dd/MM/yyyy", "dd-MM-yyyy"); + + assertThat(format.readValue("CODE1,22/05/1987", Eto.class).getDate()).isEqualTo("1987-05-22"); + assertThat(format.readValue("CODE1,22-05-1987", Eto.class).getDate()).isEqualTo("1987-05-22"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withDateFormat(java.text.DateFormat)}. + */ + @Test(expected = JsonMappingException.class) + public void testWithDateFormatsKO() throws Exception { + + final CsvFormat format = this.twoColumns.withColumns("code,date").withDateFormats("dd/MM/yyyy", "dd-MM-yyyy"); + + assertThat(format.readValue("CODE1,22/05/1987", Eto.class).getDate()).isEqualTo("1987-05-22"); + assertThat(format.readValue("CODE1,22-05-1987", Eto.class).getDate()).isEqualTo("1987-05-22"); + format.readValue("CODE1,22|05|1987", Eto.class); // unknown format + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withDateFormat(java.text.DateFormat)}. + */ + @Test + public void testWithDateFormatsWrite() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value1"); + final Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(2017, 9, 23); + eto.setDate(cal.getTime()); + + final CsvFormat format = + this.twoColumns.withColumns("code,date").withDateFormats("dd/MM/yyyy", "dd-MM-yyyy"); + + final StringBuilder sb = new StringBuilder(); + sb.append(",23/10/2017").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withoutDateFormat()}. + */ + @Test + public void testWithoutDateFormat() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value1"); + final Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(2017, 9, 23); + eto.setDate(cal.getTime()); + + final CsvFormat format = + this.twoColumns.withColumns("code,date").withDateFormat(new SimpleDateFormat("dd/MM/yyyy")).withoutDateFormat(); + + final StringBuilder sb = new StringBuilder(); + sb.append(",1508709600000").append(defaultLineSeparator); + assertThatWrittenContent(eto, format).isEqualTo(sb.toString()); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#readValue(java.io.InputStream, java.lang.Class)}. + */ + @Test + public void testReadValue() throws Exception { + + final StringBuilder sb = new StringBuilder(); + sb.append(",23/10/2017").append(defaultLineSeparator); + + final CsvFormat format = + this.twoColumns.withColumns("code,date").withDateFormat(new SimpleDateFormat("dd/MM/yyyy")); + + final ByteArrayInputStream src = new ByteArrayInputStream(sb.toString().getBytes("UTF-8")); + final Eto eto = format.readValue(src, Eto.class); + + assertThat(eto).isNotNull(); + assertThat(eto.getDate()).isEqualTo("2017-10-23"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#readValue(java.io.InputStream, java.lang.Class)}. + */ + @Test + public void testReadValues() throws Exception { + + final StringBuilder sb = new StringBuilder(); + sb.append(",23/10/2017").append(defaultLineSeparator); + sb.append("code2,24/10/2017").append(defaultLineSeparator); + + final CsvFormat format = + this.twoColumns.withColumns("code,date").withDateFormat(new SimpleDateFormat("dd/MM/yyyy")); + + final List etos = format.readValues(sb.toString(), Eto.class).readAll(); + + assertThat(etos).isNotNull().isNotEmpty(); + assertThat(etos.get(0).getDate()).isEqualTo("2017-10-23"); + assertThat(etos.get(1).getCode()).isEqualTo("code2"); + assertThat(etos.get(1).getDate()).isEqualTo("2017-10-24"); + } + + /** + * Test method for + * {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#withColumns(java.lang.String)}. + */ + @Test + public void testGetColumns() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("comment_value"); + + final CsvFormat format = this.base.withColumns("code,comment"); + assertThat(format.getColumns()).containsOnly("code", "comment"); + assertThat(format.getColumns().get(0)).isEqualTo("code"); + assertThat(format.getColumns().get(1)).isEqualTo("comment"); + + final CsvFormat format2 = format.withColumns((String) null); + assertThat(format2.getColumns()).isNull(); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + */ + @Test + public void testWriteValueAsString() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final CsvFormat format = + this.base.withoutEndingLineSeparator().withColumns("code,comment")/* .withCharset("UTF-8") */; + + assertThat(format.writeValueAsString(eto)).isEqualTo(",héh€h£"); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + */ + @Test + public void testWriteValueFile() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final CsvFormat format = this.base.withoutEndingLineSeparator().withColumns("code,comment").withCharset("UTF-8"); + + final File resultFile = File.createTempFile("CsvFormatTest", ".csv"); + + try { + format.writeValue(resultFile, eto); + assertThat(resultFile).exists(); + + final String csv = FileUtils.readFileToString(resultFile, format.getCharset()); + assertThat(csv).isEqualTo(",héh€h£"); + } finally { + resultFile.deleteOnExit(); + resultFile.delete(); + } + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + * + * @see #testWriteValueFile() + */ + @Test + public void testReadValueFile() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final CsvFormat format = + this.base.withNullValue("").withoutEndingLineSeparator().withColumns("code,comment").withCharset("UTF-8"); + + final File resultFile = File.createTempFile("CsvFormatTest", ".csv"); + + try { + format.writeValue(resultFile, eto); + final Eto readEto = format.readValue(resultFile, Eto.class); + assertThat(readEto).isEqualToComparingFieldByField(eto); + } finally { + resultFile.deleteOnExit(); + resultFile.delete(); + } + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + * + * @see #testWriteValueFile() + */ + @Test + public void testReadNullValue() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final CsvFormat format = + this.base.withNullValue("NULL").withoutEndingLineSeparator().withHeader("code,comment").withCharset("UTF-8"); + + final Eto readEto = format.readValue("code,comment" + defaultLineSeparator + "NULL,\"héh€h£\"", Eto.class); + assertThat(readEto).isEqualToComparingFieldByField(eto); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + * + * @see #testWriteValueFile() + */ + @Test + public void testReadEmptyValue() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final CsvFormat format = + this.base.withNullValue("").withoutEndingLineSeparator().withHeader("code,comment").withCharset("UTF-8"); + + final Eto readEto = format.readValue("code,comment" + defaultLineSeparator + ",\"héh€h£\"", Eto.class); + assertThat(readEto).isEqualToComparingFieldByField(eto); + } + + /** + * Test method for {@link com.orange.grace.traducteur.general.service.impl.rest.CsvFormat#writeValueAsString(Object)}. + * + * @see #testWriteValueFile() + */ + @Test + public void testReadValueFileTwoTimes() throws Exception { + + final Eto eto = new Eto(); + eto.setComment("héh€h£"); + + final Eto eto2 = new Eto(); + eto2.setComment("héyh€yh£y"); + + final CsvFormat format = + this.base.withNullValue("").withoutEndingLineSeparator().withColumns("code,comment").withCharset("UTF-8"); + + final File resultFile = File.createTempFile("CsvFormatTest", ".csv"); + + try { + format.writeValue(resultFile, eto); + final Eto readEto = format.readValue(resultFile, Eto.class); + assertThat(readEto).isEqualToComparingFieldByField(eto); + + format.writeValue(resultFile, eto2); + final Eto readEto2 = format.readValue(resultFile, Eto.class); + assertThat(readEto2).isEqualToComparingFieldByField(eto2); + } finally { + resultFile.deleteOnExit(); + resultFile.delete(); + } + } + +} diff --git a/modules/pom.xml b/modules/pom.xml index a588241ca..72e5c81b9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -24,6 +24,7 @@ batch web basic + csv diff --git a/samples/core/pom.xml b/samples/core/pom.xml index 93a70bee1..45af6d899 100644 --- a/samples/core/pom.xml +++ b/samples/core/pom.xml @@ -49,6 +49,11 @@ oasp4j-basic + + io.oasp.java.modules + oasp4j-csv + + io.oasp.java.modules oasp4j-jpa-envers @@ -174,6 +179,11 @@ com.fasterxml.jackson.jaxrs jackson-jaxrs-json-provider + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + diff --git a/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/NlsBundleApplicationRoot.java b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/NlsBundleApplicationRoot.java index a149a0af0..1b25167d5 100644 --- a/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/NlsBundleApplicationRoot.java +++ b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/NlsBundleApplicationRoot.java @@ -74,4 +74,10 @@ NlsMessage errorChangeTableIllegalStateCombination(@Named("orderId") Long orderI @Named("tableNumber") Long tableNumber); // END ARCHETYPE SKIP + /** + * @return + */ + @NlsBundleMessage("The value {value} is not allowed for the header {name}") + NlsMessage errorIllegalHeaderValue(@Named("name") String name, @Named("value") Object value); + } diff --git a/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/exception/IllegalHeaderValueException.java b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/exception/IllegalHeaderValueException.java new file mode 100644 index 000000000..1516ea398 --- /dev/null +++ b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/common/api/exception/IllegalHeaderValueException.java @@ -0,0 +1,25 @@ +package io.oasp.gastronomy.restaurant.general.common.api.exception; + +import io.oasp.gastronomy.restaurant.general.common.api.NlsBundleApplicationRoot; + +/** + * Thrown when an operation is requested that requires a user to be logged in, but no such user exists. + * + */ +public class IllegalHeaderValueException extends ApplicationBusinessException { + + /** UID for serialization. */ + private static final long serialVersionUID = 1L; + + /** + * The constructor. + * + * @param name header name + * @param value header value + */ + public IllegalHeaderValueException(final String name, final String value) { + + super(null, createBundle(NlsBundleApplicationRoot.class).errorIllegalHeaderValue(name, value)); + } + +} diff --git a/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProvider.java b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProvider.java new file mode 100644 index 000000000..7c6bad302 --- /dev/null +++ b/samples/core/src/main/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProvider.java @@ -0,0 +1,436 @@ +package io.oasp.gastronomy.restaurant.general.service.impl.rest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.text.SimpleDateFormat; +import java.util.List; + +import javax.inject.Named; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.Produces; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.ext.MessageBodyReader; +import javax.ws.rs.ext.MessageBodyWriter; +import javax.ws.rs.ext.Provider; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HeaderElement; +import org.apache.http.NameValuePair; +import org.apache.http.message.BasicHeaderElement; +import org.apache.http.message.BasicHeaderValueParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.oasp.gastronomy.restaurant.general.common.api.exception.IllegalHeaderValueException; +import io.oasp.module.basic.csv.CsvFormat; +import io.oasp.module.jpa.common.api.to.PaginatedListTo; + +/** + * Cette classe est responsable de la conversion entre Eto et ligne CSV. Elle est implicite dans la couche service. Pour + * obtenir un retour CSV de l'API le client doit fournir le header HTTP + * Accept-Header. Pour envoyer du CSV le client doit fournir le header + * Content-Header indiquant le nom des colonnes (le nom des attributs de l'ETO ou des colonnes CSV + * si les getters de l'ETO sont annotés par {@link JsonProperty @JsonProperty}) envoyées dans le body séparé par "," ou + * ";". + * + *

+ * Diagramme expliquant la requête et la réponse entre le client et le serveur : + *

+ * + * + *
+ *         CLIENT                                                                       SERVEUR
+ * +-------------------+                                                         +-------------------+
+ * |                   |Content-Type: text/csv                                   |                   |
+ * | val1;val2         |Content-Header: col1, col2                              | val1;val3         |
+ * |                   |                                                         |                   |
+ * |                   +-------------------------------------------------------> |                   |
+ * |                   |                                                         |                   |
+ * |                   |                                                         |                   |
+ * |                   |Accept: text/csv               Content-Type: text/csv    |                   |
+ * |                   |Accept-Header: col1, col3    Content-Header: col1, col3|                   |
+ * |                   |X-Date-Format: dd/MM/yyyy                                |                   |
+ * |                   |                                                         |                   |
+ * |                   | <-------------------------------------------------------+                   |
+ * +-------------------+                                                         +-------------------+
+ * 
+ * + *

+ * Si le CSV contient déjà un en-tête avec la liste des colonnes CSV le header Content-Header n'est pas obligatoire + *

+ * + * + *
+ *         CLIENT                                                                       SERVEUR
+ * +-------------------+                                                         +-------------------+
+ * |                   |Content-Type: text/csv                                   |                   |
+ * | col1;col2         |                                                         | val1;val3         |
+ * | val1;val2         |                                                         |                   |
+ * |                   +-------------------------------------------------------> |                   |
+ * |                   |                                                         |                   |
+ * |                   |                                                         |                   |
+ * |                   |Accept: text/csv               Content-Type: text/csv    |                   |
+ * |                   |Accept-Header: col1, col3    Content-Header: col1, col3|                   |
+ * |                   |X-Date-Format: dd/MM/yyyy                                |                   |
+ * |                   |                                                         |                   |
+ * |                   | <-------------------------------------------------------+                   |
+ * +-------------------+                                                         +-------------------+
+ * 
+ * + *

+ * Description des headers envoyés par le client : + *

+ * + *
    + *
  • Content-Type: text/csv : informe l'API qu'on lui envoie du CSV dans le body de la requête
  • + *
  • Content-Header : listes des colonnes CSV envoyées dans le body dans cet ordre
  • + *
  • Accept: text/csv : demande à l'API de renvoyer du CSV dans le body de sa réponse
  • + *
  • Accept-Header : demande à l'API de renvoyer uniquement certaines colonnes CSV et dans cet ordre
  • + *
  • X-Date-Format : demande à l'API de formater les dates selon le format fourni. Utilise la syntax de + * {@link SimpleDateFormat} + *
+ * + *

+ * TODO : dans le cas où le client n'envoie pas de Accept-Header réutiliser les colonnes reçues en entrée ssi les ETO à + * renvoyer sont de même type que les ETO reçus. Si les ETO renvoyés sont d'un autre type renvoyer par défaut toutes les + * colonnes possibles. + *

+ * + *

+ * En cas d'erreur com.fasterxml.jackson.core.JsonGenerationException: CSV generator does not support + * Object values for properties veuillez annoter l'Eto/Cto serialisé avec : + *

+ * + *
+ * @JsonFilter(CsvProvider.FILTER)
+ * 
+ * + *

+ * Ce qui permet d'empêcher Jackson de serialiser tous les champs même ceux qui ne sont pas demandés par le client dans + * son header Accept-Columns + *

+ * + * @author mlavigne + * @see reference + */ +@Provider +@Consumes(CsvProvider.MEDIA_TYPE) +@Produces(CsvProvider.MEDIA_TYPE) +@Named +public class CsvProvider implements ContainerRequestFilter, MessageBodyWriter, MessageBodyReader { + + /** + * + */ + public static final String ACCEPT_QUOTE_CHAR = "Accept-Quote-Char"; + + public static final String CONTENT_QUOTE_CHAR = "Content-Quote-Char"; + + /** + * + */ + public static final String ACCEPT_COLUMN_SEPARATOR_HEADER = "Accept-Column-Separator"; + + public static final String CONTENT_COLUMN_SEPARATOR_HEADER = "Content-Column-Separator"; + + /** RFC 7111 Content-Type for CSV content */ + // TODO : handle "application/vnd.ms-excel" in another CsvProvider () that extends this one + public static final String MEDIA_TYPE = "text/csv"; // TODO : rename to MEDIA_TYPE + + /** Séparateur des noms de colonnes dans les headers Columns, Accept-Header et Content-Header */ + public static final String COLUMNS_HEADER_SEPARATOR = ","; // TODO : automaticaly switch depending on Column-Separator + + /** Nom du header "Content-Header" indiquant le noms des colonnes correspondant à la ligne CSV dans le body */ + // TODO : si non spécifié alors on considère que la 1ère ligne sont les headers + public static final String CONTENT_HEADER = "Content-Header"; + + public static final String ACCEPT_HEADER = "Accept-Header"; + + public static final String ANSI_ENCODING = "Cp1252"; + + // TODO : use application.properties to get default values + public static final CsvFormat DEFAULT_CSV_FORMAT = new CsvFormat().withNullValue(StringUtils.EMPTY) // On considère la + // chaîne vide + // comme null + .withColumnSeparator(';').withQuoteChar('"').withLineSeparator("\r\n").withoutEndingLineSeparator() + .withDateFormat(new SimpleDateFormat("dd/MM/yyyy")); + + /** Logger instance. */ + private static final Logger LOG = LoggerFactory.getLogger(CsvProvider.class); + + @Context + HttpServletRequest request; + + /** + * The constructor. + */ + public CsvProvider() { + // NOP + } + + @Override + public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + + return true; + } + + @Override + public boolean isReadable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + + return true; + } + + @Override + public long getSize(Object t, Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + + // deprecated by JAX-RS 2.0 and ignored by Jersey runtime + return 0; + } + + @Override + public void writeTo(Object eto, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, + MultivaluedMap httpHeaders, OutputStream entityStream) + throws IOException, WebApplicationException { + + // Format accepté par le client + final CsvFormat responseFormat = (CsvFormat) this.request.getAttribute("responseFormat"); + + // Si le service REST renvoie une chaine non parsée, on renvoie la chaîne telle quelle. + // Du coup les headers "Columns" ne sont pas utilisés puisqu'on ne parse par le CSV + if (CharSequence.class.isAssignableFrom(type)) { + IOUtils.write((CharSequence) eto, entityStream, responseFormat != null ? responseFormat.getCharset() : null); + return; + } + + if (PaginatedListTo.class.isAssignableFrom(type)) { + // On peut transmettre genericType car le E de PaginatedListTo est le même que celui de Iterable + final List result = ((PaginatedListTo) eto).getResult(); + writeTo(result, result.getClass(), genericType, annotations, mediaType, httpHeaders, entityStream); + return; + } + + final String charset = responseFormat.getCharset(); + if (httpHeaders != null) { // null dans les TU + // On répète au client les colonnes renvoyées dans le body de la réponse dans un header Content-Header + httpHeaders.add(CONTENT_HEADER, this.request.getHeader(ACCEPT_HEADER)); + if (charset != null) { + httpHeaders.putSingle(HttpHeaders.CONTENT_TYPE, CsvProvider.MEDIA_TYPE + ";charset=" + charset); + } + } + + responseFormat.writeValue(entityStream, eto); + } + + @Override + public void filter(ContainerRequestContext req) throws IOException { + + final MediaTypeHeaderElement contentType = + MediaTypeHeaderElement.parse(HttpHeaders.CONTENT_TYPE, req.getHeaderString(HttpHeaders.CONTENT_TYPE)); + if (contentType != null) { + if (MEDIA_TYPE.equals(contentType.mediaType)) { + req.setProperty("requestFormat", getRequestFormat(req, contentType)); + } + } + + final MediaTypeHeaderElement accept = + MediaTypeHeaderElement.parse(HttpHeaders.ACCEPT, req.getHeaderString(HttpHeaders.ACCEPT)); + if (accept != null) { + if (MEDIA_TYPE.equals(accept.mediaType)) { + req.setProperty("responseFormat", getResponseFormat(req, accept)); + } + } + } + + public static final class MediaTypeHeaderValueParser extends BasicHeaderValueParser { + + @Override + protected HeaderElement createHeaderElement(String name, String value, NameValuePair[] params) { + + return new MediaTypeHeaderElement(name, value, params); + } + } + + public static final class MediaTypeHeaderElement extends BasicHeaderElement { + + /** + * The constructor. + * + * @param name + * @param value + * @param parameters + */ + public MediaTypeHeaderElement(String name, String value, NameValuePair[] parameters) { + super(name, value, parameters); + + this.mediaType = getName(); + final NameValuePair charset = getParameterByName("charset"); + if (charset != null) { + this.charset = charset.getValue(); + } + final NameValuePair headerParam = getParameterByName("header"); + if (headerParam != null) { + final String headerParamValue = headerParam.getValue(); + if ("present".equals(headerParamValue)) { + this.headerPresent = true; + } else if ("absent".equals(headerParamValue)) { + this.headerPresent = false; + } else { + throw new IllegalHeaderValueException(getName(), getValue()); + } + } + } + + /** @see MediaType */ + private String mediaType; + + private String charset; + + /** default is false */ + private boolean headerPresent = false; + + /** + * + * @param headerName "Content-Type" or "Accept" // FIXME not used + * @param headerValue Content-Type/Accept header value + * @return + * + * @see HttpHeaders#CONTENT_TYPE + * @see HttpHeaders#ACCEPT + */ + public static final MediaTypeHeaderElement parse(String headerName, String headerValue) { + + if (StringUtils.isNotBlank(headerValue)) { + final HeaderElement element = + MediaTypeHeaderValueParser.parseHeaderElement(headerValue, new MediaTypeHeaderValueParser()); + return new MediaTypeHeaderElement(element.getName(), element.getValue(), element.getParameters()); + } + return null; + } + } + + /** + * @param req + * @param mediaType + * @return + */ + private CsvFormat getRequestFormat(ContainerRequestContext req, MediaTypeHeaderElement mediaType) { + + // Content-Column-Separator + final String columnSeparatorHeader = req.getHeaderString(CONTENT_COLUMN_SEPARATOR_HEADER); + + // Content-Quote-Char + final String quoteChar = req.getHeaderString(CONTENT_QUOTE_CHAR); + + // Content-Header + final String columnsHeader = req.getHeaderString(CONTENT_HEADER); + + // Content-Charset header does not exist ; it is a parameter in the mediaType header + final String charsetHeader = null; + + return getFormat(columnSeparatorHeader, columnsHeader, quoteChar, charsetHeader, mediaType); + } + + /** + * @param req + * @param mediaType + * @return + */ + private CsvFormat getResponseFormat(ContainerRequestContext req, MediaTypeHeaderElement mediaType) { + + // Accept-Column-Separator + final String columnSeparatorHeader = req.getHeaderString(ACCEPT_COLUMN_SEPARATOR_HEADER); + + // Accept-Quote-Char + final String quoteChar = req.getHeaderString(ACCEPT_QUOTE_CHAR); + + // Accept-Header + final String columnsHeader = req.getHeaderString(ACCEPT_HEADER); + + // Accept-Charset + final String charsetHeader = req.getHeaderString(HttpHeaders.ACCEPT_CHARSET); + + return getFormat(columnSeparatorHeader, columnsHeader, quoteChar, charsetHeader, mediaType); + } + + /** + * @param columnSeparatorHeader + * @param columnsHeader + * @param quoteChar + * @param charsetHeader + * @param mediaType + * @return + */ + private CsvFormat getFormat(String columnSeparatorHeader, String columnsHeader, String quoteChar, + String charsetHeader, MediaTypeHeaderElement mediaType) { + + final Character columnSeparator = StringUtils.isNotBlank(columnSeparatorHeader) ? columnSeparatorHeader.charAt(0) + : CsvFormat.DEFAULT_COLUMN_SEPARATOR; + + CsvFormat format = DEFAULT_CSV_FORMAT; + + // *-Column-Separator + if (columnSeparator != null) { + format = format.withColumnSeparator(columnSeparator); + + // *-Header + if (StringUtils.isNotBlank(columnsHeader)) { + format = format.withColumns(CsvFormat.getColumns(columnsHeader, columnSeparator)); + } + } + + // explicit header param in MediaType header or implicit because columns are unknown in HTTP Headers + if (format.getColumns() == null || mediaType != null && mediaType.headerPresent) { + format = format.withHeader(); + } + + // *-Quote-Char + if (StringUtils.isNotBlank(quoteChar)) { + format = format.withQuoteChar(quoteChar.charAt(0)); + } + + // charset + final String charset; + if (StringUtils.isNotBlank(charsetHeader)) { + charset = charsetHeader; + } else if (mediaType != null) { + charset = mediaType.charset; + } else { + charset = null; + } + if (charset != null) { + format = format.withCharset(charset); + } + + return format; + } + + @Override + public Object readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, + MultivaluedMap httpHeaders, InputStream entityStream) + throws IOException, WebApplicationException { + + // Si le service REST s'attend à recevoir une chaine non parsée, on lui renvoie la chaîne telle quelle. + // Du coup les headers "Columns" ne sont pas utilisés puisqu'on ne parse par le CSV + if (CharSequence.class.isAssignableFrom(type)) { + return IOUtils.toString(entityStream); + } + + // Format utilisé par le client + final CsvFormat requestFormat = (CsvFormat) this.request.getAttribute("requestFormat"); + return requestFormat.readValue(entityStream, type); + } + +} diff --git a/samples/core/src/test/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProviderTest.java b/samples/core/src/test/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProviderTest.java new file mode 100644 index 000000000..a2a1a73d8 --- /dev/null +++ b/samples/core/src/test/java/io/oasp/gastronomy/restaurant/general/service/impl/rest/CsvProviderTest.java @@ -0,0 +1,21 @@ +package io.oasp.gastronomy.restaurant.general.service.impl.rest; + +import static org.junit.Assert.fail; + +import org.junit.Ignore; +import org.junit.Test; + +/** + * @author MLAVIGNE + * + */ +public class CsvProviderTest { + + @Test + @Ignore // TODO + public void test() { + + fail("Not yet implemented"); + } + +}