Kotlin + Spring Boot で OpenAPI Generator 体験記②（Mustacheテンプレート格闘編）

以下の記事の続きになります

https://qiita.com/chorei/items/884281ab874ad3098956

## 環境（再掲）

* OpneAPI Generator 7.1.0
* OpenAPI Specification 3.0.3
* Spring Boot 3.1.5
* Amazon Corretto 17
* Kotlin 1.9.10

## Mustacheテンプレートのカスタマイズ

OpenAPI Generator は豊富なオプションで出力内容をカスタマイズできますが、デフォルトの`Mustacheテンプレート`を差し替えることでさらに細かく出力内容をカスタマイズすることができます。

今回は実際に開発で変更したテンプレートの内容とその理由を説明していきます。元のテンプレートは以下で確認できます。

https://github.com/OpenAPITools/openapi-generator/tree/v7.1.0/modules/openapi-generator/src/main/resources/kotlin-spring

なお、Mustacheの記法については解説しません。公式を含めて情報が出回っているので各自ご確認お願いします。

https://mustache.github.io/

## カスタマイズ1: 数値Enumでコンパイルが通るようにする

OpenAPIで`enum`として定義したものは`enum class`として出力してくれます。
この時Jacksonでマッピングできるように`@JsonProperty`でどのenum値にマッピングするかを生成してくれるのですが、数値enumの場合にコンパイルエラーになってしまいます。

```kotlin:original
enum class SampleEnum(val value: kotlin.Int) {

    @JsonProperty(1) _1(1), // JsonPropertyのvalueはStringのためコンパイルエラー
    @JsonProperty(2) _2(2)  // 同上
}
```

若干テンプレートのバグなのでは? 疑惑はありますが、テンプレートを修正し`@JsonProperty`ではなく`@JsonValue`を使用する方向で回避します。

```diff:template-diff
index 572875356af..603b720b705 100644
--- a/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache
+++ b/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache
@@ -2,7 +2,7 @@
 * {{{description}}}
 * Values: {{#allowableValues}}{{#enumVars}}{{&name}}{{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}
 */
-enum class {{classname}}(val value: {{dataType}}) {
+enum class {{classname}}(@JsonValue val value: {{dataType}}) {
 {{#allowableValues}}{{#enumVars}}
-    @JsonProperty({{{value}}}) {{&name}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}
+    {{&name}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}
 }
```


```kotlin:custom
enum class SampleEnum(@JsonValue val value: kotlin.Int) {

    _1(1),
    _2(2)
}
```

## カスタマイズ2: 必須項目をBeanValidtionでチェックさせる

KotlinはNull安全な言語であり、型宣言で`?`を定義することででNull許容型になります

```kotlin
val nonNull: String = null // NG
val nullable: String? = null // OK
```

OpenAPIで`required = true`と定義したものは非Null型として出力してくれるのですが、リクエストがNull（またはプロパティ自体が存在しない）で送られてきた場合にJacksonのマッピングでエラーになります。このエラーをハンドリングして必須項目不足の400エラーにしても良いのですが、複数のフィールドでエラーになった場合に1つしかハンドリングができず全エラー内容をAPIのエラーとして通知できません。

```kotlin
// 両方Nullで送られてきた場合に、「nonNull1」「nonNull2」の両方が不足していることを通知したいが、1つしかハンドリングできない
val nonNull1: String
val nonNull2: String
```

私たちが扱っているAPIは1APIあたりのフィールド数も多く、1度に全てのエラーを通知できないと使い勝手が悪いです。そのためNull安全のメリットを多少犠牲することになりますが、全てのフィールドをNull許容型として出力してもらい、`@NotNull`で必須チェックを行うことにしています。

```kotlin:original
class SampleClass(

    @get:JsonProperty("nonNull1") val nonNull1: kotlin.String,
    
    @get:JsonProperty("nonNull2") val nonNull2: kotlin.String,
}
```

