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 で細部を確認。

Hashi — Scala 3 のマクロで「型安全なルーティング」を素直に書けるWebフレームワーク

ここ2週間くらい完全にClaude Codeだけで作っているウェブアプリケーションフレームワーク

Jettyの安定性と、Scala3の型機能をフルに使った型安全なルート定義ができるところが特徴

まだGitHubにはアップしていないけど、こんな構想です


TL;DR

  • Hashi は Scala 3 + Jetty 12 で書かれた軽量 Web フレームワークです
  • パスを p"/users/{id:Int}" と書くと、id が Int だとコンパイル時に確定し、ハンドラの引数型もそこから推論されます
  • パスパラメータは 個数無制限・型は String / Int / Long / Double / Boolean
  • request を引数なしで参照できる DSL、関数合成によるミドルウェア、型クラスによるレスポンス直列化
  • JSON(uPickle)、WebSocket、HTTP/2、静的配信、CORS、W3C 分散トレーシング、テスト用クライアントを同梱

まず触ってみる

  import tech.magnolia.hashi.*

  @main def hello(): Unit =
    hashi.serve(port = 8080):
      get(p"/"):
        Ok("Hello, Hashi!")

      get(p"/users/{id:Int}"): (id: Int) =>
        Ok(s"User $id")

/users/42 は User 42 を返し、/users/abc は 自動的に 400 を返します(Int への変換に失敗するため、ハンドラには到達しません)。型変換の責務がルーティング層にあるのがポイントです。

何が「型安全」なのか

Hashi の核心は p"..." という文字列インターポレータ・マクロです。

get(p"/users/{id:Int}/posts/{slug}"): (id: Int, slug: String) => Ok(s"user=$id post=$slug")

ここで何が起きているか:

  1. p"..." がコンパイル時にパス文字列を解析し、{id:Int} の :Int から型を取り出して RouteBuilder[Int : String : EmptyTuple] という型レベルのタプルを生成する
  2. get はその型に応じてオーバーロード解決され、ハンドラの引数が (Int, String) だとコンパイラが推論する
  3. パスの型注釈とハンドラの引数がズレていればコンパイルエラー

つまり「型の証明はエッジ(マクロ+オーバーロード解決)で済ませる」設計です。ランタイムのルータ自体は正規表現マッチの素直なディ スパッチに保たれ、複雑さが DSL 層に隔離されています。request をハンドラ内で引数なしに呼べるのも Scala 3 のコンテキスト関数型(Context ?=>)のおかげです。

get(p"/info"): Ok(s"${request.method} ${request.path}")

パスパラメータは個数無制限

多くの「型付きルーティング」を持つフレームワークは、内部で型をタプルに畳み込む都合上アリティ上限があります(Scala 2 系では 22 が壁になりがち)。Hashi は Scala 3 のネイティブタプルを使うため上限がありません。多引数ラムダ構文もそのまま使えます。

get(p"/orders/{oid:Long}/items/{iid:Int}/rev/{rev:Int}/{active:Boolean}"): (oid: Long, iid: Int, rev: Int, active: Boolean) => Ok(s"order=$oid item=$iid rev=$rev active=$active")

/ 演算子でパスを組み立てるスタイルも同じく任意アリティです。

get("/users" / p[Int] / "posts" / p[String] / p[Long]): (uid: Int, slug: String, rev: Long) => Ok(s"$uid/$slug/$rev")

JSON は型クラスで透過的に

hashi-upickle を入れると、ケースクラスをそのまま返せます。レスポンスの直列化は Responder[T] 型クラスに委譲されているので、String も JSON も SSE も同じ経路で扱われます。

case class User(id: Int, name: String) derives ReadWriter

post(p"/users"): request.bodyAs[User] match case Right(u) => Ok(u) // → {"id":...,"name":"..."} case Left(e) => BadRequest(e)

ミドルウェアは「関数の合成」

