1エンドポイントでJSON,Yaml,CSVに対応する

# エンドポイント

ある１つのエンドポイントがあったとして、このREST-APIのレスポンスのContent−Typeを拡張子で切り替えてみようと思います。

## 期待する結果

### デフォルト or JSON

```
Request
GET /items or /items.json
---
Response
Content-Tyep: application/json;charset=UTF-8
```

### Yaml

```
Request
GET /items.yaml
---
Response
Content-Tyep: application/yaml;charset=UTF-8
```

### CSV

```
Request
GET /items.csv
---
Response
Content-Tyep: text/csv;charset=UTF-8
```

## コントローラ

用意するのはこのコントローラ１つ。
REST-APIのコントローラとしては何のリソースを返すかだけの問題なので、これで十分なはず。
Content-Typeに合わせてどう変換するかはSpring-MVCに於いてコントローラの仕事じゃないはず。

```java
@GetMapping("/items")
List<Item> getItems(HttpServletResponse response) {
return List.of(Item.builder()
  .id(1234L)
  .name("手提げバッグ")
  .category(Category.builder()
    .id(345)
    .name("小物")
    .parent(Category.builder()
      .id(345)
      .name("バッグ")
      .build())
    .build())
  .build());
```

# Yamlを追加してみる

SpringBoot使ってたら、このままでもJSONだけなら返る。

```bash
$ curl -i http://localhost:8080/items 
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
```
```json
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]
```

これにYamlで返るように設定を追加してみる。

```application.yaml
spring:
  mvc:
    contentnegotiation:
      media-types:
        json: application/json
        yaml: application/yaml
      favor-path-extension: true
    pathmatch:
      use-registered-suffix-pattern: true
```

これで拡張子が`.json`なら`application/json`に。
`.yaml`なら`application/yaml`にレスポンスのContent-Typeが変わるはず。
CharsetはデフォルトUTF-8なので気にしない。

でもこれだけだとContent-Typeが`application/yaml`に変わるだけで、実際にYaml形式のレスポンスには変換してくれない。

なので、Yaml用の`HttpMessageConverter`を追加してみる。

pom.xmlに`jackson-dataformat-yaml`を追加する。

```xml:pom.xml
<dependency>
  <groupId>com.fasterxml.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
```

バージョンは`spring-boot-starter-parent`に任せるので指定不要。

次にBean設定。
JSONで使われている`MappingJackson2HttpMessageConverter`の`ObjectMappper`に`YamlFactory`を指定するだけ。簡単。

```java:Config.java
@Bean
public MappingJackson2HttpMessageConverter yamlConverter() {
var yamlConverter = new MappingJackson2HttpMessageConverter(
  new ObjectMapper(new YAMLFactory()));
yamlConverter.setSupportedMediaTypes(List.of(MediaType.parseMediaType("application/yaml")));
return yamlConverter;
}
```

## 結果

```bash
$ curl -i http://localhost:8080/items.yaml
HTTP/1.1 200
Content-Type: application/yaml;charset=UTF-8
```
```yaml
---
- id: 1234
  name: "手提げバッグ"
  category:
    id: 345
    name: "小物"
    parent:
      id: 345
      name: "バッグ"
      parent: null
```

JSONでも問題ない？

```bash
$ curl -i http://localhost:8080/items
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
```
```json
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]
```

問題ない。

# CSVを追加してみる

まずはContentNegotiationの設定。

```yaml:application.yaml
spring:
  mvc:
    contentnegotiation:
      media-types:
        json: application/json
        yaml: application/yaml
        csv: text/csv
      favor-path-extension: true
    pathmatch:
      use-registered-suffix-pattern: true
```

次にライブラリ追加。
これもYamlと同じようにJacksonにライブラリが無いか探してみる。

あった。

`jackson-dataformat-csv`

pom.xmlに追加する。

```xml:pom.xml
<dependency>
  <groupId>com.fasterxml.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-csv</artifactId>
</dependency>
```

これもYaml同様`spring-boot-starter-parent`にバージョン管理は任せる。

次に`HttpMessageConverter`の設定。
これも`CsvFactory`を設定して・・・エラーになった。。

そうだよ。そんな簡単なはず無いんだよ。
CSVなんて1行データでそもそもネスト階層のデータなんて表現できないんだから。

だから、まずはネスト階層のデータをどう1行データにマッピングするかを考えなきゃいけない。
`HttpMessageConverter`の設定までの道のりは遠い。

