Implementing Web Interaction Using Eliom

The code of this tutorial has been tested with the 2.1 release of the Ocsigen bundle.

This chapter is a tutorial explaining how to create a small Web site with several pages, users, sessions, etc. Then, in next chapter, we will incorporate the features of this site with the program from the previous chapter, to show that we can mix this kind of interaction with client-side programs.

We assume you have read at least the first section of the previous chapter, which explained how to create a service and constuct valid HTML pages.

We will create a simple Web site with one main page and a page for each user (assuming we have several users already created). Then we will add a login/connection form. We will also add a registration form, in order to learn how to create dynamically new services, and why it is very useful.

The full code of the program can be downloaded.

Services

The main page

Let's start again from scratch with the following site.

open Eliom_pervasives
open HTML5

let main_service =
  Eliom_output.Html5.register_service
    ~path:[""] ~get_params:Eliom_parameters.unit
    (fun () () ->
      Lwt.return
        (html (head (title (pcdata "")) [])
                       (body [h1 [pcdata "Hello"]])))

Note that we are using Eliom_output.Html5, as we are not building a client side program for now.

Put this example in a file (tuto.ml), compile it like so:

eliomc -c tuto.ml

Finally, modify your configuration file as explained in the previous chapter to load the file tuto.cmo. Then launch Ocsigen to run the site.

Adding a page for each user

We will now create a page for each user. To do this, we will create a new service, taking the user name as parameter:

let user_service =
  Eliom_output.Html5.register_service
    ~path:[""] ~get_params:(Eliom_parameters.string "name")
    (fun name () ->
      Lwt.return
        (html (head (title (pcdata name)) [])
                       (body [h1 [pcdata name]])))

Add these lines to the same file, compile, start the server and verify that everything is working by trying, for example: http://localhost:8080/?name=toto.

For our program, we would prefer to take one part of the URL path as the parameter describing the name of the user. I change the definition of my service this way:

let user_service =
  Eliom_output.Html5.register_service
    ~path:["users"]
    ~get_params:
       (Eliom_parameters.suffix (Eliom_parameters.string "name"))
    (fun name () -> ... )

The user pages are now available at URLs http://localhost:8080/users/username.

Links

We now want to add a link on each user page to go back to the main page.

Change the handler of user_service into:

(fun name () -> Lwt.return
  (html
    (head (title (pcdata name)) [])
    (body [h1 [pcdata name];
           p [Eliom_output.Html5.a
                ~service:main_service [pcdata "Home"] ()]])))

Links towards services with parameters

In our example above, the last parameter is () because the service does not expect any parameter. If the service expects, for example, a pair (int * string), you must provide a matching value as last parameter. OCaml checks at compile time that the type of the parameters in a link corresponds to the type expected by the service! Also note that the parameter names are generated automatically from the service, making it impossible to erroneously create bad links.

To show an example of a link with parameters, we will display the list of user pages on the main page. Here is the new version of the full program:

open Eliom_pervasives
open HTML5
open Eliom_parameters

let main_service =
  Eliom_services.service ~path:[""] ~get_params:unit ()

let user_service =
  Eliom_services.service
    ~path:["users"] ~get_params:(suffix (string "name")) ()

(* User names and passwords: *)
let users = ref [("Calvin", "123"); ("Hobbes", "456")]

let user_links () =
  ul (List.map (fun (name, _) -> 
                  li [Eliom_output.Html5.a
                        ~service:user_service [pcdata name] name])
               !users)

let _ = 
  Eliom_output.Html5.register
    ~service:main_service
    (fun () () ->
      Lwt.return
        (html (head (title (pcdata "")) [])
              (body [h1 [pcdata "Hello"];
                     user_links ()])));

  Eliom_output.Html5.register
    ~service:user_service
    (fun name () ->
      Lwt.return
        (html (head (title (pcdata name)) [])
              (body [h1 [pcdata name];
                     p [Eliom_output.Html5.a
                          ~service:main_service [pcdata "Home"] ()]])))

Sessions

Connection service

Now I want to add a connection form. First, I will create a service for checking the name and password. Since I don't want the username and password to be shown in the URL, I will use hidden parameters (or POST parameters). Thus, I'll need to create a new service taking these parameters:

let connection_service =
  Eliom_services.post_service
    ~fallback:main_service
    ~post_params:(string "name" ** string "password")
    ()

Now you can register a handler for the new service:

