magnoliakmemo

雑にメモを残すためのブログです。Amazonのアソシエイトとして、運営のMagnolia.Kは適格販売により収入を得ています。

hashi、というフレームワークを作ってみた

前回から一度作り直してみた


チュートリアル

Scala 3 製の、型安全な Rails 風 HTTP ルーティングライブラリ hashi を、ゼロから順に 積み上げて学ぶチュートリアルです。核は ルートのパス・型・ハンドラ引数がコンパイル時に 検証される こと。細部の仕様は routing.md を参照してください。

import tech.magnolia.hashi.*
import tech.magnolia.hashi.Result.*

1. 最小のサーバ

@main def app(): Unit =
  val routes = Seq(
    get("/hello") { _ => Ok("world") }
  )
  val server = Server.start(8080, routes)   // Jetty 12 + 仮想スレッド
  server.join()                             // 終了待ち(.stop() で停止)

get(...) は文字列補間子 route"GET /hello"完全に等価(同じマクロ)。動詞関数 get / post / put / patch / delete / head / options が使えます。

2. 型付きパスパラメータ(ここが肝)

get("/users/:id[Int]"): (req, id: Int) =>      // id は Int として渡る
  Ok(s"user $id")
  • :id[Int][Int]型注釈 として書くと、ハンドラの第 2 引数以降に その型 で 渡ります。型が合わなければ コンパイルエラー
  • 変換できないリクエスト(/users/abc)は「マッチしない」→ 404(例外は飛ばない)。
  • 組み込み型: String(既定)/ Int / Long / Boolean / UUID
  • ハンドラの第 1 引数は常に Request

パスの記法いろいろ:

get("/u/:name")                  { (_, name: String)               => Ok(name) }        // 既定 String
get("/files/*path")              { (_, p: String)                  => Ok(p) }           // splat(残り全部)
get("/users(/:id[Int])")         { (_, id: Option[Int])            => Ok(id.toString) } // 省略可能 → Option
get("/posts/:id[Int](.:format)") { (_, id: Int, f: Option[String]) => Ok(s"$id.$f") }   // 拡張子
get("""/items/:id{\d{4,}}""")    { (_, id: String)                 => Ok(id) }          // 正規表現制約

ユーザー定義型は完全修飾名 + given Conv[T]:

given Conv[java.time.LocalDate] = s => scala.util.Try(java.time.LocalDate.parse(s)).toOption
get("/events/:on[java.time.LocalDate]") { (_, d: java.time.LocalDate) => Ok(d.toString) }

3. レスポンス(Result

Ok("text")                       // 200
Created("made")                  // 201
NoContent                        // 204
Redirect("/login")               // 302(permanent = true で 301)
BadRequest("bad")                // 400
NotFound("nope")                 // 404
Json(user, status = 201)         // JSON(§5)
Stream(contentType = "text/csv") { out => out.write(...) }  // 逐次配信

装飾(どの Result にも付与可):

Ok("hi").withHeader("X-Foo", "bar").withCookie(Cookie("sid", "abc", httpOnly = true))

4. クエリパラメータ(型付き・例外なし)

get("/search"): req =>
  val page = req.queryParamOr[Int]("page", 1)   // ?page=2 → 2、無ければ 1
  val tags = req.queryParams[String]("tag")     // ?tag=a&tag=b → Seq("a","b")
  Ok(s"$page ${tags.mkString(",")}")

queryParam[T]Option)/ queryParams[T]Seq)/ queryParamOr[T]。フォームボディも 対称 API(formParam 等)。どちらも未トラスト入力として 例外を投げず 「不在」に倒します。

5. JSON ボディ(型安全に受け取る)

import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker

final case class CreateUser(name: String, age: Int = 0)
given JsonValueCodec[CreateUser] = JsonCodecMaker.make

post("/users").body[CreateUser]: (req, u) =>   // u は CreateUser
  Json(u.name, status = 201)
  • .body[T] でボディを ハンドラ引数として型付きで受け取る(パスパラメータの後ろに追加)。
  • given JsonValueCodec[T] が無ければコンパイルエラー。不正 JSON は自動で 400
  • 出力は Json(value)(要 codec)。手動派は req.bodyAs[T] / req.bodyAsOption[T]

6. ルートの構成・逆引き

scope("/api/v1")(                          // プレフィックス付与
  get("/users/:id[Int]") { (_, id: Int) => Ok(id.toString) }
)

resources[Int]("posts")(                   // RESTful 一括生成
  index = Some(_ => Ok("all")),
  show  = Some((_, id) => Ok(s"post $id"))
)