### CSVマッピングのルール定義

コントローラが返す`List<Item>`の`Item`はこんな構造。

```java:Item.java
@Data
@Builder
public class Item {
  private Long id;
  private String name;
  private Category category;
}
```

ネスト階層のデータも持つように`Category`を入れている。

```java:Category.java
@Data
@Builder
public class Category {
  private Integer id;
  private String name;

  @Nullable
  private Category parent;
}
```

`Category`はネストできるようになっていて、常に「親」カテゴリを知っている状態。
親がいなければトップ階層のカテゴリといった具合。
実際こんな構造のデータはまぁ最近は見かけないが、今回の検証用。

これをこんな形のCSVにしようと思う。

| 商品ID | 商品名 | 大カテゴリ | 中カテゴリ |
|:------:|:------------:|:----------:|:----------:|
| 1234 | 手提げバッグ | 小物 | バッグ |

じゃあ、理想の形をJacksonのMix-Inで表現してみる。

```java
@JsonProperty("商品ID")
private Long id;

@JsonProperty("商品名")
private String name;

@JsonProperty("大カテゴリ")
private String largeCategoryName;

@JsonProperty("中カテゴリ")
private String middleCategoryName;
```

でも、このMix-Inを`Item`のMix-Inとして登録しても`largeCategoryName`なんか無ぇよってエラーになる。

じゃあ、`Item`をこの理想の形に変換してからJacksonにシリアライズしてもらえればエラーにならないはず。

そこで、自前の変換ロジックを`ObjectMapper`に設定して変換してみる。

まずは独自の変換ルール用にアノテーション追加。

```java:ItemFlatMixin.java
@Data
@JsonPropertyOrder({ "商品ID", "商品名", "大カテゴリ", "中カテゴリ" })
// ネスト階層を表現できるオブジェクトをフラットにするためのアノテーション
@JsonFlatMixinFor(Item.class)
public class ItemFlatMixin {
  @JsonProperty("商品ID")
  private Long id;

  @JsonProperty("商品名")
  private String name;

  @JsonProperty("大カテゴリ")
  // フラットにする際に元の型の値へのアクセスするためのパス
  @JsonMixinProperty("category.parent.name")
  private String largeCategoryName;

  @JsonProperty("中カテゴリ")
  @JsonMixinProperty("category.name")
  private String middleCategoryName;
}
```

Mix-Inなのに何で具象クラスなんだっていうツッコミはちょっと待って下さい。

次は`Serializer`。

```java:FlatSerializer.java
@RequiredArgsConstructor
public class FlatSerializer extends JsonSerializer<Object> {

  private final Class<?> flatType;

  @Override
  public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers)
    throws IOException, JsonProcessingException {
    var targetWrapper = this.wrap(value);
    var mixinWrapper = this.wrap(BeanUtils.instantiateClass(this.flatType));

    // ItemFlatMixinのフィールドをグルッと回して対応する値をItemから取得して自身にセットする
    ReflectionUtils.doWithFields(this.flatType, field -> {
      var targetPath = this.targetPath(field);
      if (targetWrapper.isReadableProperty(targetPath) == false) {
        return;
      }
      if (mixinWrapper.isWritableProperty(field.getName()) == false) {
        return;
      }
      mixinWrapper.setPropertyValue(field.getName(), targetWrapper.getPropertyValue(targetPath));
    });
    gen.writeObject(mixinWrapper.getWrappedInstance());
  }

  private BeanWrapper wrap(Object obj) {
    var wrapper = PropertyAccessorFactory.forBeanPropertyAccess(obj);
    // JsonMixinProperty#valueの値はこの機能を使うため。
    wrapper.setAutoGrowNestedPaths(true);
    return wrapper;
  }

  private String targetPath(Field field) {
    var property = field.getAnnotation(JsonMixinProperty.class);
    if (property == null) {
      return field.getName();
    }
    return property.value();
  }

}
```

この`Serializer`をどうするんだ？`flatType`にはどうやって値をセットすんだ？
で、`SimpleSerializers`を使う。

