Erlang and Webmachine Tres

In my first post, I talked about the basic technologies I'd be using, and how to get an interactive shell started in emacs that started up your webmachine application. In this third post, I'll talk a little about building a session handling module. This will hopefully familiarize you with using redirects and cookies in your webmachine resources and using Couchbeam to store and retrieve documents. The high-level usage for sessions I have currently is:

  1. User agent accesses a supported resource (probably a browser accessing the home page initially).
    • I am defining the is\authorized/2 function in all of my resource files that correspond to web pages, as well as the finish\request/2 function.
    • The second parameter in the functions is a record, currently defined as

      -record(context, {session}).
      
  2. In the is\authorized/2 function for public facing pages, it returns the tuple:

    is_authorized(RD, Context) ->
        {true, RD, Context#context{session=session:start(RD)}.
    
    
    • Within session:start/1, I extract the session cookie (if it exists) and retrieve the session document from CouchDB. If the session cookie isn't set, I create a new #session{} record with some defaults pre-filled.
    • #+BEGINEXAMPLE %% session.hrl -record(session, {'id', 'rev', userid, expires, created, storage=[]}).

      %% session.erl start(RD) -> getsessionrec(RD).

      getsessionid(RD) -> wrq:getcookievalue(?COOKIENAME, RD).

      %% getsessiondoc(request()) -> notfound | jsonobject() getsessiondoc(RD) -> case getsessionid(RD) of undefined -> notfound; SessDocId -> couchbeamdb:opendoc(?DB, SessDocId) end.

      %% getsessionrec(request()) -> sessionrec() getsessionrec(RD) -> case getsessiondoc(RD) of notfound -> new(); Doc -> fromcouchbeam(Doc) end.

      new() -> Now = calendar:datetimetogregorianseconds(calendar:localtime()), #session{created=Now, expires=?MAXAGE, 'id'=couchbeamutil:newuuid()}.

      #+ENDEXAMPLE

    • So first I pull out the session ID from the cookie header in get\session\id/1 which will return either undefined or the ID.
    • If the session ID is undefined, get\session\doc/1 returns the not\found atom, which is what Couchbeam returns when it can't find a document. If a session ID is found, I call couchbeam:open\doc/2 to retrieve the document from CouchDB.
    • Finally, if get\session\doc/1 returns not\found, I create a new session record calling new/0. If a document is returned, I call a function that transforms the proplist to the session record.
    • Note: Couchbeam will auto assign your '\id' if the document to be saved doesn't have one or it is undefined. While useful, for sessions it causes a problem as the auto-generated UUIDs are fairly sequential (not exactly but usually only the last four or five characters differ). Calling couchbeam\util:new\uuid() creates your random uuids.
    • I store the created time in seconds, as well as the expires time (30 minutes worth of seconds right now). This allows me to easily clear ended sessions from CouchDB with a view indexing the session documents by (created+expires).
  3. Now, as the resource request is processed, I can always access the session through the Context parameter.
  4. When the request is done processing, it is time to save the session and set the cookie. In wm\resource:finish\request/2 I have:

    finish_request(RD, Context) ->
        {true, wm_session:finish(Context#context.session, RD), Context}.
    

    And in session.erl, I define the finish/2 process:

    %% finish(#session(), request()) -> request().
    %%  save session and set cookie headers
    finish(S, RD) -> save_session_rec(S, RD).
    
    save_session_rec(S, RD) ->
        DateTime = calendar:local_time(),
        Now = calendar:datetime_to_gregorian_seconds(DateTime),
        case has_expired(S#session.created + S#session.expires) of
            true -> close(S, RD);
            false ->
                D = to_couchbeam(S#session{created=Now,expires=?MAX_AGE}),
                S1 = from_couchbeam(couchbeam_db:save_doc(?DB, D)),
                set_cookie_header(RD, S1, DateTime, ?MAX_AGE)
        end.
    
    close(S, RD) ->
        DateTime = calendar:local_time(),
        SessionDoc = to_couchbeam(S),
        couchbeam_db:delete_doc(?DB, SessionDoc),
        set_cookie_header(RD, S, DateTime, -1).
    
    set_cookie_header(RD, Session, DateTime, MaxAge) ->
        {CookieHeader, CookieValue} = mochiweb_cookies:cookie(?COOKIE_NAME, Session#session.'_id', [{max_age, MaxAge},
                                                                                                    {local_time, DateTime}]),
        wrq:set_resp_header(CookieHeader, CookieValue, RD).
    
    • So to finish a session, I check if the current session has expired, and if so close it down. Otherwise, I update the created and exipres properties of the session and save it to CouchDB, and then set the cookie headers to reflect the new times.
  5. For a resource needing valid authentication to be accessible, the is\authorized/2 is slightly different:

    %% wm_resource.erl
    is_authorized(RD, Context) ->
        S = session:start(RD),
        case session:is_authorized(S) of
        true -> {true, RD, Context#context{session=S}};
            false ->
                RD0 = wrq:do_redirect(true, RD),
                RD1 = wrq:set_resp_header("Location", "/login", RD0),
                {{halt, 307}, RD1, Context#context{session=S}}
        end.
    
    %% session.erl
    is_authorized(S) ->
        S#session.user_id =/= undefined andalso
            not has_expired(S#session.created + S#session.expires).
    
    • Obviously you can set your own redirect header. The {halt, 307} is a temporary redirect (since hopefully you've only forgotten to login or been away too long and your session expired).