ミドルウェアは ハンドラ => ハンドラ という関数で、登録順に入れ子で合成されます(リクエストは外→内、レスポンスは内→外)。

hashi.serve(port = 8080): use: next => if request.header("X-Api-Key").contains("secret") then next else Forbidden("no key")

use: next =>
  next.withHeader("X-Powered-By", "hashi")

get(p"/secret"):
  Ok("ok")

ひととおり揃っている

hashi.serve(port = 8080): serveFiles("/static", java.nio.file.Path.of("public")) // 静的配信

use(Tracing())                                           // W3C TraceContext
get(p"/trace"):
  Ok(trace.map(_.traceId).getOrElse("-"))

ws(p"/echo/{room}"): (room: String) => sess =>           // WebSocket
  sess.onMessage(m => sess.send(s"[$room] $m"))

notFound:
  NotFound("見つかりません")
onError: e =>
  InternalServerError(s"oops: ${e.getMessage}")

XSS 対策として html"..." インターポレータ(自動エスケープ)や、サーバーを起動せずルートを叩ける TestClient も同梱しています。

test("パスパラメータ"): given HashiServer = new HashiServer() get(p"/greet/{name}"): (name: String) => Ok(s"Hello, $name!") assertEquals(TestClient().get("/greet/world").body, "Hello, world!")

設計の割り切り(正直なところ)

  • ルーティングは登録順の線形探索・先勝ち。トライ木や優先度はなく、ルート順序の設計は利用者の責任です
  • ランタイムの Route は型を Any に消します。型安全はコンパイル時に閉じる、という明確な境界設計です
  • ネームスペース/プレフィックスは未実装

まとめ

Hashi は「Scala 3 のマクロとコンテキスト関数で、型安全なルーティングを素直な見た目のまま書く」ことに振り切ったフレームワークで す。型の旨味はコンパイル時に取り切り、ランタイムは退屈に保つ — その割り切りが心地よさになっています。

zshの補完についてのメモ

zshでは、コマンドラインでの入力に対する便利な補完機能が用意されている。

その挙動を確認した結果のメモ

コマンド名補完

環境変数$PATH上に存在するコマンドをインクリメンタルサーチで候補を表示する。

コマンド名を途中まで入力し、TABキーを一回押下すると候補のコマンド名が表示される。

% vi[TAB]
vi              viewdiagnostic  vim             vimtutor        vis
view            vifs            vimdiff         vipw            visudo

複数回TABキーを押下すると、候補が次々と入力エリアに表示される。

例えば、上記の状態で2回TABキーを押下すると、下記の表示となる。

% view
vi              viewdiagnostic  vim             vimtutor        vis
view            vifs            vimdiff         vipw            visudo

そのままRETURNキーを押下すると実行される。

パス名補完

コマンド名が確定し、SPACEキーを押下してTABキーを押下すると、カレントディレクトリ配下のディレクトリ名や、ファイル名が候補として表示される。

% vim[SPACE][RETURN]
Applications/     Desktop/
...

例えば、上記の状態で2回TABキーを押下すると、下記の表示となる

% vim Desktop/
Applications/     Desktop/
...

この状態で更に「/」キーを押下してTABキーを押下すると、Desktop/ディレクトリ配下のディレクトリ名や、ファイル名が候補として表示される。

zshの補完機能 compinit

zshにはcompinitというプラグインが付属しており、サブコマンドや、オプションを補完してくれます。

.zshrcに以下の行を追加と有効になります。

autoload -Uz compinit && compinit

サブコマンドが大量に存在するgitで試してみます

git sまで入力し、TABキーを押下すると、「S」から始まるサブコマンドが表示されます。

% git s[TAB]
send-email      -- send collection of patches as emails
send-pack       -- push objects over git protocol to another repository
shell           -- restricted login shell for GIT-only SSH access
shortlog        -- summarize git log output
show            -- show various types of objects
...