Eliom_output.Html5.register
    ~service:connection_service
    (fun () (name, password) ->
      let message =
        if check_pwd name password
        then "Hello "^name
        else "Wrong name or password"
      in
      Lwt.return
        (html (head (title (pcdata "")) [])
              (body [h1 [pcdata message];
                     user_links ()])));

where check_pwd is defined by:

let check_pwd name pwd =
  try List.assoc name !users = pwd with Not_found -> false

Connection form

For now, I will add the connection form only on the main page of the site.

Let's create a function for generating the form:

let connection_box () =
  Eliom_output.Html5.post_form ~service:connection_service
    (fun (name1, name2) ->
      [fieldset
         [label ~a:[Eliom_output.Html5.a_for name1] [pcdata "login: "];
          Eliom_output.Html5.string_input ~input_type:`Text
                                          ~name:name1 ();
          br ();
          label ~a:[Eliom_output.Html5.a_for name2] [pcdata "password: "];
          Eliom_output.Html5.string_input ~input_type:`Password
                                          ~name:name2 ();
          br ();
          Eliom_output.Html5.string_input ~input_type:`Submit
                                          ~value:"Connect" ()
         ]]) ()

Now, add a call to this function in the handler of the main service (for example just before the user links).

Opening a session

Now we want to remember that the user is successfully connected. To do that we will set a reference when the user successfully connects, and we will restrict the scope of this reference to the session (that is, to the browser).

Define your Eliom reference with a default value:

let username = Eliom_references.eref ~scope:Eliom_common.session None

Here is the new connection_box function:

let connection_box () =
  lwt u = Eliom_references.get username in
  Lwt.return
    (match u with
      | Some s -> p [pcdata "You are connected as "; pcdata s]
      | None ->
        Eliom_output.Html5.post_form ~service:connection_service
          (fun (name1, name2) ->
            [fieldset
	       [label ~a:[Eliom_output.Html5.a_for name1] [pcdata "login: "];
                Eliom_output.Html5.string_input ~input_type:`Text
                                                ~name:name1 ();
                br ();
                label ~a:[Eliom_output.Html5.a_for name2] [pcdata "password: "];
                Eliom_output.Html5.string_input ~input_type:`Password
                                                ~name:name2 ();
                br ();
                Eliom_output.Html5.string_input ~input_type:`Submit
                                                ~value:"Connect" ()
               ]]) ())

... and replace the registration of the main service and the connection service by:

Eliom_output.Html5.register
    ~service:main_service
    (fun () () ->
      lwt cf = connection_box () in
      Lwt.return
        (html (head (title (pcdata "")) [])
              (body [h1 [pcdata "Hello"];
                     cf;
                     user_links ()])));

  Eliom_output.Html5.register
    ~service:connection_service
    (fun () (name, password) ->
      lwt message =
        if check_pwd name password
        then begin
          Eliom_references.set username (Some name) >>
          Lwt.return ("Hello "^name)
        end else
	  Lwt.return "Wrong name or password" in
      Lwt.return
        (html (head (title (pcdata "")) [])
              (body [h1 [pcdata message];
                     user_links ()])))

Display the usual page after connection

As you can see, our connection service is displaying a welcome page which is different from the main page in connected mode. We would rather display the same page. One solution is to call the same handler after registering session data.

A cleaner solution is to use an action, that is: a service which will just perform a side effect. Replace the registration of the connection service by:

Eliom_output.Action.register
    ~service:connection_service
    (fun () (name, password) ->
      if check_pwd name password
      then Eliom_references.set username (Some name)
      else Lwt.return ())

Now the main page is displayed after connection.

Putting a connection form on each page

Transform the connection service into a non-attached coservice:

let connection_service =
  Eliom_services.post_coservice'
    ~post_params:(string "name" ** string "password")
    ()

Now you can add the connection box on user pages.

let connection_service =
  Eliom_output.Html5.register
    ~service:user_service
    (fun name () ->
      connection_box () >>= fun cf ->
      Lwt.return
        (html (head (title (pcdata name)) [])
              (body [h1 [pcdata name];
                     cf;
                     p [Eliom_output.Html5.a
                          ~service:main_service [pcdata "Home"] ()]])));

Disconnection

To create a logout/disconnection form, we create another non-attached coservice using POST method, and register another action. We call the function Eliom_state.​discard with scope Eliom_common.​session to remove all session data.

let disconnection_service =
  Eliom_services.post_coservice' ~post_params:unit ()

