🎄

Spring Boot で Problem Details に対応するための kotlin での実装

2024/12/13に公開

はじめに

13日の金曜日ですね!
この記事は、株式会社エス・エム・エス Advent Calendar 2024 シリーズ2の12/13の記事です。

https://qiita.com/advent-calendar/2024/bm-sms

今回は tips として Spring Boot で REST API を開発する際に、Problem Details に対応するための kotlin での実装を紹介します。

サンプルコード全体はこちらにあります!

Spring では RFC 9457(Problem Details for HTTP APIs) に対応した実装のサポートが用意されています

Problem Details Object を表現したクラスが定義されており、
仕様に則ったレスポンスの構築や追加で任意の property を含めることが簡単にできます。

ProblemDetail クラスを返すように実装すると、

    @GetMapping("/problem-detail")
    fun problemDetail(): ProblemDetail {
        return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR).apply {
            title = "problem detail title"
            detail = "problem detail"
            properties = mapOf(
                "custom_property1" to "hoge",
                "custom_property2" to "fuga",
            )
        }
    }

以下のようなレスポンスを作ってくれます。

$ curl -s 'localhost:8080/problem-detail' | jq .
{
  "type": "about:blank",
  "title": "problem detail title",
  "status": 500,
  "detail": "problem detail",
  "instance": "/problem-detail",
  "custom_property1": "hoge",
  "custom_property2": "fuga"
}

Content-Typeapplication/problem+json となっています!

$ curl -sI 'localhost:8080/problem-detail'
HTTP/1.1 500
Content-Type: application/problem+json
Date: Mon, 25 Nov 2024 07:50:48 GMT
Connection: close

特定の実行時例外をハンドリングして Problem Details に対応する

次に特定の実行時例外から情報を詰め替えて ProblemDetail クラスを作成してみます。
すごく雑な例ですが、以下のように実行時エラーが発生する実装があったとします。

    @GetMapping("/runtime-exception")
    fun runtimeException() {
        val numbers = intArrayOf(1, 2, 3)
        println(numbers[3])
    }

この API にアクセスするとデフォルトの Spring Boot の設定だと以下のようなエラーレスポンスが返されます。

$ curl -s 'localhost:8080/runtime-exception' | jq .
{
  "timestamp": "2024-11-25T08:08:08.763+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3\n\tat com.example.exceptionHandling.SampleErrorController.runtimeException(SampleErrorController.kt:28)\n\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:580)\n\tat kotlin.reflect.jvm.internal.calls.CallerImpl$Method.callMethod(CallerImpl.kt:97)\n\tat kotlin.reflect.jvm.internal.calls.CallerImpl$Method$Instance.call(CallerImpl.kt:113)\n\tat kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:207)\n\tat kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:112)\n\tat org.springframework.web.method.support.InvocableHandlerMethod$KotlinDelegate.invokeFunction(InvocableHandlerMethod.java:334)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:252)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:986)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:891)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1088)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:978)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:905)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)\n\tat java.base/java.lang.Thread.run(Thread.java:1583)\n",
  "message": "Index 3 out of bounds for length 3",
  "path": "/runtime-exception"
}

server.error.include-stacktrace の dafault value が always に指定されているため、
trace の property に stacktrace が含まれて返されます。

stacktrace は開発時にはレスポンスに含まれても良いですが、
API の公開時には、ログに出力しレスポンスには含まないのが良いかもしれません。

実装内容がクライアントに露出することによるセキュリティのリスクがあること、
またクライアントにとっては自身のリクエストに問題があったのかを知ることができるような、エラーの解消のために役に立つ情報ではないからです。
(開発環境だと役に立つ情報になり得ますが)

そこで trace の property に例外の発生元として出ている ArrayIndexOutOfBoundsException を捉えて Problem Details の形式に対応します。

Spring が提供する共通的な例外処理の実装として @ControllerAdvice@RestControllerAdvice があります。

特定の例外に対しての処理を定義する仕組みである @ExceptionHandler に、ArrayIndexOutOfBoundsException を指定します。

HTTP status code はサーバ内部で発生しているエラーでクライアントにより解消できる性質のものではないため、引き続き 500 番を返す実装にします。

@RestControllerAdvice
class CustomExceptionHandler {

    @ExceptionHandler(ArrayIndexOutOfBoundsException::class)
    fun handleArrayIndexOutOfBoundsException(ex: ArrayIndexOutOfBoundsException): ProblemDetail {
        logger.error("exception occurs", ex)
        return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR).apply {
            detail = ex.message
        }
    }
}

