In this section we will complement the Bed Nap summary page with a view page for displaying visit details. To do so we will need to convert the request URL into meaningful Visit objects.

We have an index / summary page that lists all the visits. Next we'll add a details page that will display all the information for a given visit.

To do this we need to identify each Visit, so we'll give each one an ID. Then we'll create a view page that responds to a RESTful URL in the format:

/view/<visit-id>

That way, just by changing the URL, we can view the details of different visits. It also means each visit page is bookmarkable.

Similar to how it may work if our VisitService was backed by a database, we'll automatically generate our IDs from a numeric sequence.

Update Visit Entity

Entity objects should always know who they are (you know who you are, right!?) so we'll add an ID field to Visit.

To allow a Visit entity to be created before it is saved by the service, we will make the ID field optional / nullable. We'll then add an optional / default ID parameter to the ctor. This means we don't have to change our sample visit data code.

select all
const class Visit {
    const Int?    id
    const Str    name
    const Date    date
    const Int    rating
    const Str    comment

    new make(Str name, Date date, Int rating, Str comment, Int? id := null) {
        this.id      = id
        this.name    = name
        this.date    = date
        this.rating  = rating
        this.comment = comment
    }
}

Update Visit Service

In our VisitService we'll change the SynchronizedList to a SynchronizedMap so we can easily retrieve individual Visit objects by using the ID as a key.

The save() method will check to see the given Visit entity has an ID or not. If not, it will clone the entity and set a newly generated ID at the same time.

Note we are only cloning the entity because it is const, and it is only const because it is being stored in a different thread via SynchronizedMap. If VisitService were to be backed by a database or other means, Visit would probably be non-const and we'd just set the ID on the object.

We'll use a simple AtomicInt to assign sequential and numerical IDs.

VisitService now looks like:

select all
using concurrent::AtomicInt
using afConcurrent::SynchronizedMap
using afIoc::Inject

