A quick tutorial on how to create a mini web application with Fantom's Wisp.

A guide to serving web pages, posting forms, and uploading files.

Using Wisp is Fantom web programming at its most basic level. You have to do everything manually yourself from URL routing, through to setting the return Content-Type HTTP header.

It is the bare bones entry level API that is accessible to everyone, and as such, is useful to know before you move on to more powerful frameworks such as BedSheet and Pillow.

wisp and web are part of the core Fantom libraries and as such are bundled with SkySpark. So this tutorial may also interest SkySpark developers writing extensions.

For reference this tutorial was written with Fantom 1.0.69 and assumes the reader is already able to set up a Fantom project, build .pod files, and run code.

Contents

  1. Starting Wisp
  2. Serving Web Pages
  3. Posting Forms
  4. Uploading Files
  5. Tidy Up - Use WebOutStream
  6. Complete Example

Starting Wisp

Note that SkySpark developers don't need to worry about starting Wisp, as SkySpark obviously does that for us.

Wisp is Fantom's web server. To start it we're going to have our Main class extend util::AbstractMain so we can use its runServices() method.

When creating a WispService we need to pass in an instance of a WebMod. It is the WebMods responsibility to serve up web content. For now, we're just going to have every GET request return the text Hello Mum!. We do this by overriding the onGet() method.

select all
using util::AbstractMain
using wisp::WispService
using web::WebMod

class Main : AbstractMain {
    override Int run() {
        runServices([WispService {
            it.httpPort = 8069
            it.root = MyWebMod()
        }])
    }
}

const class MyWebMod : WebMod {
    override Void onGet() {
        res.headers["Content-Type"] = "text/plain"
        res.out.print("Hello Mum!")
    }
}

Running the program and pointing a browser at http://localhost:8069/ then gives us:

Hello Mum! - plain text

2. Serving Web Pages

As great as plain text is, we need to serve up HTML pages. This can be accomplished by changing the returned Content-Type header to text/html.

We also need the ability to serve different pages from different URLs. A simple switch statement on the URL path offers basic routing and the ability to send 404 responses.

Our MyWebMod class now looks like:

select all
const class MyWebMod : WebMod {
    override Void onGet() {
        url := req.modRel.pathOnly
        switch (url) {
            case `/`:
                onGetIndexPage()

            default:
                res.sendErr(404)
        }
    }

    Void onGetIndexPage() {
        res.headers["Content-Type"] = "text/html"
        res.out.print("<!DOCTYPE html>
                       <html>
                       <head>
                           <title>Wisp Example</title>
                       </head>
                       <body>
                           <h1>Hello Mum!</h1>
                       </body>
                       </html>")
    }
}

Which serves up a page like this:

Hello Mum! - html

3. Posting Forms

Now lets post data to the server. To do that, we're going to change the index page html to incorporate a form:

select all
<!DOCTYPE html>
<html>
<head>
    <title>Post Form Example</title>
</head>
<body>
    <h1>Post Form</h1>
    <form method='POST' action='/postForm'>
        <label for='name'>Name</label>
        <input type='text' name='name'>
        <br/>
        <label for='beer'>Beer</label>
        <input type='text' name='beer'>
        <br/>
        <input type='submit' />
    </form>
</body>
</html>

Note the method attribute in the HTML form, method="POST". This means the form will be posted to the server. If it were GET then the form values would be sent up as a query string.

Also note the action attribute in the HTML form, action="/postForm". This is the URL that the form will be posted to, and need to be handled on the server.

Our page now looks like:

Post Form

To handle a POST to /postForm we need to override the onPost() method. We'll write another switch statement, just as we did for onGet():

select all
override Void onPost() {
    url := req.modRel.pathOnly
    switch (url) {
        case `/postForm`:
            onPostForm()  // <-- need to implement this method

        default:
            res.sendErr(404)
    }
}

Now we'll implement onPostForm() and have it print out our form values. To access the form values, use WebRes.form() which is just a handy string map of name / value pairs. Note that all submitted form values are of type Str.

select all
Void onPostForm() {
    name := req.form["name"]
    beer := req.form["beer"]

    res.headers["Content-Type"] = "text/html"
    res.out.print("<!DOCTYPE html>
                   <html>
                   <head>
                       <title>Post Form Values</title>
                   </head>
                   <body>
                       <p>Hello <b>${name}</b>, you like <b>${beer}</b>!</p>
                   </body>
                   </html>")
}

If we submit our form, we should now see:

Post Form Values

4. Uploading Files

As we saw, posting forms was easy. But how about uploading files? In other languages, such as Java, this can be notoriously tricky. But as you'll see, here in Fantom land it's still pretty simple.

First we need to add a file input to our form.

And note that for file uploads it is mandatory that we change form encoding by adding the attribute, enctype="multipart/form-data".

select all
<!DOCTYPE html>
<html>
<head>
    <title>Post Form Example</title>
</head>
<body>
    <h1>Post Form</h1>
    <form method='POST' action='/postForm' enctype='multipart/form-data'>
        <label for='name'>Name</label>
        <input type='text' name='name'>
        <br/>
        <label for='beer'>Beer</label>
        <input type='text' name='beer'>
        <br/>
        <label for='photo'>Photo</label>
        <input type='file' name='photo'>
        <br/>
        <input type='submit' />
    </form>
</body>
</html>

Which renders:

Upload File

Handling form content with uploaded files on the server is a little more complicated due to the encoding. We can no longer use the handy WebReq.form() map, instead we have to use WebReq.parseMultiPartForm(). It takes a function which is invoked for every input in the form, both file uploads and normal values. The function gives us the input name, a stream of raw data, and any meta associated with the input.

Our onPostForm() now looks like:

select all
Void onPostForm() {
    name      := null as Str
    beer      := null as Str
    photoName := null as Str
    photoBuf  := null as Buf

    req.parseMultiPartForm |Str inputName, InStream in, Str:Str headers| {
        if (inputName == "name")
            name = in.readAllStr

        if (inputName == "beer")
            beer = in.readAllStr

        if (inputName == "photo") {
            quoted   := headers["Content-Disposition"]?.split(';')?.find { it.lower.startsWith("filename") }?.split('=')?.getSafe(1)
            photoName = quoted == null ? null : WebUtil.fromQuotedStr(quoted)
            photoBuf  = in.readAllBuf
        }
    }

    res.headers["Content-Type"] = "text/html"
    res.out.print("<!DOCTYPE html>
                   <html>
                   <head>
                       <title>Post Form Values</title>
                   </head>
                   <body>
                       <p>
                           Hello <b>${name}</b>, you like <b>${beer}</b>!
                           The photo <b>${photoName}</b> looks like:
                       </p>
                       <img src='data:${photoName.toUri.mimeType};base64,${photoBuf.toBase64}'>
                   </body>
                   </html>")
}

We read the raw file data in as a Buf, and then spit it out as in inline Base64 encoded image. But you could do whatever you like with it; read CSV values, save it to a database...

The long line of code that grabs the quoted string:

quoted := headers["Content-Disposition"]?.split(';')?.find {
    it.lower.startsWith("filename")
}?.split('=')?.getSafe(1)

is a means to parse the Content-Disposition header, which looks like this:

Content-Disposition: form-data; name="photo"; filename="beer.png"

As you can see, it contains the name of the uploaded file which can be very handy. Note that browsers typically only send up a file name and not the entire path. This is a security consideration so servers don't learn anything of the client computer.

The net result is that we end up with this web page:

Upload File Values

Tidy Up - Use WebOutStream

Not every one is a fan of long, multi-line strings for rendering HTML. An alternative is to use the print methods on the WebOutStream. It's a means to print HTML in a more programmatic way without having to worry about leading tabs and spaces.

Here is our onGetIndexPage() method refactored to make use of WebOutStream:

select all
Void onGetIndexPage() {
    res.headers["Content-Type"] = "text/html"
    out := res.out
    out.docType5
    out.html
    out.head
        out.title.w("Post Form Example").titleEnd
    out.headEnd
    out.body
        out.h1.w("Post Form").h1End
        out.form("method='POST' action='/postForm' enctype='multipart/form-data'")
            out.label("for='name'").w("Name").labelEnd
            out.input("type='text' name='name'")
            out.br
            out.label("for='beer'").w("Beer").labelEnd
            out.input("type='text' name='beer'")
            out.br
            out.label("for='photo'").w("Photo").labelEnd
            out.input("type='file' name='photo'")
            out.br
            out.submit
        out.formEnd
    out.bodyEnd
    out.htmlEnd
}

I would say that using WebOutStream is no better or worse than multi-line strings, just different.

6. Complete Example

Below is the complete example that:

  • Starts the Wisp web server
  • Has basic routing for page URLs
  • Displays a HTML index page, complete with a file upload form
  • Has basic routing for form posts
  • Parses uploaded form data
  • Displays the data in a new HTML page
  • Uses WebOutStream
select all
using util::AbstractMain
using wisp::WispService
using web::WebMod
using web::WebUtil

class MainWisp : AbstractMain {
    override Int run() {
        runServices([WispService {
            it.httpPort = 8069
            it.root = MyWebMod()
        }])
    }
}

const class MyWebMod : WebMod {
    override Void onGet() {
        url := req.modRel.pathOnly
        switch (url) {
            case `/`:
                onGetIndexPage()

            default:
                res.sendErr(404)
        }
    }

    override Void onPost() {
        url := req.modRel.pathOnly
        switch (url) {
            case `/postForm`:
                onPostForm()

            default:
                res.sendErr(404)
        }
    }

    Void onGetIndexPage() {
        res.headers["Content-Type"] = "text/html"
        out := res.out
        out.docType5
        out.html
        out.head
            out.title.w("Post Form Example").titleEnd
        out.headEnd
        out.body
            out.h1.w("Post Form").h1End
            out.form("method='POST' action='/postForm' enctype='multipart/form-data'")
                out.label("for='name'").w("Name").labelEnd
                out.input("type='text' name='name'")
                out.br
                out.label("for='beer'").w("Beer").labelEnd
                out.input("type='text' name='beer'")
                out.br
                out.label("for='photo'").w("Photo").labelEnd
                out.input("type='file' name='photo'")
                out.br
                out.submit
            out.formEnd
        out.bodyEnd
        out.htmlEnd
    }

    Void onPostForm() {
        name      := null as Str
        beer      := null as Str
        photoName := null as Str
        photoBuf  := null as Buf

        req.parseMultiPartForm |Str inputName, InStream in, Str:Str headers| {
            if (inputName == "name")
                name = in.readAllStr

            if (inputName == "beer")
                beer = in.readAllStr

            if (inputName == "photo") {
                quoted   := headers["Content-Disposition"]?.split(';')?.find { it.lower.startsWith("filename") }?.split('=')?.getSafe(1)
                photoName = quoted == null ? null : WebUtil.fromQuotedStr(quoted)
                photoBuf  = in.readAllBuf
            }
        }

        res.headers["Content-Type"] = "text/html"
        out := res.out
        out.docType5
        out.html
        out.head
            out.title.w("Post Form Values").titleEnd
        out.headEnd
        out.body
            out.p
                out.w("Hello ").b.w(name).bEnd.w(", you like ").b.w(beer).bEnd.w("! ")
                out.w("The photo ").b.w(photoName).bEnd.w(" looks like:")
            out.pEnd
            out.img(`data:${photoName.toUri.mimeType};base64,${photoBuf.toBase64}`)
        out.bodyEnd
        out.htmlEnd
    }
}

Have fun!

Edits

  • 30 Dec 2016 - Original article.


Discuss