再度アクセスすると、Problem Details に対応でき、

$ curl -s 'localhost:8080/runtime-exception' | jq .
{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Index 3 out of bounds for length 3",
  "instance": "/runtime-exception"
}

stacktrace はログに出すようになりました。

2024-11-25T17:43:07.329+09:00 ERROR 15135 --- [exception-handling] [io-8080-exec-2] c.e.e.config.CustomExceptionHandler      : exception occurs

java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
	at com.example.exceptionHandling.SampleErrorController.runtimeException(SampleErrorController.kt:28) ~[main/:na]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]

// (略)

ちなみに Spring Boot 3.4 から Structured Logging が対応になったため、

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.4-Release-Notes#structured-logging
https://docs.spring.io/spring-boot/reference/features/logging.html#features.logging.structured

application.yml に以下の設定を追加するだけで、ログが json 形式で出力されます!

logging:
  structured:
    format:
      console: ecs
{"@timestamp":"2024-11-25T08:46:39.175460Z","log.level":"ERROR","process.pid":15135,"process.thread.name":"http-nio-8080-exec-2","service.name":"exception-handling","log.logger":"com.example.exceptionHandling.config.CustomExceptionHandler","message":"exception occurs","error.type":"java.lang.ArrayIndexOutOfBoundsException","error.message":"Index 3 out of bounds for length 3","error.stack_trace":"java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3\n\tat com.example.exceptionHandling.SampleErrorController.runtimeException(SampleErrorController.kt:28)\n\tat java.base\/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)\n\tat java.base\/java.lang.reflect.Method.invoke(Method.java:580)\n\tat kotlin.reflect.jvm.internal.calls.CallerImpl$Method.callMethod(CallerImpl.kt:97)\n\tat kotlin.reflect.jvm.internal.calls.CallerImpl$Method$Instance.call(CallerImpl.kt:113)\n\tat kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:207)\n\tat kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:112)\n\tat org.springframework.web.method.support.InvocableHandlerMethod$KotlinDelegate.invokeFunction(InvocableHandlerMethod.java:334)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:252)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:986)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:891)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1088)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:978)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:905)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)\n\tat java.base\/java.lang.Thread.run(Thread.java:1583)\n","ecs.version":"8.11"}

どのような実行時例外をハンドリングするかはアプリの設計次第ではありますが、
@ExceptionHandler と ProblemDetail のクラスを使用することで、
処理を差し込みログ出力を行い、エラーレスポンスを作成できました。

なお、ハンドリングしない例外を含むエラーレスポンスに stacktrace を表示したくない場合は、
application.yml の設定は調整しておくとよさそうです。

server:
  error:
    include-stacktrace: never

request の validation エラーをハンドリングして Problem Details に対応する

次に REST API のクライアントからの POST リクエストに対する validation エラーに Problem Details への対応を組み込みます。
OpenAPI で schema を定義し model のコードを自動生成した場合の開発を想定します。

以下のようなリクエストを定義し、

components:
  schemas:
    TaskAddRequestDto:
      type: object
      properties:
        userId:
          type: string
          example: "01F8MECHZX3TBDSZ7XRADM79X1"
        title:
          type: string
          minLength: 3
          maxLength: 50
          example: "Implement new feature"
        description:
          type: string
          nullable: true
          minLength: 1
          maxLength: 1000
          example: "This task is to implement the new feature for our product."
      required:
        - userId
        - title

OpenAPI Generator Gradle Pluginでコードを自動生成します。

nullable が型で表現されデフォルト引数が指定されていたり、Bean Validation を実現するコードが自動生成されて便利です。

// 自動生成されたコード
data class TaskAddRequestDto(

    @get:JsonProperty("userId", required = true) val userId: kotlin.String,

    @get:Size(min=3,max=50)
    @get:JsonProperty("title", required = true) val title: kotlin.String,

    @get:Size(min=1,max=1000)
    @get:JsonProperty("description") val description: kotlin.String? = null
    ) {
}

controller で jakarta.validation@Valid を付与することで、validation を実行するようにします。

    @PostMapping
    fun add(
        @RequestBody @Valid request: TaskAddRequestDto,
        uriBuilder: UriComponentsBuilder
    ): ResponseEntity<Void> {
        val addTask = taskUsecase.addTask(request)
        val locationUri = uriBuilder
            .path("/tasks/{id}")
            .buildAndExpand(addTask.id)
            .toUri()

        return ResponseEntity.created(locationUri).build()
    }

