Skip to content

HTTP / Storage Effects

外部世界とのやりとりはすべて effect で行う。ここでは標準提供される effect の詳細仕様を述べる。


6.1 HTTP 共通

6.1.1 capability

capability対応 HTTP メソッド
http.getGET
http.postPOST
http.putPUT
http.patchPATCH
http.deleteDELETE
http.headHEAD
http.optionsOPTIONS

6.1.2 標準 effect

各メソッドに対応する高レベル effect が標準提供される:

strand
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),
                   body: HttpBody,
                   decode: Decoder
                 }
                 out=Result(Decoded, HttpError)

; put / patch / delete も同じ形

http.get 等は 未指定なら使えない(capability ガード)。app.caps に列挙必須。

6.1.3 HttpBody 型

strand
type HttpBody = Json(JsonValue)
              | Form(Map(Text, Text))
              | Multipart(Map(Text, FormValue))
              | Text(Text)
              | Bytes(Bytes)
              | Empty

6.1.4 Decoder 型

strand
type Decoder = Json(TypeRef)        ; JSON を型に decode
             | Text                  ; 文字列のまま
             | Bytes                 ; バイト列のまま
             | None                  ; レスポンス本文を捨てる

レスポンスの decode は型安全。Decoder.Json(User) を指定すれば、レスポンス JSON が User 型に decode される。失敗は HttpErrorbody に格納される。

6.1.5 共通 props(自動付与)

すべての HTTP effect は次を自動付与:

  • Accept: application/json(Decoder が Json のとき)
  • Content-Type: application/json(HttpBody が Json のとき)
  • Content-Type: multipart/form-data(Multipart のとき)
  • User-Agent: Strand/0.1

ユーザー指定の headers が優先される。


6.2 HTTP 利用例

6.2.1 GET

strand
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)

実装時、Strand コンパイラは loadUser を以下に展開する:

strand
emit http-get({
    url:     apiBase + "/users/" + $1.show,
    headers: {},
    query:   {},
    decode:  Decoder.Json(User)
})

→ 高レベル effect 名(loadUser)が URL テンプレートを内蔵することはできない。テンプレート機構は別途 6.6 高レベルラッパ を参照。

6.2.2 POST

strand
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] := $todo

6.3 認証

6.3.1 グローバル header の注入

app.http で全 HTTP effect に自動付与する header を宣言できる:

strand
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 のベース
headers全リクエストに付与(式可、slot 参照可)
on-401401 を受けた reducer
on-403403 を受けた reducer
on-5xx5xx を受けた reducer
timeoutデフォルトタイムアウト(duration)

6.3.2 401 のグローバル処理

strand
reducer handleUnauthorized
    on=app.http-401
    do= session := None
        emit navigate({path: "/login", params: {}, query: {}})

app.http-401app.http.on-401 で指定した reducer に自動でルーティングされる。


6.4 キャンセル

policy=latest または policy=latest-per-key(...) で自動キャンセルされる。手動キャンセルは:

strand
effect cancel cap=http.cancel in=EffectId out=Unit

reducer cancelSearch
    on=ui.click(CancelBtn)
    do= emit cancel(searchEffectId)

EffectIdemit 時に返される(v0.2 で実装)。v0.1 では policy 任せ。


6.5 リトライ

strand
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 はリトライしない(仕様)。


6.6 高レベルラッパ

URL テンプレートや path パラメータを書きたい場合は、ユーザーがラッパ effect を宣言する:

strand
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

6.7.1 capability

capability対応
storage.read, storage.writelocalStorage
session.read, session.writesessionStorage
indexed.read, indexed.write, indexed.deleteIndexedDB

6.7.2 標準 effect (localStorage)

strand
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)

6.7.3 例

strand
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} になる以外は同じ。

strand
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 の storeapp.indexed-db で宣言:

strand
app App
    ...
    indexed-db = {
        name: "myapp",
        version: 1,
        stores: [
            {name: "todos", key: "id"},
            {name: "drafts", key: "id", indexes: ["createdAt"]}
        ]
    }

6.8 永続化のパターン

6.8.1 起動時ロード

strand
reducer boot on=app.start do= emit loadAll()
reducer loaded on=loadAll.ok($data, _) do= state := $data

6.8.2 変更を debounce で保存

strand
effect save cap=storage.write
            in=Map(TodoId, Todo)
            out=Result(Unit, Text)
            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 楽観的更新 + サーバ同期

strand
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 のデフォルト:

設定
timeout30 秒
retrynone
Acceptapplication/json
Content-Type (Json body 時)application/json
User-AgentStrand/0.1
credentialssame-origin

ストレージ effect のデフォルト:

設定
policy並列実行(指定なし)
retrynone
エラー時の挙動Result.Err を返す(throw しない)

6.10 セキュリティ

6.10.1 CSP / CORS

Strand ランタイムは standard fetch を使うので、CORS の挙動はブラウザの fetch と同じ。CSP はサーバ側で設定する(Strand は関与しない)。

6.10.2 トークンの保存

localStorage にアクセストークンを保存するのは XSS 脆弱性のリスク。Strand のドキュメントとしては HTTP-only cookie + credentials: "include" を推奨する。

strand
app App
    ...
    http = {
        ...
        credentials: "include"
    }

6.10.3 機微情報の slot 注意

slot は episode log に含まれる。パスワード等を slot に置く場合は volatile=true を指定する:

strand
slot password : Text = ""
    volatile = true        ; episode log に書き込まれない、リロードでも消える

volatile slot は永続化対象から外れる。


6.11 設計上の判断記録

判断理由
HTTP は標準 effect として提供全アプリで再発明されないように
capability で許可制http.delete を持たないアプリで delete が呼ばれるのを構造で防ぐ
Decoder で型安全 decodeJSON.parse → as でキャストする慣習を排除
4xx はリトライしない4xx はクライアント側の問題なので無意味な再試行を避ける
HTTP-only cookie 推奨XSS リスクを構造で減らす
volatile slotパスワードが log に残るバグを構造で防ぐ

6.12 次