HTTP / Storage Effects
外部世界とのやりとりはすべて effect で行う。ここでは標準提供される effect の詳細仕様を述べる。
6.1 HTTP 共通
6.1.1 capability
| capability | 対応 HTTP メソッド |
|---|---|
http.get | GET |
http.post | POST |
http.put | PUT |
http.patch | PATCH |
http.delete | DELETE |
6.1.2 標準 effect
プログラムは capability に対して自分の effect を宣言する。以下は各メソッドでツールチェインが期待する形 — 名前は任意で、cap とレコードがランタイムの dispatch 先を決める:
effect http-get cap=http.get
in={
url: Url,
headers: Map(Text, Text),
query: Map(Text, Text),
decode: Decoder
}
out=Result(Decoded, HttpError)
effect http-post cap=http.post
in={
url: Url,
headers: Map(Text, Text),
query: Map(Text, Text),
body: HttpBody,
decode: Decoder
}
out=Result(Decoded, HttpError)
# put / patch / delete も同じ形query は http.* のすべてのメソッド(get・post・put・patch・delete)で同じく URL のクエリ文字列として送られる。各エントリは URL エンコードされ(URLSearchParams による。空白は + に、値の中の & はエスケープされる)、url が既に持つクエリ文字列の後ろ・フラグメントの前に付け足される。空の query は url をそのまま残す。クエリ文字列中のエントリの順序は保証されないので、それに依存してはならない。
http.get 等は 未指定なら使えない(capability ガード)。app.caps に列挙必須。
6.1.3 HttpBody 型
type HttpBody = Json(JsonValue)
| Form(Map(Text, Text))
| Multipart(Map(Text, FormValue))
| Text(Text)
| Bytes(Bytes)
| Empty各 variant は、その名前が示すものとして送られる:
| variant | リクエスト本文 |
|---|---|
Json(v) | v の JSON。Unit の Json は null |
Form(m) | m を URL エンコードしたもの(a=1&b=2) |
Multipart(m) | m の FormData。FileV のエントリはファイルとして送る。ファイルを持たない FileV(永続化から復元したファイルレコード)はリクエスト前に HttpError{status: 0} で effect を失敗させる |
Text(t) | t そのまま |
Bytes(b) | b のバイト列 |
Empty | 本文なし |
HttpBody の variant でない body(レコード・リスト・素の Text)は、Json と同じく JSON で送られる。Text を入力に取る body: $1 は x ではなく "x" を送る。生のテキストを送るには Text($1) と書く。
GET と HEAD は body が何であっても本文を送らない。
6.1.4 Decoder 型
Decoder.Json(User) # レスポンス JSON を型に decode
Decoder.Text # 文字列のまま
Decoder.Bytes # バイト列のまま
Decoder.None # レスポンス本文を捨てるレスポンスの decode は型としてはコンパイル時に検査され、実行時に検査されるのは JSON の構文と、宣言した型が持つ述語。JSON として壊れている 2xx の本文は、レスポンス自身の status、decode failed: で始まる message、body にレスポンス本文を持つ HttpError になる。レスポンスは届いているので接続エラー(status: 0)ではなく、リトライもされない(6.5)。本文のない 2xx(204 など)には Decoder.None が必要で、そうしないとデフォルトの decoder がその status で decode failed: を報告する。
decode した値は T にも照らして検査される。T が持つすべての述語を、それが書かれたすべての位置で検査する。型 T の slot への書き込みが受けるのと同じ検査である(§10.3.3)。拒否された値も同じ HttpError になり、message は述語と、値がそれを満たさなかった位置を示す(decode failed: uuid at .id)。検査するのは述語だけで、T が述語を持たない位置は届いたまま受け取る。そのため、構文は通るが宣言した型と形が合わない本文は実行時には検出されない。読み取り capability(http.*、storage.read、session.read、indexed.read。標準ライブラリ §2.5)に登録したホスト provider は、この検査をリクエストの decode として関数で受け取る。parse した値を渡すと、T が受け入れれば undefined を、拒否すれば満たされなかった述語({kind, args, path})を返す。述語を持たない T では、decode は文字列 "json" である。
6.1.5 共通 props(自動付与)
すべての HTTP effect は次を自動付与:
Accept: application/json(Decoder が Json のとき)Content-Type: application/json(HttpBody が Json のとき、または本文が HttpBody の variant でないとき)Content-Type: application/x-www-form-urlencoded(Form のとき)Content-Type: multipart/form-data(Multipart のとき。boundary を含めるため fetch 自身が書く)User-Agent: Kumiki
ユーザー指定の headers が優先される。上の既定値より app.http.headers が、app.http.headers より effect 自身の headers が優先される。ヘッダ名はどの段階でも大文字小文字を区別せずに比べるので、effect の content-type はグローバルの Content-Type を置き換え、送られる値はちょうど 1 つになる。
例外は Multipart だけである。プログラムが指定した Content-Type は捨てられる。このヘッダには fetch だけが知る boundary が必要で、boundary のない multipart/form-data はサーバが解析できないからである。
6.2 HTTP 利用例
6.2.1 GET
type UserId = nominal Text where uuid
type User = {id: UserId, name: Text, email: Email}
slot users : Map(UserId, LoadResult(User)) = {}
slot apiBase : Url = "https://api.example.com"
effect loadUser cap=http.get
in=UserId
out=Result(User, HttpError)
policy=latest-per-key($1)
retry=exponential(3, 200ms, 2.0)
reducer fetchUser
on=ui.click(LoadBtn)
do= users[$el.userId] := Loading
emit loadUser($el.userId)実装時、Kumiki コンパイラは loadUser を以下に展開する:
emit http-get({
url: apiBase + "/users/" + $1.show,
headers: {},
query: {},
decode: Decoder.Json(User)
})→ 高レベル effect 名(loadUser)が URL テンプレートを内蔵することはできない。テンプレート機構は別途 6.6 高レベルラッパ を参照。
6.2.2 POST
effect createTodo cap=http.post
in={text: Text}
out=Result(Todo, HttpError)
policy=queue
tile NewTodoForm = form(input(bind=draft))
reducer add
on=ui.submit(NewTodoForm)
do= emit createTodo({text: draft})
draft := ""
reducer added
on=createTodo.ok($todo, _)
do= todos[$todo.id] := $todo6.3 認証
6.3.1 グローバル header の注入
app.http で全 HTTP effect に自動付与する header を宣言できる:
app App
caps = [http.get, http.post, storage.read]
routes = {"/" -> Home, "/404" -> NotFound}
init = [loadSession()]
http = {
base-url: "https://api.example.com",
headers: {
"Authorization": fmt("Bearer {0}", session.get-or("anon"))
},
on-401: handleUnauthorized
}| http フィールド | 意味 | 評価タイミング |
|---|---|---|
base-url | 相対 URL のベース — Text、または Text の上に作られた型(Url・Email・Uuid など) | リクエストごと |
headers | 全リクエストに付与 — リクエスト自身の headers と同じ Map(Text, Text) | リクエストごと |
timeout | ミリ秒単位のデフォルトタイムアウト — Int に代入可能なもの:Int・Duration・ユーザ定義の nominal Int | リクエストごと |
credentials | fetch の credentials モード(既定値は §6.9)— omit / same-origin / include のいずれかの Text | リクエストごと |
on-401 | 401 を受けた reducer(コンパイラが解決する — 未知の名前は E0102) | コンパイル時に解決 |
on-403 | 403 を受けた reducer(同上) | コンパイル時に解決 |
on-5xx | 5xx を受けた reducer(同上) | コンパイル時に解決 |
値を取るフィールドはすべて式であり、slot を読んでよい。4 つとも評価されるのは アプリの構築時ではなくリクエストを行う時である:slot を書く reducer は次の リクエストの内容を変え、そのために再マウントする必要はない。したがって base-url: endpoint は endpoint に代入した瞬間から接続先を切り替え、 headers: {"Authorization": fmt("Bearer {0}", session.get-or("anon"))} は マウント時ではなくリクエスト時点のセッションを載せる。
reducer 名の 3 つだけは例外で、そもそも値ではない:コンパイラが reducer 定義に 対して一度だけ解決する。
4 つの式について検査されるのは名前と値である。解決されない名前は書かれた位置で E0103 になる。型の合わない値は、下の項目が別に定めない限り フィールドの位置で E0201 になる:
base-urlはTextに代入可能なものを取る —UrlなどTextの上に作られた型を含む。headersはMap(Text, Text)に代入可能なものを取る。リクエスト自身のheaders(§6.1.2)と同じ型であり、値がすべてTextの リテラル{"Name": value}か、その型を持つそれ以外の式 — slot、fn呼び出し — である。 リテラルの中のTextでないキーや値はそれが書かれた位置で、map でないものは フィールドの位置、またはそれを生むifの分岐の位置で報告される。キーは引用符で 囲む:キーを裸で書いた{Content-Type: "application/json"}は map ではなく レコードであり、フィールドの位置で E0201 になる。ランタイムはこの値を 各リクエストの headers に展開する:数値は何も展開せず、文字列は0・1・… という名前の header に展開される — いずれにせよ意図した header は 1 つもリクエストに届かない。timeoutはIntに代入可能なものを取り、ミリ秒として読まれる。Durationは その 1 つであり(実行時にはミリ秒)、ユーザ定義のnominal Intも同様である。Floatは含まれない。TextはsetTimeoutにNaNとして届いて、すべての リクエストを応答前に中断させる。credentialsはTextに代入可能なものを取り、フィールドに届くリテラル — フィールド自身の値、またはifのリテラルの分岐 — はすべて Fetch の 3 つの モードのいずれかでなければならない。それ以外を指定した init はブラウザが拒否する。
照合されるのは型であり、credentials についてはリテラルも照合される:それ以外の 方法で計算される値 — slot、呼び出し、連結 — は実行時に決まるので、型の合うものは 何を保持するかにかかわらず受理される。timeout: 0 や負の Int も Int であり、 受理される。
6.3.2 401 のグローバル処理
reducer handleUnauthorized
on=app.http-401
do= session := None
emit navigate({path: "/login", params: {}, query: {}})app.http-401 は app.http.on-401 で指定した reducer に自動でルーティングされる。
6.4 キャンセル
policy=latest または policy=latest-per-key(...) で自動キャンセルされる。手動キャンセルは:
slot searchEffectId : EffectId = EffectId.none
effect cancel cap=http.cancel in=EffectId out=Unit
reducer startSearch
on=ui.input(SearchBox)
do= let id = emit fetchResults(query)
searchEffectId := id
reducer cancelSearch
on=ui.click(CancelBtn)
do= emit cancel(searchEffectId)
searchEffectId := EffectId.noneemit を式として使うと、dispatch された effect の EffectId が返る(stdlib §2.1.1.1 参照)。EffectId.none センチネルにより emit cancel(EffectId.none) は安全な no-op になる。
id は <effect-name>:<key> である。<key> は、effect が policy=latest-per-key(<expr>) を宣言していなければ _、宣言していればその式を emit が実行された地点で 1 回だけ評価した値 である。key が読む slot は、reducer 本体がその文までに書き込んだ値を持ち、同じ本体の後続の書き込みは見えない。dispatcher はリクエストをこの同じ key で実行するため、本体がその後 key の読む slot を書き換えても、emit が返す id は自分が開始したリクエストを指す。app.init のエントリは reducer 本体の外で emit されるため、その key は dispatch された時点の slot の値で評価される。
cap=http.cancel の effect は in=EffectId out=Unit を満たさなければならず、それ以外の形はコンパイル時に拒否される(E0303)。
6.4.1 挙動
- 未知 / 既完了
EffectIdへの cancel は silent no-op(キャンセルは契約違反ではなく冪等な意図)。 - キャンセルされた effect の
.errreducer は{status: 0, message: "aborted", body: ""}で起動する。HttpError形が abort と通信失敗の両方を覆う。policy=latest/policy=latest-per-keyによる自動キャンセルにも同じ正規化が適用される。status: 0は HTTP レスポンスが届かなかったことを表し(タイムアウトと通信失敗も同じ値を返す)、HttpStatusはこの値を許す(標準ライブラリ §2.1.3)ので、HttpErrorを保持する slot はそれを受け入れる。 - 同 effect に対する
debounceタイマーは cancel でクリアされ、まだ発行されていない待機中リクエストは発生しない。 throttleのウィンドウマーカーは そのまま維持される。元の effect はすでに launch 済み(cancel はその進行中リクエストを abort)であり、マーカーを消すと直後の emit がウィンドウ終了前にレート制限をすり抜けてしまう。
6.5 リトライ
effect loadCritical cap=http.get
in=Text
out=Result(Text, HttpError)
retry=exponential(5, 500ms, 2.0)| retry | 振る舞い |
|---|---|
none | リトライしない(デフォルト) |
linear(N, ms) | N 回まで、ms 間隔で再試行 |
exponential(N, initial-ms, factor) | N 回まで、初回 initial-ms、毎回 factor 倍 |
リトライは 5xx と接続エラーのみ対象。4xx はリトライしない(仕様)。本文が JSON として壊れている 2xx、および decode した値を T が拒否した 2xx(6.1.4)もリトライしない。サーバーはすでにリクエストを受理しているので、リトライは同じ副作用をもう一度起こすだけになる。
6.6 高レベルラッパ
URL テンプレートや path パラメータを書きたい場合は、ユーザーがラッパ effect を宣言する:
slot apiBase : Url = "https://api.example.com"
effect loadUser cap=http.get
in=UserId
out=Result(User, HttpError)
policy=latest-per-key($1)
map-request={
url: apiBase + "/users/" + $1.show,
headers: {},
query: {},
decode: Decoder.Json(User)
}map-request はビルトイン effect の入力に変換する純粋関数(式断片)。これにより、高レベル effect 名と実 HTTP リクエストの関係が 1 箇所に集中する。
6.7 Storage Effects
err 値は宣言どおりの Text。 以下の capability の effect はすべて out=Result(T, Text) を宣言する。別の E を宣言すると E0306 になる。失敗した effect は失敗のメッセージをそのまま Text として渡す — 読み取りがバックエンドのブロックに当たれば "SecurityError: …"、書き込みなら呼び出しとキーを示すメッセージ(§6.7.2)、app.indexed-db の無いアプリで indexed-* effect が動けば "app.indexed-db is not declared" — それを包むレコードではない。effect の map-request、その capability に登録されたホストの provider、または組み込みハンドラが例外を投げた場合も同じ Text が渡り、渡るのは1度である:retry=(6.5)はそれを再試行しない。したがって .err($e, _) は $e : Text を束縛し(位置束縛)、problem := $e はメッセージを格納し、$e.message は E0108 になる。ホストの provider の err 値が何になるかは 標準 capability にある。
6.7.1 capability
| capability | 対応 |
|---|---|
storage.read, storage.write | localStorage |
session.read, session.write | sessionStorage |
indexed.read, indexed.write, indexed.delete | IndexedDB |
6.7.2 宣言(localStorage)
effect storage-read cap=storage.read
in={key: Text, decode: Decoder}
out=Result(Option(Decoded), Text)
effect storage-write cap=storage.write
in={key: Text, value: JsonValue}
out=Result(Unit, Text)
effect storage-remove cap=storage.write
in={key: Text}
out=Result(Unit, Text)
effect storage-clear cap=storage.write
in=Unit
out=Result(Unit, Text)クリアは宣言で決まる。map-request を持たず in=Unit(直接、または別名を通して)と宣言した effect がストレージを空にする。空になるのはこのアプリが書いたキーだけでなく、オリジン全体の localStorage である。それ以外の storage.write は書き込みか削除で、リクエスト(effect の入力、または map-request が組み立てたもの)で区別される。key を持ち value フィールドを持たないレコードはそのキーを削除し(以後の storage-read は Ok(None) を返す)、value フィールドを持つレコードはそれを書き込む。値そのものは問わない。None・[]・レコードはいずれも書き込まれる。
このどれにも当たらないリクエストは err になり、何も変更しない。レコードでないもの(空のリクエストを含む)、空でない Text でない key、JSON で表せない value がこれに当たる。Web Storage の呼び出しの失敗(容量超過、SecurityError)も err で、そのメッセージは呼び出しとキーを示す。storage.write のホストプロバイダ(§2.5)は、effect の入力または map-request が組み立てたとおりのリクエストを受け取り、クリアではリクエストを受け取らない。
保存された値は常に JSON として parse される。read の Decoder.Json(T) が parse した値を拒否した場合(レスポンスと同じく 6.1.4 の検査)、read は decode failed: で始まる Text を値とする .err になる。parse できない値と同じ扱いである。したがって、古いビルドが書いた、あるいは手で編集された、いまの型が拒否する storage は、.err reducer が扱える失敗としてプログラムに届く。reducer の batch が書き込みを拒否する .ok にはならない(§10.3.3)。そうなると、その reducer でロード状態を終えるアプリはロード画面のまま止まる。
err 値は宣言どおりの Text。 storage / session / indexed の effect が失敗すると、失敗のメッセージをそのまま Text として渡す — 読み取りがバックエンドのブロックに当たれば "SecurityError: …"、書き込みなら上記の呼び出しとキーを示すメッセージ、app.indexed-db の無いアプリで indexed-* effect が動けば "app.indexed-db is not declared" — それを包むレコードではない。effect の map-request、またはその capability に登録されたホストの provider が例外を投げた場合も同じ Text が渡る。したがって .err($e, _) は $e : Text を束縛し(位置束縛)、problem := $e はメッセージを格納し、$e.message は E0108 になる。
6.7.3 例
slot todos : Map(TodoId, Todo) = {}
effect saveTodos cap=storage.write
in=Map(TodoId, Todo)
out=Result(Unit, Text)
policy=debounce(300ms)
map-request={key: "todos", value: $1}
effect loadTodos cap=storage.read
in=Unit
out=Result(Option(Map(TodoId, Todo)), Text)
policy=once
map-request={key: "todos", decode: Decoder.Json(Map(TodoId, Todo))}
reducer boot
on=app.start
do= emit loadTodos()
reducer todosLoaded
on=loadTodos.ok($maybeMap, _)
do= todos := $maybeMap.get-or({})
reducer onChange
on=ui.click(TodoRow)
do= ...
emit saveTodos(todos)6.7.4 sessionStorage / IndexedDB
session-* も同じ形。indexed-* はキー指定が {store: Text, key: Text} になる以外は同じ。拒否された Decoder.Json(T) は、storage-read と同じく session-read と indexed-read でも .err になる。IndexedDB は構造化された値を保持するので parse はしないが、検査は行う。
effect indexed-read cap=indexed.read
in={store: Text, key: Text, decode: Decoder}
out=Result(Option(Decoded), Text)
effect indexed-write cap=indexed.write
in={store: Text, key: Text, value: JsonValue}
out=Result(Unit, Text)
effect indexed-query cap=indexed.read
in={store: Text, index: Option(Text), range: Option(IndexRange)}
out=Result(List(JsonValue), Text)IndexedDB の store は app.indexed-db で宣言:
app App
...
indexed-db = {
name: "myapp",
version: 1,
stores: [
{name: "todos", key: "id"},
{name: "drafts", key: "id", indexes: ["createdAt"]}
]
}6.8 永続化のパターン
6.8.1 起動時ロード
reducer boot on=app.start do= emit loadAll()
reducer loaded on=loadAll.ok($data, _) do= state := $data6.8.2 変更を debounce で保存
effect save cap=storage.write
in=Map(TodoId, Todo)
out=Result(Unit, Text)
map-request={key: "todos", value: $1}
policy=debounce(300ms)
reducer afterChange
on=ui.click(TodoRow)
do= todos[$el.id].done := not todos[$el.id].done
emit save(todos)6.8.3 楽観的更新 + サーバ同期
reducer addOptimistic
on=ui.submit(NewTodoForm)
do= let id = TodoId.fresh()
todos[id] := {id, text=draft, done=false, pending=true}
draft := ""
emit createOnServer({text: draft, clientId: id.show})
reducer addOk
on=createOnServer.ok($serverTodo, $clientId)
do= todos := todos.remove(TodoId.parse($clientId).get-or(""))
todos[$serverTodo.id] := $serverTodo
reducer addErr
on=createOnServer.err($e, $clientId)
do= todos := todos.remove(TodoId.parse($clientId).get-or(""))
emit toast({kind: "error", text: "Failed to save"})6.9 デフォルト設定
すべての HTTP effect のデフォルト:
| 設定 | 値 |
|---|---|
timeout | 30 秒 |
retry | none |
Accept | application/json |
Content-Type (Json body 時) | application/json |
User-Agent | Kumiki |
credentials | same-origin |
ストレージ effect のデフォルト:
| 設定 | 値 |
|---|---|
policy | 並列実行(指定なし) |
retry | none |
| エラー時の挙動 | Result.Err を返す(throw しない) |
6.10 セキュリティ
6.10.1 CSP / CORS
Kumiki ランタイムは standard fetch を使うので、CORS の挙動はブラウザの fetch と同じ。CSP はサーバ側で設定する(Kumiki は関与しない)。
6.10.2 トークンの保存
localStorage にアクセストークンを保存するのは XSS 脆弱性のリスク。Kumiki のドキュメントとしては HTTP-only cookie + credentials: "include" を推奨する。
app App
...
http = {
...
credentials: "include"
}6.10.3 機微情報の slot 注意
slot は episode log に含まれる。パスワード等を slot に置く場合は volatile=true を指定する:
slot password : Text volatile = "" # episode log に書き込まれない、リロードでも消えるvolatile slot は永続化対象から外れる。
6.11 設計上の判断記録
| 判断 | 理由 |
|---|---|
| HTTP は標準 effect として提供 | 全アプリで再発明されないように |
| capability で許可制 | http.delete を持たないアプリで delete が呼ばれるのを構造で防ぐ |
| Decoder で型安全 decode | JSON.parse → as でキャストする慣習を排除 |
| 4xx はリトライしない | 4xx はクライアント側の問題なので無意味な再試行を避ける |
| HTTP-only cookie 推奨 | XSS リスクを構造で減らす |
volatile slot | パスワードが log に残るバグを構造で防ぐ |