```java:SampleSerializers.java
@RequiredArgsConstructor
public class SampleSerializers extends SimpleSerializers {

  // 全てのPOJOとフラットな型への変換マッピング
  private final Map<Class<?>, Class<?>> flatterMap;

  // シリアライズ時にシリアライザを探す処理が走るので、その際に対象の型からシリアライザを動的生成する
  @Override
  public JsonSerializer<?> findSerializer(SerializationConfig config, JavaType type, BeanDescription beanDesc) {
    var rawClass = type.getRawClass();
    var flatClass = this.flatterMap.entrySet().stream()
      .filter(entry -> entry.getKey().isAssignableFrom(rawClass))
      .findFirst()
      .map(Entry::getValue)
      .orElse(null);

    if (flatClass != null) {
      return new FlatSerializer(flatClass);
    }

    return super.findSerializer(config, type, beanDesc);
  }
}
```

スーパークラスの実装はシリアライザをキャッシュしているので、そのあたりは本実装する際はよしなに実装する。
キャッシュは使った方が良い。

そして、この`SimpleSerializers`を`ObjectMapper`に登録する。
CSVの場合は`ObjectMapper`ではなく`CsvMapper`。

```java:Config.java
@Bean
public CsvMapper csvMapper() {
var csvMapper = new CsvMapper();
csvMapper.registerModule(new SimpleModule() {
  @Override
  public void setupModule(SetupContext context) {
    context.addSerializers(new SampleSerializers(Config.this.flatterMap()));
  }
});

return csvMapper;
}

private Map<Class<?>, Class<?>> flatterMap() {
return new Reflections("jp.uich.databind.mixin")
  .getTypesAnnotatedWith(JsonFlatMixinFor.class)
  .stream()
  .collect(Collectors.toMap(
    type -> type.getAnnotation(JsonFlatMixinFor.class).value(), Function.identity()));
}
```

ここで使っている`Reflections`は`org.reflections`の`reflections`。
SpringBootの特定バージョン以上の場合、`Guava`でパッケージスキャンするとExecutableJarにした際にうまくいきません。
これは本題ではないので、理由は割愛します。

次は `HttpMessageConverter` の設定。

```java:Config.java
@Bean
public MappingJackson2HttpMessageConverter csvConverter() {
  var csvConverter = new MappingJackson2HttpMessageConverter(this.csvMapper());
  csvConverter.setSupportedMediaTypes(Collections.singletonList(MediaType.parseMediaType("text/csv")));
  return csvConverter;
}
```

として終われば良いんだけど、そうはいかない。
`CsvSchema` の設定をしないといけない。
こればかりは`CsvMapper`側でどう頑張っても解決できないので`HttpMessageConverter`に手を入れる必要がある。

ということで、`AbstractJackson2HttpMessageConverter`を継承して実装してみる。

```java:MappingJackson2CsvHttpMessageConverter.java
@Override
protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
var csvMapper = (CsvMapper) this.getObjectMapper();

// レスポンスの文字コード
// #getJsonEncoding(MediaType)は使わない。Shift_JISに対応していないから。
var actualCharset = Optional.ofNullable(outputMessage.getHeaders().getContentType())
  // レスポンスヘッダに指定されていたらそれを使用する
  .map(contentType -> contentType.getCharset())
  // そうでなければデフォルトのCharset
  .orElseGet(this::getDefaultCharset);

var generator = csvMapper.getFactory().createGenerator(
  new OutputStreamWriter(outputMessage.getBody(), actualCharset));

try {
  // CSVのスキーマ(1行目のヘッダー)を生成するために型を特定する
  // 型 → シリアライズルールを定義している型 → ItemFlatMixin.java
  var objectWriter = Optional.ofNullable(type)
    .map(csvMapper::schemaFor)
    .map(CsvSchema::withHeader)
    .map(csvMapper::writer)
    .orElseGet(csvMapper::writer);

  objectWriter.writeValue(generator, object);
  generator.flush();

} catch (JsonProcessingException ex) {
  throw new HttpMessageNotWritableException("Could not write content: " + ex.getMessage(), ex);
}
```

これでOK！と思って実行してみたらエラー。
そらそうだ。スキーマを特定しようとしている型は`Item`。
`CsvMapper`は`Item`のシリアライズルールに`ItemFlatMixin`が使われることを知らない。

なので、Mix-Inとして登録してみる。

```java:Config.java
@Bean
CsvMapper csvMapper() {
var csvMapper = new CsvMapper();
// これを追加
csvMapper.setMixIns(this.flatterMap());

csvMapper.registerModule(new SimpleModule() {
  @Override
  public void setupModule(SetupContext context) {
    context.addSerializers(new SampleSerializers(Config.this.flatterMap()));
  }
});

return csvMapper;
}
```

