Spring bootを仕事で使っていて、
server.error.whitelabel.enabled
を指定すると具体的にどうなるのかとか、
誰がどうレスポンスを生成しているのかとか、
この辺イマイチ浅い理解をしていてちゃんと説明出来そうにないのでちゃんとデバッガを動かしながら見てみた
読んでいたところのコードの各種バージョン
- SpringBoot(
2.7.6
) - Tomcat(
10.1.4
) - SpringWebMvc(
5.3.24
)
理解する上で併せて読むと良い公式ブログ spring.io
server.error.whitelabel.enabled
true
を指定した場合(デフォルト)
WhitelabelErrorView
のConfiguration
が有効になる(defaultErrorView
のBeanが登録される)
ErrorMvcAutoConfiguration#WhitelabelErrorViewConfiguration
この状態でaccept: text/html
のリクエストを投げた場合
Viewは↓で生成されたものが返される
ErrorMvcAutoConfiguration#StaticView
<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat Dec 10 16:54:07 JST 2022</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>
この状態でapplication/json
のリクエストを投げた場合
{"timestamp":"2022-12-10T07:59:51.927+00:00","status":404,"error":"Not Found","path":"/base/test"}
false
を指定した場合
この状態でaccept: text/html
のリクエストを投げた場合
↓でTomcat側で生成されたものが返される(Tomcatを使っている場合)
ErrorReportValve#report
<!doctype html><html lang="en"><head><title>HTTP Status 404 – Not Found</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 404 – Not Found</h1></body></html>
この状態でapplication/json
のリクエストを投げた場合
true
の時と同じものが返される
{"timestamp":"2022-12-10T07:57:47.967+00:00","status":404,"error":"Not Found","path":"/base/test"}
上の部分のレスポンス返すまでどういう挙動をしているのかが気になったので、ErrorController周りを見ることにした
ErrorController周り
ErrorController
の Bean
が無ければ BasicErrorController
が登録される
ErrorMvcAutoConfiguration#basicErrorController
ErrorController
を実装した抽象クラスのAbstractErrorController
AbstractErrorController
を継承したBasicErrorController
BasicErrorControllerの中身
html
とその他のAcceptHeader
で呼び分けているところ
AcceptHeader
にtext/html
があるならBasicErrorController#errorHTML
それ以外の場合はBasicErrorController#error
HttpMediaTypeNotAcceptableException
が起きた場合にエラーステータスをそのまま返すだけのExceptionHandler
も登録している
大体何をやっているかがわかったので、ErrorControllerを実装してカスタムなErrorControllerを書いてみた
ErrorControllerを実装したカスタムなErrorController
実装自体は少し BasicErrorController
をコピーしつつjavaからKotlinに直している
errorのメソッドは特に書くことはなくて、
mediaTypeNotAcceptable
のメソッドの方は、ResponseEntity<Any>
で返すと最終的にDefaultHandlerExceptionResolver
に捕まってレスポンスボディがnull
になるので(後述)、ObjectMapper使ってResponseEntity<String>
で返させている
そして、仮にval status = getStatus(request)
でstatus
を受け取って渡さなかった場合、HttpMediaTypeNotAcceptable
のレスポンスコードとして用意されている406
が返される
text/html
だろうがなんだろうがJSON
的なレスポンスを強制的に返したかったのでやっているが、本来は406
を返しつつ受け取れるメディアタイプを返す実装の方が正しいとは思う
(ただまあ404なエンドポイントに投げられて、統一したレスポンスボディ返しつつ406
ではなく404
を返したいケースはある気がする)
import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.boot.web.servlet.error.ErrorController import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.HttpMediaTypeNotAcceptableException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import javax.servlet.RequestDispatcher import javax.servlet.http.HttpServletRequest @RestController class ErrorController( private val objectMapper: ObjectMapper, ): ErrorController { @RequestMapping("/error") fun error(request: HttpServletRequest): ResponseEntity<Any> { val status = getStatus(request) val body = mapOf("message" to status.reasonPhrase, "status" to status.value()) return ResponseEntity(body, status) } @ExceptionHandler(HttpMediaTypeNotAcceptableException::class) fun mediaTypeNotAcceptable(request: HttpServletRequest): ResponseEntity<String> { val status = getStatus(request) val body = mapOf("message" to status.reasonPhrase, "status" to status.value()) return ResponseEntity(objectMapper.writeValueAsString(body), status) } private fun getStatus(request: HttpServletRequest): HttpStatus { val statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE) as Int? ?: return HttpStatus.INTERNAL_SERVER_ERROR return try { HttpStatus.valueOf(statusCode) } catch (ex: Exception) { HttpStatus.INTERNAL_SERVER_ERROR } } }
accept application/json
curl -XGET http://localhost:8080/404 -H 'accept: application/json' -sS -v | jq * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /404 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.81.0 > accept: application/json > * Mark bundle as not supporting multiuse < HTTP/1.1 404 < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < Content-Type: application/json < Transfer-Encoding: chunked < Date: Sat, 10 Dec 2022 16:39:19 GMT < { [47 bytes data] * Connection #0 to host localhost left intact { "message": "Not Found", "status": 404 }
accept text/html
curl -XGET http://localhost:8080/404 -H 'accept: text/html' -sS -v | jq * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /404 HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.81.0 > accept: text/html > * Mark bundle as not supporting multiuse < HTTP/1.1 404 < Vary: Origin < Vary: Access-Control-Request-Method < Vary: Access-Control-Request-Headers < Content-Type: text/html;charset=UTF-8 < Content-Length: 36 < Date: Sat, 10 Dec 2022 16:39:42 GMT < { [36 bytes data] * Connection #0 to host localhost left intact { "message": "Not Found", "status": 404 }
accept: text/html
の場合にResponseEntity<Any>
を返しても最終的にDefaultHandlerExceptionResolver
に捕まってResponseのBody
がnull
のステータス406
で返ってくる部分
上のErrorControllerでエラーレスポンスを返した際に、
AbstractMessageConverterMethodProcessor#writeWithMessageConverters
辺りでmediaTypes
がapplication/json
, application/*+json
にマッチしない且つbody
がnull
ではないので、
HttpMediaTypeNotAcceptableException
が投げられることになる
で、その後にDefaultHandlerExceptionResolver
側で捕捉されて、406
,null
で返されることになる
DefaultHandlerExceptionResolver#doResolveException
DefaultHandlerExceptionResolver#handleHttpMediaTypeNotAcceptable
実際にFramework側のコードを読みながら手を動かすと理解が進むので今後も暇を見つけてやっていきたい