前回から一度作り直してみた
チュートリアル
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)
server.join()
get(...) は文字列補間子 route"GET /hello" と 完全に等価(同じマクロ)。動詞関数
get / post / put / patch / delete / head / options が使えます。
2. 型付きパスパラメータ(ここが肝)
get("/users/:id[Int]"): (req, 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) }
get("/files/*path") { (_, p: String) => Ok(p) }
get("/users(/:id[Int])") { (_, id: Option[Int]) => Ok(id.toString) }
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")
Created("made")
NoContent
Redirect("/login")
BadRequest("bad")
NotFound("nope")
Json(user, status = 201)
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)
val tags = req.queryParams[String]("tag")
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) =>
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")(
index = Some(_ => Ok("all")),
show = Some((_, id) => Ok(s"post $id"))
)
val r = get("/users/:id[Int]") { (_, id: Int) => Ok("") }
r.url(Tuple1(42))
val named = NamedRoutes("user" -> r)
named.url("user", Tuple1(42))
7. ミドルウェア
val mw = Middleware.chain(
Middlewares.logging(),
Middlewares.requestId(),
Middlewares.cors(origin = "*"),
Middlewares.basicAuth() { (u, p) => u == "ann" && p == "s3cret" }
)
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(...),
onServerError = (_, _) => InternalServerError()
)
Server.start(8080, routes, errorHandler = errors)
例外 → Result マッピングと、404 / 405 / 500 のレンダリングを差し替え。エラーレスポンスも
ミドルウェアを通って返ります。
9. Cookie / セッション / 認証
given Signer = Signer(sys.env("APP_SECRET"))
given SessionConfig = SessionConfig()
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
dispatch(routes, TestRequest.post("/x").json(CreateUser("a")))
r.runIfMatches(TestRequest.get("/users/42"))
r.call(TestRequest.get("/_"), 42)
詳しくは testing.md。
12. フロント連携(型を共有)
TsGen.module[(Task, TaskInput)]
import tech.magnolia.hashi.tsclient.TsClient, TsClient.op
TsClient.render(Seq(
op("getTask", "GET", "/tasks/{id}").path[Long]("id").returns[Task]
))
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 で細部を確認。