http4sのHTTP Serverを使ってみる

はじめに

FS2に依存するライブラリはいくつかありますが、http4sというHTTPサーバとクライアントを用意してくれているライブラリがあり、ちょっと興味が湧いたので触ってみようかと思います。
http4sはFS2(と、もちろんCats Effects)をベースにしているので、FunctinnalでStreamingでFunctionalというのが特徴みたいです。

やってみる

環境

$ scala --version
Scala code runner version 2.13.6 -- Copyright 2002-2021, LAMP/EPFL and Lightbend, Inc.

$ sbt --version
[info] 1.2.7
sbt script version: 1.5.2

プロジェクトの作成

まずはベースとなるプロジェクトを作成します。
 

$ sbt new sbt/scala-seed.g8
[info] welcome to sbt 1.5.2 (Oracle Corporation Java 1.8.0_292)
[info] loading global plugins from /home/yuya-hirooka/.sbt/1.0/plugins
[info] set current project to new (in build file:/tmp/sbt_adb8af4/new/)

A minimal Scala project. 

name [Scala Seed Project]: http4s-example

作成されたプロジェクトのbuild.sbtに以下の依存を追加します。

libraryDependencies += "org.http4s" %% "http4s-dsl" % "0.21.23",
libraryDependencies += "org.http4s" %% "http4s-blaze-server" % "0.21.23",
libraryDependencies += "org.http4s" %% "http4s-blaze-client" % "0.21.23",

今回はhttp4sの現在のstableバージョンである0.21.x系を利用しようと思います。

名前を受け取って挨拶を返すサーバを作成する

最初にhttp4s-blaze-serverを使って、サーバーの方を作成します。
最終的には名前をJsonで受け取って挨拶を返すサーバを作成しようと思います。

helloの文字列を返す

まずは、単純なGETリクエストに対して"hello"という文字列を返すだけのサーバを作ります。
そのためにはRouterBlazeServerBuilderを使います。
Routerを使ってルートの定義を作成します。

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits.http4sKleisliResponseSyntaxOptionT
import org.http4s.server.Router

object SampleRouters {

  def helloHandler() = IO("hello")

  val helloRouter = HttpRoutes.of[IO] {
    case GET -> Root / "hello" => helloHandler().flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter).orNotFound

}

HttpRoutes http4s-dsl,を使ってルータを定義します。
みてすぐわかるかも知れませんが、パターンマッチングでGETと/helloのパスにマッチした場合helloHandr()を呼び出し、その戻り値であるIO("")をflatMapして取り出しOk()レスポンスで返すRouterを定義しています。
1番最後の行ではRouter objectを作成策定していますが、ここでは定義したルータのルートに当たるパスを指定することができます。

Routeの作成はできたので、次にアプリケーションのエントリーポイントとなるobjectを作ります。

import cats.effect.{ExitCode, IO, IOApp}
import org.http4s.server.blaze.BlazeServerBuilder

import scala.concurrent.ExecutionContext.Implicits.global


object AppStarter extends IOApp {

  override def run(args: List[String]): IO[ExitCode] =
    BlazeServerBuilder[IO](global)
      .bindHttp(8081, "localhost")
      .withHttpApp(SampleRouters.createApp)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)

}

http4sは様々なバックエンドをサポートしているようですが(サポートはここを確認してください。
BlazeServerBuilderを使って、 もし、IOApp以外の場所でBuilderを使いたい場合は以下のimplicitを定義する必要があります。

implicit val cs: ContextShift[IO] = IO.contextShift(global)
implicit val timer: Timer[IO] = IO.timer(global)

アプリケーションを実行するし、cURLでアプリケーションにリクエストを送ってみます。

$ curl localhost:8081/greeting -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /greeting HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
< Date: Fri, 11 Jun 2021 14:20:49 GMT
< Content-Length: 5
< 
* Connection #0 to host localhost left intact
hello

うまく起動できてるみたいですね。

パスパラーメータで値を受け取る

それでは次に、パスパラメータ名前を受け取ってその名前に対して挨拶を返す用にしてみます。
新しく、Routerを作成します。

object SampleRouters {

  def helloHandler() = IO("hello")

  val helloRouter = HttpRoutes.of[IO] {
    case GET -> Root / "hello" => helloHandler().flatMap(Ok(_))
  }

  def greetingSomeone(name: String) = IO(s"Hello, $name")

  val greetingRouter = HttpRoutes.of[IO] {
    case GET -> Root / "greeting" / name => helloHandler().flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter, "/v1" -> greetingRouter).orNotFound

}

ここではgreetingRouterを新たに定義してます。
DSLのなかでパスの位置い変数を置くことで、パスパラメータを束縛することができます。
また、上記のようにRouter object作成時には複数のRouterを登録することができます。

cURLでリクエストを送ってみます。

$ curl localhost:8081/v1/greeting/Moheji -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /v1/greeting/Moheji HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
< Date: Fri, 11 Jun 2021 14:46:01 GMT
< Content-Length: 13
< 
* Connection #0 to host localhost left intact
Hello, Moheji

Jsonで値を受け取る。Jsonの値を返す

それでは最後にJsonの値を扱うようなPathを追加してみようと思います。
まずですが、http4sでJsonを扱うためには以下の依存を追加する必要があります。

libraryDependencies += "org.http4s" %% "http4s-circe" % "0.21.23"
libraryDependencies += "io.circe" %% "circe-generic" % "0.13.0"

circe-genericの依存はオプショナルっぽいですが、JsonScalaのクラスとの変換を知れてくれるみたいなので追加しておきます。

それでは追加したhttp4s-circecirce-genericを使ってgreetingRouterに新たなRouteを書き加えてみます。

import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
//他のImportは省略


object SampleRouters {
  // 他の実証は諸略  

  case class Name(name: String)

  case class Greeting(hello: String)

  val greetingRouter = HttpRoutes.of[IO] {
    case req@POST -> Root / "greeting" => for {
      n <- req.as[Name]
      resp <- Created(Greeting(n.name).asJson)
    } yield resp
    case GET -> Root / "greeting" / name => greetingSomeone(name).flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter, "/v1" -> greetingRouter).orNotFound

}

POSTリクエストで名前を受け取るためにDSLを使います。
req@のように記述刷ることでreqにリクエストの情報をバインドできるみたいです。
リクエストを受け取るためのcase

// リクエスト用のcase class
case class Name(name: String)

// レスポンス用のcase class
case class Greeting(hello: String)

本来はこれらに対してエンコーダーデコーダーを作ってimplicitとして定義刷る必要があるようですが。 以下の2つをインポートしていればそれぞれが自動でimplicitされるみたいです。

import io.circe.generic.auto._
import org.http4s.circe.CirceEntityCodec._

それではリクエストを送ってみましょう。

$ curl localhost:8081/v1/greeting -d '{"name":"Moheji"}' -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> POST /v1/greeting HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Length: 17
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 17 out of 17 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Type: application/json
< Date: Fri, 11 Jun 2021 15:51:56 GMT
< Content-Length: 18
< 
* Connection #0 to host localhost left intact
{"hello":"Moheji"}

これでちょっと触った程度ですが一応、サーバができましたね。