home

Continuation-Based Web Workflows, Part Two

24 Nov 2011

Get the source code for this article: https://github.com/JamesEarlDouglas/imperatively

In an earlier article, I introduced a basic way to represent multi-step Web workflows in an imperative style. The example was somewhat limited, and I have since added a few features to make it more practical.

Complex forms

Greeter screenshot 1
Forms with multiple fields are supported, which lets form-handling functions return more than just a string.
def workflow(): NodeSeq @imp = {
  val (name, age) = getNameAndAge()
  val book = getBook()

  <html>
    <body>
      <h1>Greeter</h1>
      <div>Hello { name }, you are { age } years old and your favorite book is { book }!</div>
    </body>
  </html>
}

Stateful forms

Greeter screenshot 2
Greeter screenshot 3
Greeter screenshot 4

Fields can be individually validated, and in case of an invalid form, only invalid fields need to be resubmitted. This works by storing the state of the workflow, including valid inputs, within the continuation itself.

def getNameAndAge(params: Map[String, String] = Map.empty): (String, String) @imp = {

  var inputs: Map[String, String] = Map.empty
  var errors: Map[String, String] = Map.empty
    
  var name: String = ""
  var age: String = ""
   
  validateName(params) match {
    case Left(fields) => inputs = inputs ++ fields.inputs; errors = errors ++ fields.errors
    case Right(_name)  => name = _name
  }

  validateAge(params) match {
    case Left(fields) => inputs = inputs ++ fields.inputs; errors = errors ++ fields.errors
    case Right(_age)  => age = _age
  }
    
  if (inputs.size > 0) getNameAndAge(params ++ prompt(form(inputs, errors)))
  else (name, age)
}

I like this approach because it eliminates the need to leak state by storing valid inputs in some mutable map somewhere outside the getNameAndAge function.

Session-based workflow

The state of the workflow, a continuation, is stored in an HTTP session variable so multiple users can access the workflow without stepping on each other's progress.

trait ImperativelyServlet
  extends ScalatraServlet
  with Imperatively[Map[String, String], NodeSeq] {

  def nextStep =
    if (session.contains("step")) session("step").asInstanceOf[Step]
    else (x: Map[String, String]) => imperatively

  def nextStep_=(next: Step) = session("step") = next

  get("/") { nextStep(params.toMap) }
}

Web-less workflow

The Imperatively trait no longer uses servlet-related code, and it generalizes on the workflow step input and output types, so it can be used as a general-purpose workflow engine.

trait Imperatively[A,B] {

  type Step = A => B

  type imp = cps[B]

  def nextStep: Step
  def nextStep_=(next: Step): Unit

  def prompt(b: B): A @imp =
    shift { k: (A => B) =>
      nextStep = k
      b
    }

  def imperatively: B = reset {
    val resp: B = workflow()
    nextStep = (x: A) => imperatively
    resp
  }

  def workflow(): B @imp
}