この補完は、コマンドごとにzsh側で用意している定義ファイルが有り、その内容を元に表示されています(つまり、バージョンが合わない場合、正確でない可能性がある)。

macOS 26では、下記のディレクトリに配置されています。

/usr/share/zsh/5.9/functions

補完の定義ファイルは、$fpathを元に検索されます。

追加の補完の定義ファイル

zshが提供していない補完の定義は、以下のリポジトリで見つかるかもしれない。

github.com

より強力な補完機能

github.com

zshが提供する補完機能に加え、ヒストリから補完してくれるプラグイン

シンタックスハイライト

github.com

$PATH上にコマンドが存在すると、色を変えてくれるので、コマンドのタイプミスが無いことに気付ける

また、ファイルパスを入力した際は、存在するパスの場合はアンダーラインが引かれるため、ファイルパスのタイプミスに気付ける

補完機能と、組み合わせると、非常に少ないタイプ数でコマンドを入力し、誤りがあってもすぐ気付くことができる、便利

Ghosttyの使い方メモ

Ghosttyの使い方メモ

インストール

ダウンロードサイトからインストーラをダウンロードするか、パッケージマネージャーでインストールする。

ghostty.org

brew install --cask ghostty

zshの補完ファイルと、manファイルは、homebrewがインストールする

Ghosttyのconfigファイル用のvimプラグインのインストール

Ghosttyのconfigファイル用のvim, nvim用プラグインが用意されているので、個別にインストールする

cp -r /Applications/Ghostty.app/Contents/Resources/vim/vimfiles/. $HOME/.vim
cp -r /Applications/Ghostty.app/Contents/Resources/nvim/site/. $HOME/.config/nvim

ただし、ftdetect/ghostty.vimmacOS用Ghosttyの設定ファイルを自動検知しないため、以下のように書き換える(Ver.1.2.3)

au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty
↓
au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/*,*/*.ghostty/config setf ghostty

環境変数$EDITORvimか、nvimを設定することで、ghostty +edit-configで指定したエディタが起動し、configが読み込まれる

:makeで構文がチェックできる

ヘルプの確認

ghostty --help

manで詳細が分かる。

man ghostty
man 5 ghostty

キーバインドの確認

まずはキーバインドについて、どんな設定項目が有り、どんな設定になっているか確認する。

ghostty +list-keybinds --default

基本的な設定

Ghosttyは、ゼロコンフィグを目指している(設定なしで使える)ので、カスタマイズをする必要性は低い

環境変数$EDITORが設定された状態で、以下のコマンドを叩くとそのエディタが起動する

ghostty +edit-config

フォントと、サイズは完全に好みで(標準搭載されているJet Brains Monoは良いけど、リガチャでけっこう形が変わるので、0xProtoを使っている)

font-family = "0xProto"
font-size = 18
font-thicken = true
theme = "Tomorrow Night Bright"
window-width = 120
window-height = 40

macOS Tahoe 26から、Terminalに、SF Mono Terminalというフォントが追加になっている

macOS Tahoe 26から、Terminalに、SF Mono Terminalというフォントが追加になっている

  • Terminalに内蔵された専用フォント
  • 従来のSF Monoとは別のフォント
  • Powerlineのグリフが増えている
  • OpenTypeではなく、TrueType
  • RegularとItalicは有るが、Boldはない(ウェイトの数も少ない)
  • SF Mono、バージョンはダウンロードできるバージョンの方が少し新しい(内蔵フォントは15、ダウンロード版は18)
  • SF Mono Terminalのバージョンは21

その他

  • iTerm2はアプリビルトインのPowerlineのグリフを持つが、デザインが少し独特(LNが太め)
  • GhosttyはアプリビルトインのPowerlineのグリフに加え、Nerd Fontのグリフも持つ
  • Ghosttyはフォールバック先のフォントを独自に指定できる
  • 一方で、iTerm2はNon-ASCII用に別のフォントを指定できる
  • Terminalはフォールバック先は指定できないが、SF Mono Terminalのフォールバック先はシステムフォントのSF Proなので、デザインの統一感はある

