3 posts

Major OpaDo Speed-Up with @publish

I received a patch for OpaDo from Francois Regis Sinot that has made the hosted OpaDo on Dotcloud MUCH faster, adding and removing item is now instantaneous. He placed @publish around explicitly server side functions that caused adding todo items to go from 21 to 1 server calls. I thought this simple but maybe not obvious for new Opa developers (it certainly wasn't to me!) needed a blog post so that beginners like my self may find it and be able to start using the @publish directive. Check out the Opa manual for some more information on @publish, specifically I'd look at Section 4.7.4 and 10.2

Below are the changes he made. Which you can also of see in the commit diff on Github and check out the entire source.

-   @async  
-   db_make_done(username: string, id: string) =  
+   @publish @async  
+   db_make_done(id: string) =  
+       username = User.get_username()  
        items = /todo_items[username]  
        item = Option.get(StringMap.get(id, items))  
        /todo_items[username] <- StringMap.add(id, {item with done=true}, items)  
-   @async  
+   @publish @async  
    db_remove_item(id: string) =  
      username = User.get_username()  
      items = /todo_items[username]  
-   @async  
+   @publish @async  
    db_add_todo(id: string, x: string) =  
      username = User.get_username()  
      items = /todo_items[username]  
      /todo_items[username] <- StringMap.add(id, { value=x done=false created_at="" }, items)  
+   @publish  
    add_todos() =  
      username = User.get_username()  
      items = /todo_items[username]  

OpaDo: Personal ToDo Lists

This is a continuation of two past posts (one, two) on my first application with Opa called OpaDo. You can try the live demo here and check out the full source code on Github

Updating OpaDo to add user accounts the project structure has been changed a bit and modularized. Below is the new project layout.

├── Makefile  
├── dotcloud.yml  
├── resources  
│   ├── destroy.png  
│   └── todos.css  
└── src  
    ├── main.opa  
    ├── todo.opa  
    └── user.opa

Now there is a main, todo and user module. The main module is the entry point for the app and looks like:

package opado.main  
import opado.user  
import opado.todo  
urls : Parser.general_parser(http_request -> resource) =  
  | {Rule.debug_parse_string(s -> Log.notice("URL",s))} -> error("")  
  | "/todos" result={Todo.resource} -> result  
  | "/user" result={User.resource} -> result  
  | "/login" result={User.resource} -> result  
  | (.*) result={Todo.resource} -> result  
server = Server.of_bundle([@static_resource_directory("resources")])  
server = Server.make(urls)

Here we define the name of this package and import the user and todo modules. Next is the url matching code. urls is a parser that takes an HTTP request and returns a resource. The matching is pretty straight forward. For example:

  | "/todos" result={Todo.resource} -> result

Here we are matching on URLs that begin with /todos but could have anything after that. What is contained after /todos is passed to the Todo.resource which the variable result is set to. And finally that result is returned.

The last two lines simple define the reource directory for the server and pass in the matching function for the HTTP requests.

The todo resource isn't important to us in this post since its hardly changed. But there are a two important changes:

db /todo_items : stringmap(stringmap(todo_item))  
db /todo_items[_][_]/done = false

Here we see that the /todo_items database is not longer simply a stringmap of todo_item's but a stringmap of a that. This is so we can reference the items by a user identifier. For example a user identified by the string "user01" who has a todo item identified by "aaa" would be read from the data base as /todo["user01"]["aaa"].

There are a few other changes to the todo module so that items are properly inserted for the logged in user and deleting must be done in the second stringmap. But we'll move on to the user module now.

Much of the user module was taken from Matthieu Guffroy's OpaCMS code on github. But I've made a number of modification for my needs.

@abstract type User.password = string  
@abstract type User.ref = string  
type User.t =  
    username : string  
    fullname : string  
    password : User.password  
type User.status = { logged : User.ref } / { unlogged }  
type = UserContext.t(User.status)  
type'a) = ordered_map(User.ref, 'a, String.order)  
db /users :  
User_data = {{  
  mk_ref( login : string ) : User.ref =  
  ref_to_string( login : User.ref ) : string =  
  save( ref : User.ref, user : User.t ) : void =  
    /users[ref] <- user  
  get( ref : User.ref ) : option(User.t) =  

Above we have the data, types and database definitions necessary to handle the users.

User.t provides the record for storing necessary user data. Next, we have types for checking the user status of if they are logged in or not.

UserContext is a module provided by Opa for dealing with associating the user values with the client -- via cookies. And the data for that user can only accessed by the user that owns it.

User_data object provides functions for accessing and manipulating users.

Now we can look at the User module.

User = {{  
  @private state = UserContext.make({ unlogged } : User.status)  
  create(username, password) =  
    do match ?/users[username] with  
      | {none} ->  
          user : User.t =  
            { username=username ;  
              fullname="" ;  
              password = Crypto.Hash.sha2(password) }  
          /users[username] <- user  
      | _ -> void  

At the beginning of the User object we declare a UserContext and a function for creating new users. The function simply checks if the user exists already with the match statement and if not creates a new User.t record and inserts it to the users database.

If we wish to login we must also modify the UserContext

  login(login, password) =  
    useref = User_data.mk_ref(login)  
    user = User_data.get(useref)  
    do match user with  
     | {some = u} -> if u.password == Crypto.Hash.sha2(password) then  
                       UserContext.change(( _ -> { logged = User_data.mk_ref(login) }), state)  
     | _ -> void  

The function attempts to read the user from the database and checks if the passwords match. If so, it will set the UserContext to logged in. The function then tells the client to go to /todos. If the login was unsuccessful, it doesn't matter and will just redirect to the sign up page.

Obviously, better error handling and notification is the next step for the application.

The last interesting part for this I think is the request matching. The rest of the code is mostly just HTML and piecing together the functions I already described.

  resource : Parser.general_parser(http_request -> resource) =  
    | "/new" ->  
      _req -> Resource.styled_page("New User", ["/resources/todos.css"], new())  
    | "/edit" ->  
      _req -> edit()  
    | "/view/" login=(.*) ->  
      _req -> view(Text.to_string(login))  
    | .* ->  
      _req -> start()

The key match to look at is:

    | "/view/" login=(.*) ->  
      _req -> view(Text.to_string(login))

This shows the request matching /view, which in this case comes after the main module matches '/user' and routes to the User module resource. But then we have login=(.*), this is matching the variable login to the rest of the url. This variable login can then be used in view(Text.to_string(login)) to pass to the view function so it knows what user is being asked to be displayed.

There'll be more to come. Next, I need to add some validation, an admin page and then the ability for users to have categories to organize their todo items under.

And let me know anything else people would like to see!

OpaDo Data Storage

OpaDo (a port of the TodoMVC app to Opa) now persists todo items to the Opa database. The new version is up on dotcloud,

I've added a todo_item type which stores the item's value and two other attributes we won't use until the next post when we have user accounts for their own todo_item stores.

type todo_item = { user_id : string  
                 ; value : string  
                 ; created_at : string  

To tell Opa where to store the records we'll create, we provide a path to the Opa db function and set its type. For our todo items we use a stringmap since currently the id's are randomly generated strings (I know, I know, but its just an example!). We can then reference a record in the database with the path /todo_item[some_id_string].

db /todo_items : stringmap(todo_item)

Now we can insert todo_item's to this db path as so:

/todo_items[id] <- { value=x user_id="" created_at="" }

For now user_id and created_at are empty, but I'll be updating that when I add user accounts.

Since we are storing each item, we need to populate the list on page load with whats already stored:

add_todos() =  
  items = /todo_items  
  StringMap.iter((x, y -> add_todo_to_page(x, y.value)), items)

The first line of the function sets the variable items to all the todo_item records in the database. We use StringMap.iter to take each todo_item and add it to the page. The first argument to the anonymous function is the id the item is stored in the database with (the id we will use in the HTML as well) and the second is the actual todo_item, so we take its value field and pass that to the add_todo_to_page function along with the id.

To have the add_todos function when the list element is ready we add an on_ready attribute that will call add_todos:

<ul id=#todo_list onready={_ -> add_todos() } ></ul>

Lastly, we want to be able to delete a todo_item from the database:

remove_item(id: string) =  
  do Dom.remove(Dom.select_parent_one(#{id}))  
  do Db.remove(@/todo_items[id])  
remove_all_done() =  
  Dom.iter(x -> remove_item(Dom.get_id(x)), Dom.select_class("done"))

The main piece to notice here is @/todo_items[id] in Db.remove(). The @ is saying that we are passing the path itself to remove() and not the value at that path.

Nice and easy! No database to setup or deploy, just Opa. Next time we'll add user accounts, so we don't have to all share the same todo list.