LoginSignup
5
3

More than 1 year has passed since last update.

memo: springdoc-openapi tips (Web MVC)

Posted at

Spring (Boot) において OpenAPI (a.k.a. Swagger) を生成する際に使うことになる springdoc-openapi だが、長らく使われてきた Springfox (Swagger 2)を置き換えた経緯もあってかカスタマイズのための情報が未だに少ないため、自分用のメモを兼ねてポイントを記す。

Web MVC との組み合わせで使うことが多かったため、今の所 Web MVC 用の情報のみである。

Jackson / HttpMessageConverter の設定を OpenAPI に反映する

MappingJackson2HttpMessageConverter 等で Web MVC の Jackson をカスタマイズ 1 している場合、以下のように springdoc-openapi の ModelResolver を定義することで Jackson ObjectMapper の設定を springdoc-openapi にも反映することができる:

@Configuration
class ApiJsonConfiguration {
    @Bean
    fun openapiModelResolver(converter: MappingJackson2HttpMessageConverter) =
        ModelResolver(converter.objectMapper) // Jackson の ObjectMapper を openapi-springdoc に渡す

    // MappingJackson2HttpMessageConverter は普通に定義するだけ、以下は定義の例
    @Bean
    fun apiJackson2Converter() = MappingJackson2HttpMessageConverter(
        Jackson2ObjectMapperBuilder()
            // 例: Controller の返す JSON を snake_case にする
            // ModelResolver 経由でこの ObjectMapper を springdoc にも渡しているため、OpenAPI 定義にも反映される
            .propertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
            .build<ObjectMapper>().also {
                it.registerKotlinModule() // このコード例は Kotlin なのでこれも登録している
            }
    )
}

@JsonProperty を明示的に指定することでも OpenAPI の生成を制御できるが、そのためだけに毎回アノテーションを手書きするのはさすがに無駄なので、HttpMessageConverter を使っている際にはこのような構成が簡潔である。

特定の型・パラメタ・プロパティに対して生成される OpenAPI 定義を制御する

ParameterCustomizerPropertyCustomizer を実装する Bean を定義することで、任意のパラメタ・プロパティに対して生成された OpenAPI 定義を書き換えることができる。

筆者は Spring の Converter@JsonFactory を利用する際に springdoc によって生成される OpenAPI 定義が乖離してしまう際の対処としてそれらを実装した。

abstract class ValueObject<T> という型を type = "string" などのスキーマに変換する例:

    @Bean
    fun openApiValueObjectPropertyCustomizer() = PropertyCustomizer { schema, swaggerType ->
        swaggerType.type.let { klass ->
            when {
                klass is JavaType && ValueObject::class.java.isAssignableFrom(klass.rawClass) -> 
                    valueObjectClassToSchema(klass.rawClass) ?: schema
                else -> schema
            }
        }
    }

    @Bean
    fun openApiValueObjectParameterCustomizer() = ParameterCustomizer { model, parameter ->
        model.schema = parameter.parameterType.let { klass ->
            when {
                ValueObject::class.java.isAssignableFrom(klass) ->
                    valueObjectClassToSchema(klass) ?: model.schema
                else -> model.schema
            }
        }
        model
    }

    private fun valueObjectClassToSchema(klass: Class<*>) =
        // rawValueTypeOf は Class<out ValueObject<T>> から Class<T> を得る関数、本題ではないため実装例は省略
        // この例では ValueObject は abstract class である想定なので Class<T> の情報が type erasure で消えてはいない
        when (ClassUtils.resolvePrimitiveIfNecessary(rawValueTypeOf(klass))) {
            String::class.java -> Schema<Any>().also { it.type = "string" }
            Int::class.java -> Schema<Any>().also { it.type = "integer" }
            Long::class.java -> Schema<Any>().also { it.type = "integer" }
            Boolean::class.java -> Schema<Any>().also { it.type = "boolean" }
            else -> null
        }

HandlerMethod の情報に基づいた OpenAPI 定義のカスタマイズ

Controller の handler method (@RequestMapping の付いているメソッド)の情報に基づいて OpenAPI 定義を書き換えたい場合もままある。

特に各種のアノテーション(認可処理など)の情報に基づいて OpenAPI 定義を書き換えたいことが多い。
認可の判定処理やエラーレスポンスの返却処理などは ControllerAdvice や Filter, Aspect によって一律に実装することが多く、個別のエンドポイントで OpenAPI スキーマのそれらの部分を手書きするよりもアノテーション等から自動で生成する方が建設的であろう。