val r = get("/users/:id[Int]") { (_, id: Int) => Ok("") }
r.url(Tuple1(42))                          // "/users/42" ← 型安全な逆引き

val named = NamedRoutes("user" -> r)
named.url("user", Tuple1(42))              // 名前で逆引き(実行時チェック)

7. ミドルウェア

val mw = Middleware.chain(
  Middlewares.logging(),                   // "GET /x -> 200 1.2 ms"
  Middlewares.requestId(),                 // X-Request-Id 付与
  Middlewares.cors(origin = "*"),
  Middlewares.basicAuth() { (u, p) => u == "ann" && p == "s3cret" }  // 401 で短絡
)
Server.start(8080, routes, middleware = mw)

ミドルウェアは素の関数 (Request, Request => Result) => Resultnext を呼ぶ前後で 加工/短絡します。認証成功時はユーザを X-Auth-User ヘッダで下流へ渡します。

8. エラーハンドリング

val errors = ErrorHandler(
  onException   = { case _: NoSuchElementException => NotFound("gone") },
  onNotFound    = req => Json(...),                 // 404 を JSON で
  onServerError = (_, _) => InternalServerError()   // 既定で例外メッセージは漏らさない
)
Server.start(8080, routes, errorHandler = errors)

例外 → Result マッピングと、404 / 405 / 500 のレンダリングを差し替え。エラーレスポンスも ミドルウェアを通って返ります。

given Signer = Signer(sys.env("APP_SECRET"))          // HMAC-SHA256
given SessionConfig = SessionConfig()                  // Secure + HttpOnly + SameSite=Lax

post("/login").body[Creds]: (_, c) =>
  Redirect("/").withSession(Map("user" -> c.name))     // 署名付きセッション
get("/me") { req => Ok(req.session.getOrElse("user", "guest")) }      // 検証済みで読む
post("/logout"){ _ => Redirect("/").clearSession }

署名は 改ざん検知(暗号化ではない)。低レベルに withSignedCookie / signedCookie も。

10. 静的ファイル配信

import java.nio.file.Paths
val pub = Paths.get("public")
get("/assets/*path") { (req, p: String) => StaticFiles.serve(pub, p, req) }
get("/")             { req => StaticFiles.serveFile(pub.resolve("index.html"), req) }

Content-Type 推定・ETag/304・Range/206・パストラバーサル防御 込み。

11. テスト(サーバ不要)

import tech.magnolia.hashi.testkit.*

val routes = Seq(get("/users/:id[Int]") { (_, id: Int) => Ok(s"user $id") })

dispatch(routes, TestRequest.get("/users/3")).bodyText         // "user 3"(先勝ち・404/405 もサーバと同じ)
dispatch(routes, TestRequest.post("/x").json(CreateUser("a"))) // JSON ボディ
r.runIfMatches(TestRequest.get("/users/42"))                   // 1 ルートをパスマッチ込みで
r.call(TestRequest.get("/_"), 42)                              // パラメータを直接渡してハンドラだけ

詳しくは testing.md

12. フロント連携(型を共有)

// Scala の case class → TypeScript 型
TsGen.module[(Task, TaskInput)]      // export interface Task { ... }

// 型付き fetch クライアント生成
import tech.magnolia.hashi.tsclient.TsClient, TsClient.op
TsClient.render(Seq(
  op("getTask", "GET", "/tasks/{id}").path[Long]("id").returns[Task]
))                                   // export async function getTask(id: number): Promise<Task>

// OpenAPI 3
import tech.magnolia.hashi.openapi.*, OpenApi.*
OpenApi.document(Info("API", "1.0"), Seq(get("/tasks").ok[Seq[Task]]))

13. 全部入りの実例

examples/crud/ が H2 + JSON CRUD + 静的フロント + 型生成のフルサンプルです。

sbt "runMain tech.magnolia.hashi.examples.crud.runTaskApp"   # http://localhost:8080/
sbt "runMain tech.magnolia.hashi.examples.crud.genClient"    # TS クライアント生成
sbt "runMain tech.magnolia.hashi.examples.crud.genOpenApi"   # OpenAPI 生成

学習の順番(おすすめ)

  1. §1–§3(サーバ・ルート・型付きパラメータ)で核を掴む。
  2. §5 JSON ボディ で型安全の旨味を体感。
  3. §11 テストdispatch を回しながら §4・§6–§10 を試す。
  4. 仕上げに §13 の CRUD 実例 を読む → routing.md で細部を確認。