validation エラーとなるリクエストを実行することで、

$ curl -s 'localhost:8080/tasks' \
--header 'Content-Type: application/json' \
--data '{
  "userId": "01F8MECHZX3TBDSZ7XRADM79X1",
  "title": "aa",
  "description": "over1000 characters......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................"
}' | jq .

ログの出力内容か

{"@timestamp":"2024-11-25T13:21:44.431613Z","log.level":"WARN","process.pid":36499,"process.thread.name":"http-nio-8080-exec-8","service.name":"exception-handling","log.logger":"org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver","message":"Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0]

エラーレスポンスへの stacktrace の表示を有効にしている場合、レスポンスの trace の property から、
発生した例外が MethodArgumentNotValidException であることがわかりました。

"trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] ... (略)"

あとは先ほど同様に、@ExceptionHandler と ProblemDetail のクラスを使用することで、

    private fun FieldError.toErrorDetail() =
        ValidationErrorDetail(
            field = this.field,
            rejectedValue = this.rejectedValue,
            code = this.code,
            objectName = this.objectName,
            message = this.defaultMessage
        )

    data class ValidationErrorDetail(
        val field: String,
        val rejectedValue: Any?,
        val code: String?,
        val objectName: String,
        val message: String?
    )

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationErrors(ex: MethodArgumentNotValidException): ProblemDetail {
        logger.error("exception occurs", ex)
        val errors = ex.bindingResult.fieldErrors.map { it.toErrorDetail() }

        // HTTP response status code は `422: Unprocessable Content` でも良いと思います
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
        return ProblemDetail.forStatus(HttpStatus.BAD_REQUEST).apply {
            detail = ex.message
            setProperty("errors", errors)
        }
    }

クライアントに Problem Details に対応したエラーレスポンスを返すことができました。

$ curl -s 'localhost:8080/tasks' \
--header 'Content-Type: application/json' \
--data '{
  "userId": "01F8MECHZX3TBDSZ7XRADM79X1",
  "title": "aa",
  "description": "over1000 characters......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................"
}' | jq .
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Validation failed for argument [0] in public org.springframework.http.ResponseEntity<java.lang.Void> com.example.exceptionHandling.task.TaskController.add(kiyotakeshi.com.example.generated.openapi.model.TaskAddRequestDto,org.springframework.web.util.UriComponentsBuilder) with 2 errors: [Field error in object 'taskAddRequestDto' on field 'title': rejected value [aa]; codes [Size.taskAddRequestDto.title,Size.title,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [taskAddRequestDto.title,title]; arguments []; default message [title],50,3]; default message [size must be between 3 and 50]] [Field error in object 'taskAddRequestDto' on field 'description': rejected value [over1000 characters......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................]; codes [Size.taskAddRequestDto.description,Size.description,Size.java.lang.String,Size]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [taskAddRequestDto.description,description]; arguments []; default message [description],1000,1]; default message [size must be between 1 and 1000]] ",
  "instance": "/tasks",
  "errors": [
    {
      "field": "title",
      "rejectedValue": "aa",
      "code": "Size",
      "objectName": "taskAddRequestDto",
      "message": "size must be between 3 and 50"
    },
    {
      "field": "description",
      "rejectedValue": "over1000 characters......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................",
      "code": "Size",
      "objectName": "taskAddRequestDto",
      "message": "size must be between 1 and 1000"
    }
  ]
}

なお、今回の実装方法だとクライアントとの仕様の共有については工夫が必要です。

OpenAPI で schema を定義しそこから自動生成したコードを controller で使用するというのではなく、@RestControllerAdvice 内で Problem Details のレスポンスを組み立てているからです。

Spring REST Docs を使用する、
Problem Details 標準以外の property を追加する場合(上記の実装例だと errors)は仕様を明文化する、といった対応などが必要だと思います!

おわりに

Spring Boot で REST API を開発する際に、Problem Details に対応するための kotlin での実装の紹介でした。

エス・エム・エスが取り組んでいる「カイポケ」フルリニューアルプロジェクトでは、
技術スタックとしては、GraphQL を採用しています。
(私も日々の開発では GraphQL server の開発が100%)

またの機会に Spring for GraphQL でのエラーレスポンスの作成方法や、
kotlin-resultを使用した例外のハンドリングについても書ければと思います〜

株式会社エス・エム・エス

Discussion