http4s
Scala向けの最小限で慣用的なHTTPサービスライブラリ。cats-effectとFS2ストリーミングライブラリ基盤の純粋関数型アプローチ。composabilityを重視し、I/O管理を通じた堅牢なHTTP通信を実現。Scala.js、Scala Native対応。
GitHub概要
トピックス
スター履歴
ライブラリ
http4s
概要
http4sは「Scala向けのミニマルでイディオマティックなHTTPインターフェース」として開発された、型安全で関数型のストリーミングHTTPライブラリです。Ruby Rack、Python WSGI、Haskell WAI、Java Servletsに相当するScalaのHTTPソリューションとして位置づけられます。Cats Effect(エフェクト管理)とFS2(ストリーミング)を基盤とし、不変のリクエスト/レスポンスモデル、型クラスベースのエンティティコーデック、豊富なDSLによる関数型HTTPプログラミングを実現。クライアントとサーバー両機能を提供し、Scala.jsやScala Nativeにも対応する包括的なHTTPエコシステムです。
詳細
http4s 2025年版は関数型プログラミングパラダイムに基づくHTTP通信の決定版として確固たる地位を築いています。Cats EffectとFS2の組み合わせにより、型安全で副作用を管理しやすく、メモリ効率的なストリーミング処理を実現。EmberクライアントはHTTP/1.xとHTTP/2をサポートし、接続プールとリソース管理を自動化。不変なRequest/Responseモデルにより並行処理での安全性を保証し、型クラスベースのエンティティエンコーダー/デコーダーでJSON、XML等の自動変換をサポート。モジュラー設計により異なるバックエンド(Ember、Blaze、Netty等)の選択が可能で、豊富なミドルウェアエコシステムが企業レベルの要求に対応します。
主な特徴
- 純粋関数型アプローチ: Cats EffectによるエフェクトとFS2によるストリーミング処理
- 型安全なHTTP操作: コンパイル時の型チェックによる安全なHTTP通信
- 不変リクエスト/レスポンス: 並行処理での安全性と予測可能性
- ストリーミング対応: 大容量データの定数空間での効率的処理
- モジュラー設計: 交換可能なクライアント/サーバーバックエンド
- クロスプラットフォーム: Scala.js、Scala Native対応
メリット・デメリット
メリット
- Scalaエコシステムでの関数型プログラミングとの完全な統合
- 型安全性によるコンパイル時エラー検出とランタイム安全性
- FS2ストリーミングによる大容量データの効率的処理能力
- Cats Effectのリソース管理による接続リークの防止
- 豊富なミドルウェアによる横断的関心事の効率的実装
- ScalaJS/Native対応によるフルスタック開発の可能性
デメリット
- 関数型プログラミング(モナド、ファンクター等)の学習コストが高い
- Cats Effect、FS2等のエコシステム特有の概念習得が必要
- 他言語のHTTPライブラリと比較してコミュニティサイズが限定的
- JVMウォームアップ時間が従来のライブラリより長い場合がある
- 関数型でないScalaコードベースとの統合に追加設計が必要
- デバッグ時にエフェクトスタックの理解が困難になることがある
参考ページ
書き方の例
プロジェクト設定とインストール
// build.sbt
scalaVersion := "2.13.12" // または 3.x、2.12.x
val http4sVersion = "0.23.26"
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-client" % http4sVersion,
"org.http4s" %% "http4s-ember-server" % http4sVersion,
"org.http4s" %% "http4s-dsl" % http4sVersion,
"org.http4s" %% "http4s-circe" % http4sVersion,
"io.circe" %% "circe-generic" % "0.14.6"
)
// Scala 2.12.xの場合は以下のコメントを外す
// scalacOptions ++= Seq("-Ypartial-unification")
// スナップショットリリースの場合のみ必要
// resolvers += Resolver.sonatypeOssRepos("snapshots")
基本的なインポートとクライアント作成
import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.client.Client
// 基本的なクライアント作成パターン
object BasicHttpClient extends IOApp.Simple {
def printHello(client: Client[IO]): IO[Unit] =
client
.expect[String]("http://localhost:8080/hello/http4s")
.flatMap(IO.println)
val run: IO[Unit] = EmberClientBuilder
.default[IO]
.build
.use(client => printHello(client))
}
// REPL用の簡易クライアント(本番非推奨)
import org.http4s.client.JavaNetClientBuilder
// REPL やmdoc でのみ使用!
val httpClient: Client[IO] = JavaNetClientBuilder[IO].create
基本的なHTTPリクエスト(GET/POST/PUT/DELETE)
import cats.effect.IO
import org.http4s._
import org.http4s.circe._
import io.circe.generic.auto._
import io.circe.syntax._
// 基本的なGETリクエスト
val basicGet: IO[String] =
httpClient.expect[String](uri"https://api.example.com/users")
// クエリパラメータ付きGETリクエスト
val withParams: IO[String] = {
val uri = uri"https://api.example.com/users"
.withQueryParam("page", 1)
.withQueryParam("limit", 10)
.withQueryParam("sort", "created_at")
httpClient.expect[String](uri)
}
// カスタムヘッダー付きGETリクエスト
import org.http4s.headers._
val customHeaderRequest: IO[String] = {
val request = Request[IO](
method = Method.GET,
uri = uri"https://api.example.com/protected",
headers = Headers(
Authorization(Credentials.Token(AuthScheme.Bearer, "your-token")),
Accept(MediaType.application.json),
Header.Raw(ci"X-API-Version", "v2")
)
)
httpClient.expect[String](request)
}
// POSTリクエスト(JSON送信)
case class User(name: String, email: String, age: Int)
case class CreateUserResponse(id: Long, name: String, email: String)
implicit val createUserResponseDecoder: EntityDecoder[IO, CreateUserResponse] = jsonOf
val createUser: IO[CreateUserResponse] = {
val newUser = User("田中太郎", "[email protected]", 30)
val postRequest = Request[IO](
method = Method.POST,
uri = uri"https://api.example.com/users"
).withEntity(newUser.asJson)
.withHeaders(Authorization(Credentials.Token(AuthScheme.Bearer, "your-token")))
httpClient.expect[CreateUserResponse](postRequest)
}
// フォームデータPOST
val loginRequest: IO[String] = {
val formData = UrlForm(
"username" -> "testuser",
"password" -> "secret123"
)
val request = Request[IO](
method = Method.POST,
uri = uri"https://api.example.com/login"
).withEntity(formData)
httpClient.expect[String](request)
}
// PUTリクエスト(データ更新)
val updateUser: IO[User] = {
val updatedData = User("田中次郎", "[email protected]", 31)
val putRequest = Request[IO](
method = Method.PUT,
uri = uri"https://api.example.com/users/123"
).withEntity(updatedData.asJson)
.withHeaders(Authorization(Credentials.Token(AuthScheme.Bearer, "your-token")))
httpClient.expect[User](putRequest)
}
// DELETEリクエスト
val deleteUser: IO[Unit] = {
val deleteRequest = Request[IO](
method = Method.DELETE,
uri = uri"https://api.example.com/users/123"
).withHeaders(Authorization(Credentials.Token(AuthScheme.Bearer, "your-token")))
httpClient.successful(deleteRequest).flatMap { success =>
if (success) IO.println("ユーザー削除完了")
else IO.raiseError(new RuntimeException("削除に失敗しました"))
}
}
エラーハンドリングとリソース管理
import cats.effect._
import cats.syntax.all._
import org.http4s.client.UnexpectedStatus
// 包括的なエラーハンドリング
def safeRequest[A](request: Request[IO])(implicit decoder: EntityDecoder[IO, A]): IO[Either[String, A]] = {
httpClient.expect[A](request).attempt.map {
case Right(response) => Right(response)
case Left(_: UnexpectedStatus) => Left("HTTPステータスエラー")
case Left(_: org.http4s.client.ConnectionFailure) => Left("接続エラー")
case Left(_: java.util.concurrent.TimeoutException) => Left("タイムアウト")
case Left(ex) => Left(s"予期しないエラー: ${ex.getMessage}")
}
}
// 使用例
val safeGetUser: IO[Either[String, User]] = {
val request = Request[IO](Method.GET, uri"https://api.example.com/users/123")
safeRequest[User](request)
}
// リトライ機能付きリクエスト
import scala.concurrent.duration._
def requestWithRetry[A](
request: Request[IO],
maxRetries: Int = 3,
backoff: FiniteDuration = 1.second
)(implicit decoder: EntityDecoder[IO, A]): IO[A] = {
def attempt(retriesLeft: Int): IO[A] = {
httpClient.expect[A](request).handleErrorWith { error =>
if (retriesLeft > 0) {
IO.println(s"リクエスト失敗、${backoff.toSeconds}秒後に再試行... (残り$retriesLeft回)") >>
IO.sleep(backoff) >>
attempt(retriesLeft - 1)
} else {
IO.raiseError(error)
}
}
}
attempt(maxRetries)
}
// カスタムタイムアウト設定
val timeoutClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.withTimeout(30.seconds)
.withIdleTimeInPool(5.minutes)
.build
// 接続プール設定
val pooledClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.withMaxTotal(100)
.withMaxPerDestination(10)
.withMaxIdleTime(1.minute)
.build
クライアントミドルウェアと高度な機能
import org.http4s.client.middleware._
import org.typelevel.ci._
// ログ出力ミドルウェア
val loggedClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.build
.map(Logger.apply[IO](logHeaders = true, logBody = true))
// リダイレクト追跡ミドルウェア
val redirectClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.build
.map(FollowRedirect(maxRedirects = 3))
// リトライミドルウェア
import org.http4s.client.middleware.Retry._
val retryClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.build
.map(Retry(RetryPolicy.exponentialBackoff(maxWait = 10.seconds, maxRetry = 3)))
// カスタムミドルウェアの作成
def addUserAgentHeader(underlying: Client[IO]): Client[IO] =
Client[IO] { req =>
underlying.run(
req.withHeaders(Header.Raw(ci"User-Agent", "MyApp/1.0 (http4s Scala)"))
)
}
// 認証トークン自動更新ミドルウェア
class AuthRefreshMiddleware(
clientId: String,
clientSecret: String,
tokenEndpoint: Uri
) {
private val tokenRef: Ref[IO, Option[String]] = Ref.unsafe(None)
def apply(underlying: Client[IO]): Client[IO] = Client[IO] { req =>
for {
token <- ensureValidToken
authedRequest = req.withHeaders(
Authorization(Credentials.Token(AuthScheme.Bearer, token))
)
response <- underlying.run(authedRequest)
} yield response
}
private def ensureValidToken: IO[String] = {
tokenRef.get.flatMap {
case Some(token) => IO.pure(token)
case None => refreshToken
}
}
private def refreshToken: IO[String] = {
val authRequest = Request[IO](
method = Method.POST,
uri = tokenEndpoint
).withEntity(UrlForm(
"grant_type" -> "client_credentials",
"client_id" -> clientId,
"client_secret" -> clientSecret
))
// トークン取得とキャッシュのロジック
IO.pure("new-token") // 実装簡略化
}
}
// 組み合わせミドルウェア
val enhancedClient: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.build
.map(addUserAgentHeader)
.map(FollowRedirect(maxRedirects = 5))
.map(Logger.apply[IO](logHeaders = false, logBody = false))
並行処理とストリーミング
import cats.syntax.parallel._
import fs2.Stream
// 並列リクエスト処理
def fetchMultipleUsers(userIds: List[Long]): IO[List[User]] = {
userIds.parTraverse { id =>
httpClient.expect[User](uri"https://api.example.com/users" / id.toString)
}
}
// ストリーミングレスポンスの処理
def streamLargeDataset(client: Client[IO]): Stream[IO, String] = {
val request = Request[IO](
method = Method.GET,
uri = uri"https://api.example.com/large-dataset"
)
client.stream(request).flatMap(_.body.through(fs2.text.utf8.decode))
}
// ページネーション対応の全データ取得
case class PagedResponse[T](items: List[T], hasMore: Boolean, nextPage: Option[Int])
def fetchAllPages[T: EntityDecoder[IO, *]](
baseUri: Uri,
initialPage: Int = 1
): IO[List[T]] = {
def fetchPage(page: Int): IO[PagedResponse[T]] = {
val uri = baseUri.withQueryParam("page", page).withQueryParam("per_page", 100)
httpClient.expect[PagedResponse[T]](uri)
}
def loop(page: Int, acc: List[T]): IO[List[T]] = {
fetchPage(page).flatMap { response =>
val newAcc = acc ++ response.items
if (response.hasMore) {
loop(page + 1, newAcc)
} else {
IO.pure(newAcc)
}
}
}
loop(initialPage, List.empty)
}
// Server-Sent Events (SSE) の処理
import org.http4s.dom.WebSocketEvent
def subscribeToEvents(client: Client[IO]): Stream[IO, String] = {
val request = Request[IO](
method = Method.GET,
uri = uri"https://api.example.com/events",
headers = Headers(Accept(MediaType.text.`event-stream`))
)
client.stream(request)
.flatMap(_.body.through(fs2.text.utf8.decode))
.through(fs2.text.lines)
.filter(_.startsWith("data: "))
.map(_.drop(6)) // "data: " を除去
}
実用的なアプリケーション例
import cats.effect.{ExitCode, IOApp}
// 包括的なAPIクライアントアプリケーション
object ApiClientApp extends IOApp {
case class Config(
baseUrl: String,
apiKey: String,
timeout: FiniteDuration,
maxRetries: Int
)
class ApiClient(client: Client[IO], config: Config) {
private def authenticatedRequest(request: Request[IO]): Request[IO] =
request.withHeaders(
Authorization(Credentials.Token(AuthScheme.Bearer, config.apiKey)),
Header.Raw(ci"Accept", "application/json"),
Header.Raw(ci"User-Agent", "http4s-client/1.0")
)
def getUsers(page: Int = 1, limit: Int = 50): IO[List[User]] = {
val uri = Uri.unsafeFromString(config.baseUrl) / "users"
.withQueryParam("page", page)
.withQueryParam("limit", limit)
val request = authenticatedRequest(Request[IO](Method.GET, uri))
client.expect[List[User]](request)
}
def createUser(user: User): IO[User] = {
val uri = Uri.unsafeFromString(config.baseUrl) / "users"
val request = authenticatedRequest(
Request[IO](Method.POST, uri).withEntity(user.asJson)
)
client.expect[User](request)
}
def uploadFile(filePath: String, contentType: MediaType): IO[String] = {
import java.nio.file.Paths
val file = Paths.get(filePath)
val multipart = Multipart[IO](
Vector(
Part.fileData[IO]("file", file, `Content-Type`(contentType))
)
)
val uri = Uri.unsafeFromString(config.baseUrl) / "upload"
val request = authenticatedRequest(
Request[IO](Method.POST, uri).withEntity(multipart)
)
client.expect[String](request)
}
}
def run(args: List[String]): IO[ExitCode] = {
val config = Config(
baseUrl = "https://api.example.com",
apiKey = sys.env.getOrElse("API_KEY", "default-key"),
timeout = 30.seconds,
maxRetries = 3
)
EmberClientBuilder
.default[IO]
.withTimeout(config.timeout)
.build
.use { baseClient =>
val enhancedClient = Logger.apply[IO](logHeaders = true, logBody = false)(
FollowRedirect(maxRedirects = 3)(baseClient)
)
val apiClient = new ApiClient(enhancedClient, config)
for {
users <- apiClient.getUsers(page = 1, limit = 10)
_ <- IO.println(s"取得したユーザー数: ${users.length}")
newUser = User("新規ユーザー", "[email protected]", 25)
created <- apiClient.createUser(newUser)
_ <- IO.println(s"作成されたユーザー: $created")
} yield ExitCode.Success
}
}
}