The web session problem¶
Earlier on, the oddities underlying a web application are explained. In short – there is no program that stays in memory for the entire time during which a user uses a web application. This is troublesome not only for web application framework developers, but for programmers writing web applications as well.
One crucial example of this issue is when you want users to be able to log into your application. Let’s say you have a separate login page on which a user can log in. When the user does the actual logging in, some code is executed on the server (to check login credentials, etc). After this, the server sends a response back to the browser and promptly releases from memory all the information thus gathered.
Since nothing stays in memory between requests, what do you do? Where do you save the User object (for example) representing the user who is currently logged in so that it is available in subsequent requests?
User sessions¶
In order to deal with this problem, Reahl has the notion of a
UserSession – a class implementing UserSessionProtocol
. An
implementation of UserSessionProtocol
is an object which represents one sitting
of a user while the user is interacting with the web application. When
a user first visits the web application, such a UserSession is created and
saved to the database. On subsequent visits, Reahl checks behind the
scenes (currently using cookies) for an existing UserSession matching
the browser which sent the current request. The UserSession is
automatically destroyed if a specified amount of time passes with no
further interaction from the user.
The UserSession provides a way for the programmer to persist objects on the server side – for a particular user while that user uses the web application. The programmer can get to objects stored “in the session” from any page. The stuff persisted as part of the UserSession typically disappears automatically when the UserSession expires. An Object that lives in the UserSession is said to be session scoped.
An example: logging in¶
To see how one can use the UserSession in Reahl, let’s build an example which allows users to log in using an email address and password. Reahl has a built-in way of dealing with user logins explained later on, but doing this from scratch provides for a nice example.
Firstly, we’ll need a bit of a model. This model needs some object to represent the User(s) that can log in, and some session scoped object that keeps track of who is currently logged in. How about a User object then, and a LoginSession object:
The application itself will have two pages – a home page on which the
name of the User who is currently logged in is displayed, and a page
on which you can log in by specifying your email address and a
password. There should also be a Menu
you can use to navigate to the
two different pages.
Declaring the LoginSession¶
In Reahl, if you want to persist an object just for the lifetime of a UserSession, that object should be persisted via the same persistence mechanism used by your other persistent objects. It should also hold onto the UserSession so that it is discoverable for a particular UserSession.
When using SqlAlchemy, creating an object that has a session scoped lifetime is really easy: just use the @session_scoped class decorator:
@session_scoped
class LoginSession(Base):
__tablename__ = 'sessionscope_loginsession'
id = Column(Integer, primary_key=True)
current_user_id = Column(Integer, ForeignKey(User.id))
current_user = relationship(User)
email_address = Column(UnicodeText)
@exposed
def fields(self, fields):
fields.email_address = EmailField(label='Email', required=True)
fields.password = PasswordField(label='Password', required=True)
@exposed
def events(self, events):
events.log_in = Event(label='Log in', action=Action(self.log_in))
def log_in(self):
user = Session.query(User).filter_by(email_address=self.email_address).one()
if user.matches_password(self.password):
self.current_user = user
else:
raise InvalidPassword()
The responsibility of a LoginSession starts by keeping track of who is logged in. That is why it has a .current_user attribute. Since LoginSession is a persisted object itself, it is trivial to make it hold onto another persisted object (User) via a relationship.
The other responsibility of the LoginSession is to actually log someone in. The logic of doing this is quite easily followed by looking at the implementation of the .log_in() method.
Just one trick: the email_address and password needed to actually
log someone in have to be provided by a user. That is why these are
Field
s on the LoginSession – they are to be linked to Inputs on the
user interface. (The same goes for the log_in Event
.)
To complete the model itself, here is the implementation of User. Because only the paranoid survive, we’ve done the right thing and refrained from storing our User passwords in clear text. Besides that, there’s really nothing much unexpected to the User class:
class User(Base):
__tablename__ = 'sessionscope_user'
id = Column(Integer, primary_key=True)
email_address = Column(UnicodeText, nullable=False)
name = Column(UnicodeText, nullable=False)
password_md5 = Column(String, nullable=False)
def set_password(self, password):
self.password_md5 = self.password_hash(password)
def matches_password(self, password):
return self.password_md5 == self.password_hash(password)
def password_hash(self, password):
return hashlib.md5(password.encode('utf-8')).hexdigest()
Using a session scoped object¶
In this example, the current LoginSession is needed for two
purposes. On the home page, we need to display who is logged in. On
the login page, we need the LoginSession to be able to link its
.fields and .events to Widget
s on that page.
In this example, we obtain the current LoginSession at the beginning
of .assemble() of the UserInterface
, and send it around as argument to the
login page. Most of that is old hat for you, except the bit about how
to obtain the LoginSession for the current UserSession. The
@session_scoped decorator adds a method to the class it decorates
for this purpose. LoginSession.for_current_session() will check
whether there is a LoginSession for the current UserSession and create
one if necessary. Either way it returns what you expect:
class SessionScopeUI(UserInterface):
def assemble(self):
login_session = LoginSession.for_current_session()
if login_session.current_user:
user_name = login_session.current_user.name
else:
user_name = 'Guest'
home = self.define_view('/', title='Home')
home.set_slot('main', P.factory(text='Welcome %s' % user_name))
login_page = self.define_view('/login', title='Log in')
login_page.set_slot('main', LoginForm.factory(login_session))
bookmarks = [i.as_bookmark(self) for i in [home, login_page]]
self.define_page(MenuPage, bookmarks)
Dealing with DomainExceptions¶
This example provides the perfect place for introducing the concept of
a DomainException
. A DomainException
is an exception that can be raised
to communicate a domain-specific error condition to the user. A
DomainException
can be raised during the execution of an Action
.
In this example it may happen that a user supplies an incorrect password. That’s why an InvalidPassword exception is raised in this case inside the .log_in() method of LoginSession.
When a DomainException
is raised, this poses some more
UserSession-related problems for the framework. Understanding some of
the underlying implementation is necessary to explain this well. So,
please bear with us going down that route:
To display the login page, the web browser issues a request to the
server to GET that page. The browser then displays the response to
the user. Next, the user proceeds to type an email address and
incorrect password, and hits the ‘Log in’ button. The browser
communicates this Event
to the server by issuing a POST request to the
server, sending the supplied information with. During the processing of
this request, our code raises the InvalidPassword exception.
When a DomainException
is raised, the Reahl framework ignores all
Transition
s defined. Instead, it takes note of the exception that
happened as well as the input supplied by the user. Then it instructs
the browser to do its initial GET request again. (Another way of
saying this is that the user is Transitioned back to the same View
.)
Upon the second GET, the login page is rendered to the user again. However, since the framework made a note of the values typed (and, incidentally, whether they were valid or not) the same values appear pre-filled-in for the user again.
The exception (which was also noted down) is available too, and a Reahl programmer should check whether there is currently an exception and render its messsage if need be. See how this is done in the implementation of LoginForm below.
class LoginForm(Form):
def __init__(self, view, login_session):
super(LoginForm, self).__init__(view, 'login')
if self.exception:
self.add_child(ErrorFeedbackMessage(view, self.exception.as_user_message()))
self.add_child(LabelledBlockInput(TextInput(self, login_session.fields.email_address)))
self.add_child(LabelledBlockInput(PasswordInput(self, login_session.fields.password)))
self.define_event_handler(login_session.events.log_in)
self.add_child(Button(self, login_session.events.log_in))
Can you spot the UserSession at work here? The input values and an exception were available during the POST, but has to be saved in session scope for them to be retrievable in a subsequent GET request again.
The easy (but wrong) way around the problem could be for the framework to send back a rendition of the same page (including exception) in response to the POST request. That way all the information it needs to re-render the login page would still have been available without the need for a UserSession. This solution, however is not what was intended by the designers of the HTTP standard, and hence can cause the back-button of the browser to behave strangely. It could even result in users submitting the same form twice by mistake (not a nice thing if the form instructs the system to pay someone some money...). See a more detailed discussion of this elsewhere.
The final example¶
Here is the complete code for the example, and a test putting it through its paces:
from __future__ import print_function, unicode_literals, absolute_import, division
import hashlib
from sqlalchemy import Column, ForeignKey, Integer, UnicodeText, String
from sqlalchemy.orm import relationship
from reahl.sqlalchemysupport import Session, Base, session_scoped
from reahl.component.exceptions import DomainException
from reahl.web.fw import UserInterface
from reahl.web.ui import HTML5Page, Form, TextInput, LabelledBlockInput, Button, Panel, P, H, InputGroup, Menu,\
HorizontalLayout, PasswordInput, ErrorFeedbackMessage
from reahl.web.pure import PageColumnLayout, UnitSize
from reahl.component.modelinterface import Action
from reahl.component.modelinterface import EmailField
from reahl.component.modelinterface import Event
from reahl.component.modelinterface import PasswordField
from reahl.component.modelinterface import exposed
class User(Base):
__tablename__ = 'sessionscope_user'
id = Column(Integer, primary_key=True)
email_address = Column(UnicodeText, nullable=False)
name = Column(UnicodeText, nullable=False)
password_md5 = Column(String, nullable=False)
def set_password(self, password):
self.password_md5 = self.password_hash(password)
def matches_password(self, password):
return self.password_md5 == self.password_hash(password)
def password_hash(self, password):
return hashlib.md5(password.encode('utf-8')).hexdigest()
@session_scoped
class LoginSession(Base):
__tablename__ = 'sessionscope_loginsession'
id = Column(Integer, primary_key=True)
current_user_id = Column(Integer, ForeignKey(User.id))
current_user = relationship(User)
email_address = Column(UnicodeText)
@exposed
def fields(self, fields):
fields.email_address = EmailField(label='Email', required=True)
fields.password = PasswordField(label='Password', required=True)
@exposed
def events(self, events):
events.log_in = Event(label='Log in', action=Action(self.log_in))
def log_in(self):
user = Session.query(User).filter_by(email_address=self.email_address).one()
if user.matches_password(self.password):
self.current_user = user
else:
raise InvalidPassword()
class MenuPage(HTML5Page):
def __init__(self, view, main_bookmarks):
super(MenuPage, self).__init__(view, style='basic')
self.use_layout(PageColumnLayout(('main', UnitSize('1/2'))))
self.layout.header.add_child(Menu.from_bookmarks(view, main_bookmarks).use_layout(HorizontalLayout()))
class InvalidPassword(DomainException):
def as_user_message(self):
return 'The email/password given do not match.'
class LoginForm(Form):
def __init__(self, view, login_session):
super(LoginForm, self).__init__(view, 'login')
if self.exception:
self.add_child(ErrorFeedbackMessage(view, self.exception.as_user_message()))
self.add_child(LabelledBlockInput(TextInput(self, login_session.fields.email_address)))
self.add_child(LabelledBlockInput(PasswordInput(self, login_session.fields.password)))
self.define_event_handler(login_session.events.log_in)
self.add_child(Button(self, login_session.events.log_in))
class SessionScopeUI(UserInterface):
def assemble(self):
login_session = LoginSession.for_current_session()
if login_session.current_user:
user_name = login_session.current_user.name
else:
user_name = 'Guest'
home = self.define_view('/', title='Home')
home.set_slot('main', P.factory(text='Welcome %s' % user_name))
login_page = self.define_view('/login', title='Log in')
login_page.set_slot('main', LoginForm.factory(login_session))
bookmarks = [i.as_bookmark(self) for i in [home, login_page]]
self.define_page(MenuPage, bookmarks)
from __future__ import print_function, unicode_literals, absolute_import, division
from reahl.tofu import test, set_up
from reahl.sqlalchemysupport import Session
from reahl.web_dev.fixtures import WebFixture
from reahl.webdev.tools import Browser, XPath
from reahl.doc.examples.tutorial.sessionscope.sessionscope import SessionScopeUI, User
class SessionScopeFixture(WebFixture):
def new_browser(self):
return Browser(self.new_wsgi_app(site_root=SessionScopeUI))
password = 'bobbejaan'
def new_user(self):
user = User(name='John Doe', email_address='[email protected]')
user.set_password(self.password)
Session.add(user)
return user
class DemoFixture(SessionScopeFixture):
commit=True
@set_up
def make_stuff(self):
self.user
Session.commit()
@test(SessionScopeFixture)
def logging_in(fixture):
"""A user can log in by going to the Log in page.
The name of the currently logged in user is displayed on the home page."""
browser = fixture.browser
user = fixture.user
browser.open('/')
browser.click(XPath.link_with_text('Log in'))
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.type(XPath.input_labelled('Password'), 'bobbejaan')
browser.click(XPath.button_labelled('Log in'))
browser.click(XPath.link_with_text('Home'))
assert browser.is_element_present(XPath.paragraph_containing('Welcome John Doe'))
@test(SessionScopeFixture)
def email_retained(fixture):
"""The email address used when last logged in is always pre-populated on the Log in page."""
browser = fixture.browser
user = fixture.user
browser.open('/')
browser.click(XPath.link_with_text('Log in'))
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.type(XPath.input_labelled('Password'), 'bobbejaan')
browser.click(XPath.button_labelled('Log in'))
# Go away from the page, then back
browser.click(XPath.link_with_text('Home'))
browser.click(XPath.link_with_text('Log in'))
# .. then the email is still pre-populated
typed_value = browser.get_value(XPath.input_labelled('Email'))
assert typed_value == '[email protected]'
@test(SessionScopeFixture)
def domain_exception(fixture):
"""Typing the wrong password results in an error message being shown to the user."""
browser = fixture.browser
user = fixture.user
browser.open('/')
browser.click(XPath.link_with_text('Log in'))
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.type(XPath.input_labelled('Password'), 'wrong password')
browser.click(XPath.button_labelled('Log in'))
assert browser.is_element_present(XPath.paragraph_containing('The email/password given do not match'))