そのような場合、 OperationCustomizer を実装すると良い。

OperationCustomizer には HandlerMethod の参照も渡されるため、controller のメソッドやクラスのアノテーションの情報にも自由にアクセスできる。

OpenAPI 定義を class の型情報から生成する

ParameterCustomizer, PropertyCustomizer などを実装する際に、OpenAPI スキーマを全て自力で生成するのではなく、任意の Class<T> に対して自動生成したい場合がある。

そのような場合は SpringDocAnnotationsUtils.extractSchema 2 を使うと良い。
このメソッドは static メソッドであるため複雑なセットアップを自力で行ったりせずに利用できる。

extractSchema は引数に Components を要求するが、ここには OpenAPI.components (OpenAPI スキーマ全体の "components" オブジェクトへの参照)を渡すのが正解である。
extractSchema は内部で $ref (components 以下の要素への参照) を生成することがあり、その際に ref の参照先の component を Components 以下に add するため、引数に渡した Components には更新が行われる。

なお、spring-doc openapi の傾向として、少しでも複雑な型定義に対しては { "type" = "object" } というだけの情報が全くないスキーマを生成してしまう傾向がある。そのようなスキーマが生成されてしまう場合、springdoc が処理できるシンプルな型に対して extractSchema にスキーマを生成させ、そのスキーマを加工する手法が有用である。

各種 Customizer から OpenAPI オブジェクト(OpenAPI スキーマ全体)を参照する方法

ここまでに述べた ParameterCustomizer, PropertyCustomizer, OperationCustomizer は引数に OpenAPI (OpenAPI スキーマ全体を表現するオブジェクト)を受け取らず、カスタマイズ対象の schema のみを参照・改変するインタフェースとなってしまっている。
実際には springdoc-openapi がこれらの customizer を呼び出す際には OpenAPI オブジェクトも存在しており、springdoc の実装においてはスキーマの各所を自在に参照・更新もしているのだが、残念ながらも customizer の引数には渡されないのである。

この制約は SpringDocAnnotationsUtils.extractSchema のようにスキーマの別の箇所を参照する $ref の仕様が避けられないケースで特に問題となる。

一方で OpenApiCustomiser 3 は OpenAPI オブジェクトを読み書きすることが可能である。
しかし OpenAPI オブジェクトには HandlerMethod などの情報が含まれないため、カスタマイズするための情報が不足する 4

そのため、customizer を実装する際には以下のようにする手法が便利である:

    private val operationIdToHandlerMethod = ConcurrentHashMap<String, HandlerMethod>()

    @Bean
    fun openApiOperationCustomizer() = OperationCustomizer { operation, handlerMethod ->
        operationIdToHandlerMethod[operation.operationId] = handlerMethod
        operation
    }

    @Bean
    fun openApiCustomizer(
        businessErrorCodeIterators: List<HandlerMethodBusinessErrorCodeIterator>
    ) = OpenApiCustomiser { openApi ->
        openApi.paths.values.forEach { pathItem ->
            pathItem.readOperations().forEach operationLoop@{ operation ->
                val handlerMethod = operationIdToHandlerMethod[operation.operationId]
                if (handlerMethod == null) {
                    // WebMVC 以外のエンドポイントもある場合はここは error にしないべきであろう
                    logger.error("No HandlerMethod found for OpenAPI operation ID \"${operation.operationId}\"")
                    return@operationLoop
                }

                // ... operation (= handler method)に対するカスタマイズ処理をここで実現 ...
            }
        }
    }

OpenApiCustomiser は OpenAPI スキーマ全体が生成された後に呼び出される仕様であるため、上記のコードは期待している順序どおりに呼び出される。


  1. 私見だが、アプリケーション全体で共有されている ObjectMapper をいじるよりも HttpMessageConverter で Controller 関係に絞って制御するほうが行儀が良い 

  2. springdoc-openapi がスキーマを生成する処理は巡り巡って最終的にこのメソッドを使っている 

  3. Customizer のうち OpenApiCustomiser だけ Customi"s"er であり、パッケージ名の customi"z"ers とも矛盾しているが、breaking change になってしまうので今から変えるわけにも行かないのであろう... 

  4. Operation のパスと HTTP メソッドを元に RequestMappingHandlerMapping の find メソッドによって HandlerMethod を得ることも可能ではあるが、膨大なメソッドを持つ HttpServletRequest インタフェースを実装する結果となってしまったので本記事の手法に戻した...。 

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3