const class VisitService {
    @Inject { id="bednap.visits"; type=Int:Visit# }
    private const SynchronizedMap   visits
    private const AtomicInt         lastId := AtomicInt()

    new make(|This| f) { f(this) }

    Visit[] all() {
        visits.vals
    }

    Void save(Visit visit) {
        if (visit.id == null) {
            nextId := lastId.incrementAndGet
            visit   = Visit(visit.name, visit.date, visit.rating, visit.comment, nextId)
        }

        visits[visit.id] = visit
    }

    Visit get(Int id) {
        visits[id]
    }
}

Create View Page

Our ViewPage code will look very similar to IndexPage except the render() method will take the ID of the entity to render. The ID will simply be the last path segment from request URL.

Note how we turn the ID into an Int so we can query the VisitService with it.

The returned Visit object is used to populate the HTML. In this fashion, the ViewPage is used as a template for ALL the visit pages.

select all
using afIoc::Inject
using afBedSheet::Text
using web::WebOutStream

const class ViewPage {

    @Inject private const VisitService visitService

    new make(|This|in) { in(this) }

    Text render(Str strId) {
        id      := strId.toInt
        visit   := visitService.get(id)

        htmlBuf := StrBuf()
        html    := WebOutStream(htmlBuf.out)

        html.docType5
        html.html
        html.head
        html.title.w("Bed Nap View Page").titleEnd
        html.headEnd
        html.body

        html.h1.w("Bed Nap Tutorial").h1End
        html.h2.w("Visit View Page").h2End

        html.div.w("${visit.name} said:").divEnd
        html.div.w(visit.comment).divEnd
        html.div.w("on ${visit.date}").divEnd
        html.div.w("${visit.rating} / 5 stars").divEnd

        html.div
        html.a(`/`).w("< Back").aEnd
        html.divEnd

        html.tableEnd
        html.bodyEnd
        html.htmlEnd

        return Text.fromHtml(htmlBuf.toStr)
    }
}

Update Routing

Now we have to tell BedSheet how to route URLs to our new ViewPage. Following the documentation for Route we can convert a segment of the request URL into a method parameter by using /** notation. So we'll update our AppModule:

@Contribute { serviceType=Routes# }
Void contributeRoutes(Configuration config) {
    config.add(Route(`/`,        IndexPage#render))
    config.add(Route(`/view/**`, ViewPage#render))
}

Run It

We can now run the Bed Nap web app and view our new detail page. If we go to http://localhost:8069/view/2 we should see:

Bed Nap View Page

Wow! Stunning!

Now, what if we type in a URL for a Visit entity that doesn't exist? Like http://localhost:8069/view/69?

Or what if we type in a non-numeric Visit ID, like http://localhost:8069/view/Oops!?

Bed Nap Error Page

Urgh! A 500 Error Page!

Not very nice. In the ValueEncoders section we'll see an easy way to avoid this.

Tidy Up

Our web application works - but it could be better!

TypeCoercing

When passing method arguments to Route Handlers, such as our page render() methods, BedSheet is clever about it. Using TypeCoercer from BeanUtils it tries to convert the Str segments from the request URL into whatever is needed by the route handlers.

That means our render() method could just take an Int as the ID parameter:

Text render(Int id) {
    visit := visitService.get(id)

    ...
}

The conversion from Str to Int has now been taken out of our hands. So what happens now if we access a ViewPage with a non-numerical ID? Let's try it:

Bed Nap 404 Page in Development Mode

Wow, a 404 page! That's exactly what we want. We tried to access information about a Visit that doesn't exist. And because nothing exists for that particular URL, a 404 should be returned to the browser.

Don't worry about all that debug information being returned to your users. BedSheet only adds that when it is run in development mode. Try running BedSheet in production mode and refresh your browser.

Hint, change the -env parameter in Main.main(), or just omit it entirely. (BedSheet defaults to production mode when no env parameter is supplied.)

BsMain().main("-env prod bednap 8069".split)

Bed Nap 404 Page in Production Mode

ValueEncoders

Having URL segments type coerced to fit our method parameters is nice, but what if we could go further? What if BedSheet could just pass us our Visit entity?

Well, BedSheet can! We just need to tell it how! And that's the job of a ValueEncoder. A ValueEncoder converts an object to and from a Str. So we'll write one for converting Visit objects:

select all
using afIoc::Inject
using afBedSheet::ValueEncoder

const class VisitEncoder : ValueEncoder {

    @Inject private const VisitService visitService

    new make(|This|in) { in(this) }

    override Str toClient(Obj? value) {
        visit := (Visit) value
        return visit.id.toStr
    }

    override Obj? toValue(Str clientValue) {
        id := clientValue.toInt
        return visitService.get(id)
    }
}

We can use IoC injection because we're going to autobuild an instance of VisitEncoder it when we contribute it to the ValueEncoders service in our AppModule:

@Contribute { serviceType=ValueEncoders# }
Void contributeValueEncoders(Configuration config) {
    config[Visit#] = config.autobuild(VisitEncoder#)
}

Note how the config key is the object type that the encoder converts.

Our ViewPage now becomes greatly simplified, for it no longer needs to reference VisitService:

select all
using afBedSheet::Text
using web::WebOutStream

const class ViewPage {

    Text render(Visit visit) {
        htmlBuf := StrBuf()
        html    := WebOutStream(htmlBuf.out)

        ...

        return Text.fromHtml(htmlBuf.toStr)
    }
}

Because BedSheet is now handling the entire conversion of a URL Str to a Visit instance, even out of range Int IDs now return a 404 page (and not an error page).

Bed Nap 404 Page in Production Mode

Update Index Page

As we've been concentrating on the ViewPage we've neglected the IndexPage! The summary table needs to be updated with links to the individual detail pages. This is where it becomes handy to have the ID bundled with the entity.

select all
Text render() {
    htmlBuf := StrBuf()
    html    := WebOutStream(htmlBuf.out)

    ...

    visitService.all.each {
        html.tr
        html.td.w(it.name).tdEnd
        html.td.w(it.date).tdEnd
        html.td.w(it.rating).tdEnd
        html.td
        html.a(`/view/${it.id}`).w("view").aEnd
        html.tdEnd
        html.trEnd
    }

    ...

    return Text.fromHtml(htmlBuf.toStr)
}

Bed Nap Index Page

Source Code

All the source code for this tutorial is available on the Bed Nap Tutorial Bitbucket Repository.

Code for this particular article is available on the 04-IDs-and-ValueEncoders branch.

Use the following commands to check the code out locally:

C:\> hg clone https://bitbucket.org/fantomfactory/bed-nap-tutorial
C:\> cd bed-nap-tutorial
C:\> hg update 04-IDs-and-ValueEncoders

Don't forget, you can trial the finished tutorial application at Bed Nap.

Have fun!

Edits

  • 4 Aug 2016 - Updated tutorial to use BedSheet 1.5 & IoC 3.0.
  • 4 Aug 2015 - Updated tutorial to use BedSheet 1.4.
  • 29 Aug 2014 - Original article.


Discuss