とやってみたが、エラーになる。
まぁ、これもそうなんだよ。 `Item`と`ItemFlatMixin`はフィールドの内容が違うから`ObjectMapper`はスキーマ判定する際に

```
Item？ -> ItemFlatMixin使おう -> ん？何かフィールド違わないか？ -> エラー!!!!
```

ってなる。
なので、スキーマの型を特定するところに少し手を加えてみる。

```java:MappingJackson2CsvHttpMessageConverter.java
private Class<?> getSchemaType(Type type) {
  if (type == null) {
    return null;
  }
  var javaType = this.getJavaType(type, null);
  Class<?> rowClass;

  if (javaType.isCollectionLikeType()) {
    rowClass = Optional.ofNullable(javaType.getContentType())
      .map(JavaType::getRawClass)
      .orElse(null);

    if (rowClass == null) {
      return javaType.getRawClass();
    }
  } else {
    rowClass = javaType.getRawClass();
  }

  // Item -> ItemFlatMixinに変換
  var classForSchema = this.getObjectMapper().findMixInClassFor(rowClass);
  if (classForSchema == null) {
    return rowClass;
  }

  return classForSchema;
}
```

で、さっきの`MappingJackson2CsvHttpMessageConverter`の該当行を次のように書き換える。

```java:MappingJackson2CsvHttpMessageConverter.java
var objectWriter = Optional.ofNullable(this.getSchemaType(type))
  .map(csvMapper::schemaFor)
  .map(CsvSchema::withHeader)
  .map(csvMapper::writer)
  .orElseGet(csvMapper::writer);
```

この`getSchemaType`から返る型は`ItemFlatMixin`となり、このクラスはこのままシリアライズ時のルールを持っているので`ObjectMapper`はMix-Inを探さずに（探すとは思うが見つからない）そのまま`ItemFlatMixin`のシリアライズルールに則りスキーマを出力する。

これで`MappingJackson2CsvHttpMessageConverter`をBean登録すれば準備完了。

```java:Config.java
@Bean
MappingJackson2CsvHttpMessageConverter csvConverter() {
  var converter = new MappingJackson2CsvHttpMessageConverter(this.csvMapper());
  converter.setSupportedMediaTypes(List.of(MediaType.parseMediaType("text/csv")));
  return converter;
}
```

## 結果確認

```bash
$ curl -i http://localhost:8080/items.csv
HTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
```
```
商品ID,商品名,大カテゴリ,中カテゴリ
1234,手提げバッグ,バッグ,小物
```

OK。

JSONやYamlも問題ない？

```bash
$ curl -i http://localhost:8080/items.json
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
```
```json
[ {
  "id" : 1234,
  "name" : "手提げバッグ",
  "category" : {
    "id" : 345,
    "name" : "小物",
    "parent" : {
      "id" : 345,
      "name" : "バッグ",
      "parent" : null
    }
  }
} ]
```
```bash
$ curl -i http://localhost:8080/items.yaml
HTTP/1.1 200
Content-Type: application/yaml;charset=UTF-8
```
```
---
- id: 1234
  name: "手提げバッグ"
  category:
    id: 345
    name: "小物"
    parent:
      id: 345
      name: "バッグ"
      parent: null
```
```bash
$ curl -i http://localhost:8080/items
HTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
```
```
商品ID,商品名,大カテゴリ,中カテゴリ
1234,手提げバッグ,バッグ,小物
```

あら？デフォルトがCSVになってしまった。
デフォルトはJSONのままにしたい。

`HttpMessageConverter`に`@Order`で調整してみたけどダメだった。
で結局解決したのはこれ↓

```java:Config.java
@Bean
HttpMessageConverters converters() {
  return new HttpMessageConverters(false, List.of(
    this.jsonConverter(),
    this.yamlConverter(),
    this.csvConverter()));
}
```

# まとめ

- シリアライズ処理はJacksonで一本化可能
- 今回の検証でのMixinの使い方は気持ち悪い。フラットにする方とJacksonで使用するMix-Inは分けた方が良いかも

今回のコードも[GitHub](https://github.com/uich/demo-jackson-csv-web)にアップしてます。
CSVでダウンロードする際に文字コードを`Shift_JIS`に変更する処理も入れていたりしますので興味のある方はどうぞ。

# 追記

## 2019-02-22

- JDK11
- Spring Boot<span>@</span>2.1.2