```diff:template-diff
index d8bb06aa166..ce04efee84c 100644
--- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache
+++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache
@@ -1,4 +1,4 @@
 {{#useBeanValidation}}{{>beanValidation}}{{>beanValidationModel}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}
     @Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeDoubleQuote}}{{{.}}}{{/lambdaEscapeDoubleQuote}}{{/lambdaRemoveLineBreak}}", {{/example}}required = true, {{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}
     @ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeDoubleQuote}}{{{.}}}{{/lambdaEscapeDoubleQuote}}{{/lambdaRemoveLineBreak}}", {{/example}}required = true, {{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}
-    @get:JsonProperty("{{{baseName}}}", required = true){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInCamelCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}
\ No newline at end of file
+    @get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInCamelCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}?{{#defaultValue}} = {{{.}}}{{/defaultValue}}

index 996353148d6..a909ee32f4e 100644
--- a/modules/openapi-generator/src/main/resources/kotlin-spring/beanValidationModel.mustache
+++ b/modules/openapi-generator/src/main/resources/kotlin-spring/beanValidationModel.mustache
@@ -35,4 +35,7 @@ isLong set
 Not Integer, not Long => we have a decimal value!
 }}{{^isInteger}}{{^isLong}}{{#minimum}}
     @get:DecimalMin("{{.}}"){{/minimum}}{{#maximum}}
-    @get:DecimalMax("{{.}}"){{/maximum}}{{/isLong}}{{/isInteger}}
\ No newline at end of file
+    @get:DecimalMax("{{.}}"){{/maximum}}{{/isLong}}{{/isInteger}}{{!
+check required
+}}{{#required}}
+    @field:NotNull{{/required}}
```

```kotlin:custom
class SampleClass(

    @field:NotNull
    @get:JsonProperty("nonNull1") val nonNull1: kotlin.String?,

    @field:NotNull
    @get:JsonProperty("nonNull2") val nonNull2: kotlin.String?,
}
```

## カスタマイズ3: デフォルト値`null`を出力させないようにする

Kotlinではメソッドやコンストラクタの引数にデフォルト値を設定することができ、OpenAPIでデフォルト値を定義しなかった場合はデフォルト値`null`として出力されます。[^1]

```kotlin
class SampleResponse(

    @get:JsonProperty("field1") val field1: kotlin.String? = null,
}
```

私たちは出力されたクラスをAPIのRequest/Response定義として使用しています。このようにデフォルト値が設定されてしまうとAPI仕様変更時に実装を修正しなくてもコンパイルが通ってしまい、修正漏れに気づきづらくなるため好ましくありません。（主にResponseで使用するclass）

```kotlin
class SampleResponse(

    @get:JsonProperty("field1") val field1: kotlin.String? = null,

    @get:JsonProperty("field2") val field2: kotlin.String? = null, // 追加された項目
}

@RestController
class SampleController {

    @GetMapping("/sample")
    fun sample(): SampleResponse {
        return SampleResponse(
            field1 = "hoge",
            // field2にはデフォルト値が設定されているため、使用側を修正しなくてもコンパイルが通る
        )
    }
}
```

API仕様変更時に気づきやすいようにデフォルト値の設定を削除します。

```kotlin:original
class SampleResponse(

    @get:JsonProperty("field1") val field1: kotlin.String? = null,
}
```

```diff:template-diff
index 211736d7912..286139970d3 100644
--- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache
+++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache
@@ -2,4 +2,4 @@
     @Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeDoubleQuote}}{{{.}}}{{/lambdaEscapeDoubleQuote}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}
     @ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeDoubleQuote}}{{{.}}}{{/lambdaEscapeDoubleQuote}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}{{#deprecated}}
     @Deprecated(message = ""){{/deprecated}}
-    @get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInCamelCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}? = {{{defaultValue}}}{{^defaultValue}}null{{/defaultValue}}
\ No newline at end of file
+    @get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInCamelCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}?{{#defaultValue}} = {{{.}}}{{/defaultValue}}
\ No newline at end of file
```

```kotlin:original
class SampleResponse(

    @get:JsonProperty("field1") val field1: kotlin.String?,
}
```

## まとめ

今回は、Kotlin + Spring Boot で OpenAPI Generator を使用した際に参考になりやすそうなMustacheテンプレートの修正を3点ご紹介しました。ツールやライブラリ自体に手を入れずテンプレートの修正だけで細かく調整できるのは自由度が高く素晴らしいですね。

[^1]: OpenAPIで`default`を指定した場合はその値を出力してくれます。ですが、私たちは`null`の場合と同様の理由で`default`の使用を禁止しています。API仕様の明示のために`default`を活用した方がいいケースはありますが、今回のAPIはフロントエンドサービスからのみ呼び出されるAPIなためメリットが小さく先のデメリットとのバランスで禁止しています。