let disconnect_box () =
  Eliom_output.Html5.post_form disconnection_service
    (fun _ -> [p [Eliom_output.Html5.string_input
                    ~input_type:`Submit ~value:"Log out" ()]]) ()

let _ =
  Eliom_output.Action.register
    ~service:disconnection_service
    (fun () () -> Eliom_state.discard ~scope:Eliom_common.session ())

Then add this form in the connection box:

let connection_box () =
  lwt u = Eliom_references.get username in
  Lwt.return
    (match u with
      | Some s -> div [p [pcdata "You are connected as "; pcdata s; ];
                       disconnect_box () ]
      | None -> ...

Registration of users

Basic registration form

We will now add a registration form to the application. We create a new regular service, attached to the path /registration, that displays a registration form, and an action that will add the user to the "database":

let new_user_form_service =
  Eliom_services.service ~path:["create account"] ~get_params:unit ()

let create_account_service = 
  Eliom_services.post_coservice
    ~fallback:main_service
    ~post_params:(string "name" ** string "password") ()

let create_account_form () =
  Eliom_output.Html5.post_form ~service:create_account_service
    (fun (name1, name2) ->
      [fieldset
         [label ~a:[Eliom_output.Html5.a_for name1] [pcdata "login: "];
          Eliom_output.Html5.string_input ~input_type:`Text
                                          ~name:name1 ();
          br ();
          label ~a:[Eliom_output.Html5.a_for name2] [pcdata "password: "];
          Eliom_output.Html5.string_input ~input_type:`Password
                                          ~name:name2 ();
          br ();
          Eliom_output.Html5.string_input ~input_type:`Submit
                                          ~value:"Connect" ()
         ]]) ()

let _ =
  Eliom_output.Html5.register
    ~service:new_user_form_service
    (fun () () ->
      Lwt.return
        (html (head (title (pcdata "")) [])
              (body [h1 [pcdata "Create an account"];
                     create_account_form ();
                    ])));

  Eliom_output.Action.register
    ~service:create_account_service
    (fun () (name, pwd) ->
      users := (name, pwd)::!users;
      Lwt.return ())

Then add the link to this service in the connection box:

let connection_box () =
  lwt u = Eliom_references.get username in
  Lwt.return
    (match u with
      | Some s -> div [p [pcdata "You are connected as "; pcdata s; ];
                       disconnect_box () ]
      | None ->
        div [Eliom_output.Html5.post_form ~service:connection_service
                (fun (name1, name2) ->
...
                ) ();
             p [Eliom_output.Html5.a new_user_form_service
                  [pcdata "Create an account"] ()]]
            )

Registration form with confirmation

Now we want to add a confirmation page before actually creating the account. We replace the service create_account_service by a new POST attached coservice called account_confirmation_service:

let account_confirmation_service =
  Eliom_services.post_coservice
    ~fallback:new_user_form_service
    ~post_params:(string "name" ** string "password")
    ()

and we make the account creation form point at this new service.

We register an HTML handler on this service, with the confirmation page. As a side effect, this page will create the actual account creation service:

Eliom_output.Html5.register
  ~service:account_confirmation_service
  (fun () (name, pwd) ->
    let create_account_service =
      Eliom_output.Action.register_coservice
        ~fallback:main_service
        ~get_params:Eliom_parameters.unit
        ~timeout:60.
        (fun () () ->
          users := (name, pwd)::!users;
          Lwt.return ())
    in
    Lwt.return
      (html
        (head (title (pcdata "")) [])
          (body
            [h1 [pcdata "Confirm account creation for "; pcdata name];
             p [Eliom_output.Html5.a
                  ~service:create_account_service [pcdata "Yes"] ();
                pcdata " ";
                Eliom_output.Html5.a
                  ~service:main_service [pcdata "No"] ()]
            ])))

Also remove the registration of the create_account_service service and modify the user creation form to make it points towards account_confirmation_service.

A few enhancements

Displaying a "wrong password" message

In the current version, our Web site fails silently when the password is wrong. Let's improve this behavior by displaying an error message. To do that, we need to pass information to the service occurring after the action. We record this information in an Eliom reference with scope Eliom_common.request.

Define an Eliom reference:

let wrong_pwd = Eliom_references.eref ~scope:Eliom_common.request false

Modify the connection box this way:

let connection_box () =
  lwt u = Eliom_references.get username in
  lwt wp = Eliom_references.get wrong_pwd in
  Lwt.return
    (match u with
      | Some s -> div [p [pcdata "You are connected as "; pcdata s; ];
                       disconnect_box () ]
      | None ->
        let l =
          [Eliom_output.Html5.post_form ~service:connection_service
            (fun (name1, name2) ->
              [fieldset
	         [label ~a:[Eliom_output.Html5.a_for name1] [pcdata "login: "];
                  Eliom_output.Html5.string_input ~input_type:`Text
                                                  ~name:name1 ();
                  br ();
                  label ~a:[Eliom_output.Html5.a_for name2] [pcdata "password: "];
                  Eliom_output.Html5.string_input ~input_type:`Password
                                                  ~name:name2 ();
                  br ();
                  Eliom_output.Html5.string_input ~input_type:`Submit
                                                  ~value:"Connect" ()
                 ]]) ();
             p [Eliom_output.Html5.a new_user_form_service
                  [pcdata "Create an account"] ()]]
        in
        if wp
        then div ((p [em [pcdata "Wrong user or password"]])::l)
        else div l
    )

... and modify the connection_service handler:

Eliom_output.Action.register
    ~service:connection_service
    (fun () (name, password) ->
      if check_pwd name password
      then Eliom_references.set username (Some name)
      else Eliom_references.set wrong_pwd true);

Sending 404 errors for non-existing users

Our service user_service responds to any request parameter, even if the user does not exist in the database. We want to check that the user is in the database before displaying the page, and send a 404 error if the user is not. To do that, we will replace the module My_appl by Eliom_output.​Any to register the service user_service:

Eliom_output.Any.register
  ~service:user_service
  (fun name () ->
    if List.exists (fun (n, _) -> n = name) !users
    then begin
      lwt cf = connection_box () in
      Eliom_output.Html5.send
        (html (head (title (pcdata name)) [])
              (body [h1 [pcdata name];
                    cf;
                    p [Eliom_output.Html5.a
                         ~service:main_service [pcdata "Home"] ()]]))
    end else
      Eliom_output.Html5.send
        ~code:404
        (html (head (title (pcdata "404")) [])
              (body [h1 [pcdata "404"];
                     p [pcdata "That page does not exist"]]))
    );

>

Using customized output module to simplify getting user data

When you want to assume that you have informations available in sessions, for instance when a site is mainly available to connected users, it becomes tedious to check everywhere that a reference is not None. We can build a version of a registration module to simplify that using Eliom_output.​Customize.

We first need a translation module which checks for session informations and fall back to a default page if they are not available.

module Connected_translate =
struct
  type page = string -> Eliom_output.Html5.page Lwt.t
  let translate page =
    lwt username = Eliom_references.get username in
    match username with
    | None ->
      let connection_box =
        Eliom_output.Html5.post_form ~service:connection_service
          (fun (name1, name2) ->
    	    [fieldset
	      [label ~a:[Eliom_output.Html5.a_for name1] [pcdata "login: "];
               Eliom_output.Html5.string_input ~input_type:`Text
                                               ~name:name1 ();
               br ();
               label ~a:[Eliom_output.Html5.a_for name2] [pcdata "password: "];
               Eliom_output.Html5.string_input ~input_type:`Password
                                               ~name:name2 ();
               br ();
               Eliom_output.Html5.string_input ~input_type:`Submit
                                               ~value:"Connect" ()
	     ]]) ()
      in
      Lwt.return
        (html 
	  (head (title (pcdata "")) [])
          (body [h1 [pcdata "Hello"];
	         connection_box;]))
    | Some username -> page username
end

The translate function takes a function page and apply it with the current username if available and falls back to a default login page if not.

We can now make our own registration module.

module Connected = Eliom_output.Customize(Eliom_output.Html5)
                                         (Connected_translate)

let _ = Connected.register_service 
  ~path:[""] ~get_params:unit
  (fun () () ->
    Lwt.return
     (fun username ->
        Lwt.return (html (head mytitle [])
                   (body [h1 [pcdata ("Welcome " ^ username) ]; ]))))

The type of Connected.register_service forces us to have the heavy notation: Lwt.return (fun username -> Lwt.return...). We can make it lighter using

let ( !% ) f = fun a b -> return (fun c -> f a b c)

let _ = Connected.register_service 
  ~path:[""] ~get_params:unit
  !% (fun () () username ->
    return (html (head mytitle [])
	      (body [h1 [pcdata ("Welcome " ^ username) ]; ])))