Blog
sprayのRejectionHandlerで遊んでみた
こんにちは。Dynalystのkayanoです。
Dynalystでは、管理画面などで利用するAPIにsprayを使っています。
今日は、そのAPIの開発で
RejectionHandlerを使ってエラーのハンドリングをしていて便利だったので、ちょっと遊んでみました。
sprayのRejection
sprayでは、リクエストのpathや、paramaterなどがroutingに合わなかった場合にRejectionが呼ばれます。
例えば、CookieDirectivesの実装では、Cookieが無かった場合に、MissingCookieRejectionがrejectされます。
1 2 3 4 5 6 |
/** * Extracts an HttpCookie with the given name. If the cookie is not present the * request is rejected with a respective [[spray.routing.MissingCookieRejection]]. */ def cookie(name: String): Directive1[HttpCookie] = headerValue(findCookie(name)) | reject(MissingCookieRejection(name)) |
routingでcompleteされなかったリクエストは、rejectされたRejectionの型によってレスポンスが決まります。
そのレスポンスをハンドリングしているのがRejectionHandlerです。
defaultのRejectionHandler
sprayでrejectされると、特に何も設定していなければ、defaultのRejectionHandlerが働きます。
※ defaultの内容
例えばこんなActorを用意したとします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class MyServiceActor extends Actor with MyService { def actorRefFactory = context def receive = runRoute(route) } trait MyService extends HttpService { val route = path("hoge") { get { parameter('id.as[Int]){ id => respondWithMediaType(`application/json`) { complete { s"""{ "success": id is $id. }""" } } } } } } |
sbtで起動してcurlでリクエストしてみます。
parameterが正常な場合
1 2 3 4 5 6 7 8 9 |
$ curl -X GET -v http://localhost:8080/hoge?id=1 < HTTP/1.1 200 OK * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 06 Apr 2015 09:33:07 GMT < Content-Type: application/json; charset=UTF-8 < Content-Length: 23 * Connection #0 to host localhost left intact { "success": id is 1. } |
parameterが不正な場合
1 2 3 4 5 6 7 8 9 10 11 |
$ curl -X GET -v http://localhost:8080/hoge?id=xxx < HTTP/1.1 400 Bad Request * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 06 Apr 2015 09:36:08 GMT < Content-Type: text/plain; charset=UTF-8 < Content-Length: 81 < The query parameter 'id' was malformed: * Connection #0 to host localhost left intact 'xxx' is not a valid 32-bit integer value |
sprayでparameterの不正でrejectionされた場合は、MalformedQueryParamRejectionというRejectionでrejectされます。rejestされたリクエストがどのように処理されるかはRejectionHandlerによって決まります。
Defaultで設定されているRejectionHandlerのMalformedQueryParamRejectionでは
1 2 |
case MalformedQueryParamRejection(name, msg, _) :: _ ⇒ complete(BadRequest, "The query parameter '" + name + "' was malformed:\n" + msg) |
この様に設定されているため、上記の様にテストするとtextでエラーメッセージが飛ぶのですね。
RejectionHandlerをカスタマイズしてみる
さて、基本的にAPIのIFは合わせたいので、jsonを扱っているAPIであれば、利用する側のことも考えてエラー時もJsonを返したいところです。さっきの例でrouteに普通に追加して処理することもできますが
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
trait MyService extends HttpService { val route = path("hoge") { get { parameter('id.as[Int]) { id => respondWithMediaType(`application/json`) { complete { s"""{ "success": id is $id. }"""} } } ~ parameter('id) { id => complete(BadRequest, s"""{"error":{"message":"The query parameter id was malformed":"$id"}}""") } } } } |
見辛いし、なによりメンテナンス性に欠けます。やりたくない。テンション下がる。。。
これをRejectionHandlerで処理してみます。
1 2 3 4 |
implicit val myRejectionHandler = RejectionHandler { case MalformedQueryParamRejection(name, msg, _) :: _ => respondWithMediaType(`application/json`)(complete(BadRequest, s"""{"error":{"message":"The query parameter $name was malformed: $msg"}}""")) } |
このようにRejectionHandlerをimplicitで定義するだけです。簡単ですね。
※ sprayでのjsonの扱いはもっとイケてる方法がありますが、それはまた次の機会にします。
実行してみます
1 2 3 4 5 6 7 8 9 |
$ curl -X GET -v http://localhost:8080/hoge?id=xxxx < HTTP/1.1 400 Bad Request * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 04 May 2015 07:47:29 GMT < Content-Type: application/json; charset=UTF-8 < Content-Length: 105 * Connection #0 to host localhost left intact {"error":{"message":"The query parameter id was malformed: 'xxxx' is not a valid 32-bit integer value"}} |
エラーがjsonで返る様になりました
Rejectionを作ってみる
もう少しカスタマイズしてみます。今度は自分でRejectionを作ってみます。Cookieの値に関するRejectionがみつからなかったので、作ってみました。
1 |
case class MyCookieValidationRejection(ck: HttpCookie) extends Rejection |
ベタな感じですが、”mycookie”というcookieを取得し、cookieの値が”my”で始まらなかったらrejectionを飛ばすしてみます。
1 2 3 4 5 6 7 8 9 10 11 |
val route = path("hoge") { get { cookie("mycookie") { mycookie => if (mycookie.content.startsWith("my")) respondWithMediaType(`application/json`) { complete { s"""{"success": my cookie is ${mycookie.content}"}""" } } else reject(MyCookieValidationRejection(mycookie)) } } } |
独自で作ったRejectionを受け取る様にHandlerを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
implicit val myRejectionHandler = RejectionHandler { case MalformedQueryParamRejection(name, msg, _) :: _ => respondWithMediaType(`application/json`)( complete(BadRequest, s"""{"error":{"message":"The query parameter $name was malformed":"$msg"}}""") ) case MyCookieValidationRejection(ck) :: _ => respondWithMediaType(`application/json`)( complete(BadRequest, s"""{"error":{"message":"My cookie value was malformed";"cookie":{"name":"${ck.name}";"value": "${ck.content}"}}""") ) case MissingCookieRejection(cookieName) :: _ ⇒ respondWithMediaType(`application/json`)( complete(BadRequest, s"""{"error":{"message":"Request is missing required cookie: $cookieName"}}""") ) } |
cookie自体が無かった時に起きるMissingCookieRejectionも忘れずに定義します。
実行してみます。
成功パターン
1 2 3 4 5 6 7 8 9 10 |
$ curl -X GET -H "Cookie: mycookie=my123456" -v http://localhost:8080/hoge < HTTP/1.1 200 OK * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 04 May 2015 06:36:24 GMT < Content-Type: application/json; charset=UTF-8 < Content-Length: 37 < * Connection #0 to host localhost left intact { "success": my cookie is my123456" } |
cookieのvalueが不正なパターン
1 2 3 4 5 6 7 8 9 10 |
$ curl -X GET -H "Cookie: mycookie=xxxxx" -v http://localhost:8080/hoge < HTTP/1.1 400 Bad Request * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 04 May 2015 07:19:05 GMT < Content-Type: application/json; charset=UTF-8 < Content-Length: 93 < * Connection #0 to host localhost left intact {"error":{"message":"My cookie value was malformed";"cookie":{"name":"mycookie";"value": "xxxxx"}} |
cookieがないパターン
1 2 3 4 5 6 7 8 9 10 |
$ curl -H GET -v http://localhost:8080/hoge < HTTP/1.1 400 Bad Request * Server spray-can/1.3.2 is not blacklisted < Server: spray-can/1.3.2 < Date: Mon, 04 May 2015 07:20:19 GMT < Content-Type: application/json; charset=UTF-8 < Content-Length: 76 < * Connection #0 to host localhost left intact {"error":{"message":"Request is missing required cookie":"mycookie"}} |
Rejectionの使いどころ
多機能なAPIを開発していると、複数のrouteを結合して利用する事が多くなります。元々私がRejectionに触れたは、後続のrouteで処理したい内容が、その前のrouteの定義内でエラーとしてcompleteしてしまうと、後続サービスが機能しなくなってしまう問題を解決させたいのが発端でした。
1 2 3 4 5 6 7 8 9 10 |
get { parameter('id') { id => // id が success の時だけ実行させたい case "success" => complete(OK) case _ => complete(BadRequest) // ① } ~ parameter('id) { id => // id が success 以外の時にも実行させたいけど、① でcompleteしてしまっているのでこちらの処理には入らない } } |
他の機能に影響しない様にするため、また全体のIFを統一化のためにも、基本的には異常時の処理は、completeではなくrejectを行った方がよいと思いました。あと、定義されてるRejectionはいろんな処理があって、眺めてて面白かったので、Spray でRestAPIを作る際などはぜひ見てみてください。
※ 今回試してみたコードはこちらです。
https://github.com/kayano-shoko/spray-test
それでは、また。
Author