前回から一度作り直してみた
チュートリアル
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) => Result。next を呼ぶ前後で
加工/短絡します。認証成功時はユーザを 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 のレンダリングを差し替え。エラーレスポンスも
ミドルウェアを通って返ります。
9. Cookie / セッション / 認証
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–§3(サーバ・ルート・型付きパラメータ)で核を掴む。
- §5 JSON ボディ で型安全の旨味を体感。
- §11 テスト で
dispatchを回しながら §4・§6–§10 を試す。 - 仕上げに §13 の CRUD 実例 を読む → routing.md で細部を確認。