Lazy.nvimを導入した

ScalaのLSPサーバであるmetalsvimバインディング「nvim-metals」がNeoVimしかサポートしていないため、常用するエディタをvimからNeoVimに切り替えた。

それに合わせてパッケージマネージャーもluaで書かれたNeoVim専用のLazy.nvimに乗り換えることにした。

lazy.folke.io

インストール方法

gitリポジトリからクローンし、setup関数を呼び出すことで準備が完了する。

NeoVimの設定ファイルのスタートポイントである~/.config/nvim/init.luaに以下の行を追加する。

require("config.lazy")

上記のパスが示す~/.config/nvim/lua/config/lazy.luaというファイルに、以下のコードを記載する。

-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
  local lazyrepo = "https://github.com/folke/lazy.nvim.git"
  local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
  if vim.v.shell_error ~= 0 then
    vim.api.nvim_echo({
      { "Failed to clone lazy.nvim:\n", "ErrorMsg" },
      { out, "WarningMsg" },
      { "\nPress any key to exit..." },
    }, true, {})
    vim.fn.getchar()
    os.exit(1)
  end
end
vim.opt.rtp:prepend(lazypath)

-- Make sure to setup `mapleader` and `maplocalleader` before
-- loading lazy.nvim so that mappings are correct.
-- This is also a good place to setup other settings (vim.opt)
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"

-- Setup lazy.nvim
require("lazy").setup({
  spec = {
    -- import your plugins
    { import = "plugins" },
  },
  -- Configure any other settings here. See the documentation for more details.
  -- colorscheme that will be used when installing plugins.
  install = { colorscheme = { "habamax" } },
  -- automatically check for plugin updates
  checker = { enabled = true },
})

NeoVimや、luaに慣れていないと見慣れない記述も多いので、いくつか調べておいたことをメモしておく。

  • vim.fn.stdpath("data")は、プラグインモジュールのロード先のスタートポイント...dataと書かれているのに、macOSや、Linuxでは~/.local/share/nvimを指す...なんで?
  • 「..」(ドット2つ)は、文字列結合...ドット一つでも珍しいけど、2つは更に珍しい
  • vim.uvと、vim.loopは、libuvへのインタフェース...NeoVim 0.10.x以降ではvim.uvが使われ、それ以前のバージョンではvim.loopが使われる...ただし、vim.loopは既にdeprecatedになっていて将来は削除される
  • leaderキーの設定は好みも有るけど、サンプルコードではspace keyを指定している
  • import = "plugins"という指定で、pluginsディレクトリに存在するファイルが全て読み込まれる

pluginsには、使いたいパッケージ名を指定するファイルを置く。置いておけば自動的にロードしてくれる。

例えば、gitインタフェースを提供するfugitiveモジュールのロードは以下のような指定を行う。

文字列を書いておくと、自動的にhttps://github.com/{文字列}.gitで置換してくれる。明示的なパス設定や、フルのURLでも指定できるが、非公開のモジュールでも導入しない限り、他の指定方法を利用する場面はあまり無いと思われる。

 -- vim-fugitiveの設定
 
 return {
   'tpope/vim-fugitive'
}

色々なオプションも渡すことができる。

return {
  "folke/tokyonight.nvim",
  lazy = false,
  priority = 1000,
  opts = function()
    vim.cmd[[colorscheme tokyonight]]
  end,
}

ちょっと混乱してしまうのが、lua言語の独特なデータ構造'テーブル'による、配列とハッシュをまとめて記述する方法で書くこと。

先頭のパッケージ名が配列の一つ目になり、残るキーとバリューの組み合わせはハッシュとなる。慣れるまでは違和感が凄い。