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:
- 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}).
-
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).
- Now, as the resource request is processed, I can always access the session through the Context parameter.
-
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.
-
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).