arizona_controller (arizona v0.4.0)

View Source

Controller routes: plain HTTP actions that drive the page without a full reload, written against Arizona's request and reply types only, so the same controller runs on any arizona_server adapter.

A controller route (a verb tag like {post, ...}, or {match, ...}) dispatches to an action function, Handler:Action/2 (the action option, default handle), that the browser reaches via arizona_js:fetch/2. Because it is a real HTTP request, the response may carry a Set-Cookie (HttpOnly honored) the WebSocket transport can't -- so it suits flows that rotate a session cookie while the page stays put.

The action

The action receives the arizona_req:request() the route's middleware pipeline produced and a context, #{state := State, bindings := Bindings} -- the route's state option (#{} when unset) and the bindings the pipeline produced (session from fetch_session, ...) -- and returns {Reply, Request}:

handle(Req0, _Ctx) ->
    {ok, Body, Req1} = arizona_req:body(Req0),
    %% ...parse Body, validate, derive UserId...
    Req2 = arizona_req:clear_session(Req1),
    Req3 = arizona_req:put_session(Req2, ~"user_id", UserId),
    %% Show server-computed content by broadcasting to a topic the view
    %% subscribed to; the response effects are for request-local UI only.
    arizona_pubsub:broadcast({account, UserId}, {account_updated, Body}),
    {arizona_controller:reply_effects([arizona_js:set_attr(~"#error", ~"hidden", ~"")]), Req3}.

Return the request you wrote to: what it carries -- session and flash writes, arizona_req:put_resp_cookie/4 and put_resp_header/3 -- is what reaches the response (and, in store mode, the session store). Thread it through arizona_req:body/1 too, since reading the body advances the request.

Replies

KindShape
buffered{Status, Headers, Body}
stream{stream, Status, Headers, Fun} -- Fun(Send); Send(Data, nofin | fin)
sendfile{sendfile, Status, Headers, {Path, Offset, Length}}
loop{loop, Status, Headers, State} -- the controller exports handle_info/3

Every adapter answers a buffered reply. The others are a capability the adapter declares (arizona_server:capabilities/0): dispatch/3 refuses one it lacks with {unsupported_reply, Kind, Adapter} before the session commits, rather than the reply degrading silently.

A loop keeps the response open and hands every message the connection receives to Handler:handle_info(Msg, Push, State), where Push(Data) writes one chunk; return {ok, State} to keep going or {stop, State} to end the response (the Server-Sent Events shape). When the client goes away it gets one final {arizona_disconnect, Reason}, after which the loop ends whatever it returns.

reply_effects/1,2 and reply_redirect/1 build the buffered reply the fetch command expects: the same {"e": [...]} effects wire payload the WebSocket sends. To re-render the live view after the request, broadcast over arizona_pubsub; the effects returned here are for request-local UI (inline error/success) or a navigate.

Summary

Functions

Runs a controller route against a request for the server adapter Adapter: the route's middleware pipeline (the CSRF check_origin step included by default), then the action, handed the route state and the pipeline's bindings as its context/0.

Renders this module's errors. Picked up by erl_error via the error_info annotation.

Builds a 200 application/json reply whose body is the {"e": [...]} effects wire payload the arizona_js:fetch/2 command applies on the page (the same effects handle_event/3 returns). 200 is the success leg -- a fetch form with az-form-reset clears only on a 2xx. For an error leg that keeps the typed fields, use reply_effects/2 with a non-2xx status (the effects still apply).

Like reply_effects/1 but with an explicit HTTP status. The fetch command applies the effects on any status, so a controller can return a real 422 for a validation error -- keeping the form's typed fields (a fetch form only resets on a 2xx) while still rendering the error, e.g. reply_effects(422, [arizona_js:push_event(~"invalid", #{~"field" => ~"password"})]).

Builds a reply that sends the client to Location via a SPA navigation. Sugar for reply_effects([arizona_js:navigate(Location)]): a fetch-followed HTTP 3xx can't drive a SPA navigation (its Location is unreadable), so a redirect is delivered as a navigate effect instead.

Types

context()

-nominal context() :: #{state := term(), bindings := az:bindings()}.

headers()

-nominal headers() :: [{binary(), iodata()}].

push_fun()

-nominal push_fun() :: fun((iodata()) -> ok | {error, term()}).

reply()

-nominal reply() ::
             {arizona_req:resp_status(), headers(), iodata()} |
             {stream, arizona_req:resp_status(), headers(), fun((send_fun()) -> term())} |
             {sendfile, arizona_req:resp_status(), headers(), sendfile_spec()} |
             {loop, arizona_req:resp_status(), headers(), State :: term()}.

reply_kind()

-nominal reply_kind() :: buffered | stream | sendfile | loop.

route()

-nominal route() ::
             #{handler := module(),
               action := atom(),
               state := term(),
               middlewares := [arizona_middleware:middleware()]}.

send_fun()

-nominal send_fun() :: fun((iodata(), nofin | fin) -> ok | {error, term()}).

sendfile_spec()

-nominal sendfile_spec() :: {file:filename_all(), non_neg_integer(), non_neg_integer()}.

Functions

dispatch(Route, Request, Adapter)

-spec dispatch(Route, Request, Adapter) -> {reply(), Request}
                  when Route :: route(), Request :: arizona_req:request(), Adapter :: module().

Runs a controller route against a request for the server adapter Adapter: the route's middleware pipeline (the CSRF check_origin step included by default), then the action, handed the route state and the pipeline's bindings as its context/0.

Returns the reply with arizona_req:flush/1's lines ahead of the action's own headers (so a written session is committed and its cookie sent), and the request the action returned, whose raw value is what the adapter hands back to its server. A middleware halt returns its arizona_req:halt_reply/1 as a buffered reply, without running the action.

A reply kind Adapter lacks raises {unsupported_reply, Kind, Adapter}, like a malformed reply, before the flush. The adapter maps the rest onto its server; for a loop it routes the connection's messages to Handler:handle_info/3, translating its server's disconnect signal into {arizona_disconnect, Reason}.

format_error(Reason, Stacktrace)

-spec format_error(Reason, Stacktrace) -> ErrorInfo
                      when
                          Reason :: term(), Stacktrace :: [tuple()], ErrorInfo :: #{general := iolist()}.

Renders this module's errors. Picked up by erl_error via the error_info annotation.

reply_effects(Effects)

-spec reply_effects(Effects) -> reply() when Effects :: arizona_effect:cmds().

Builds a 200 application/json reply whose body is the {"e": [...]} effects wire payload the arizona_js:fetch/2 command applies on the page (the same effects handle_event/3 returns). 200 is the success leg -- a fetch form with az-form-reset clears only on a 2xx. For an error leg that keeps the typed fields, use reply_effects/2 with a non-2xx status (the effects still apply).

reply_effects(Status, Effects)

-spec reply_effects(Status, Effects) -> reply()
                       when Status :: arizona_req:resp_status(), Effects :: arizona_effect:cmds().

Like reply_effects/1 but with an explicit HTTP status. The fetch command applies the effects on any status, so a controller can return a real 422 for a validation error -- keeping the form's typed fields (a fetch form only resets on a 2xx) while still rendering the error, e.g. reply_effects(422, [arizona_js:push_event(~"invalid", #{~"field" => ~"password"})]).

A navigate/patch with a flash opt is refused (flash_outside_live_effect): that opt is carried by the WebSocket, for an effect a live handler returns, and a SPA navigate cannot see a cookie set after its socket opened. To show a flash on the page a controller sends the user to, set it on the request and navigate in full, so the browser's next GET carries the cookie:

handle(Req, _Ctx) ->
    Reply = arizona_controller:reply_effects([arizona_js:navigate(~"/done", #{full => true})]),
    {Reply, arizona_req:put_flash(Req, info, ~"Saved.")}.

reply_redirect(Location)

-spec reply_redirect(Location) -> reply() when Location :: binary().

Builds a reply that sends the client to Location via a SPA navigation. Sugar for reply_effects([arizona_js:navigate(Location)]): a fetch-followed HTTP 3xx can't drive a SPA navigation (its Location is unreadable), so a redirect is delivered as a navigate effect instead.

A Location that is not a path on this origin (arizona_req:location_kind/1) -- an identity provider, an absolute URL -- cannot be SPA-navigated, so it is sent with the full option and the browser loads it for real. A relative one (~"login") loads too, resolved against the current URL exactly as an HTTP Location would be. A scheme other than http/https (javascript:, data:) raises {unsafe_redirect, Location}: the client would run it in the page, so a Location taken from the request (a next parameter) would be script injection.

This is a SPA navigation over the existing WebSocket, so it suits same-identity redirects. After an identity change (login/logout) use arizona_js:reload/0 instead, so the socket re-handshakes with the new session.