Pragmatism in the real world

Kitura tutorial part 5: Hypermedia

In part 4, we looked at configuration, so lets turn our attention back to the API endpoints. We currently have two endpoints in our API: / and /books. While it’s useful to get the entire collection of books, maybe we only want one book. To do this we need a new endpoint: /books/{some unique string}.

In order for the client to know that the URL is to a specific book, we need to tell it by adding a link property to the collection (/books). This is known as Hypermedia.

Adding hypermedia to /books

We’re going to add a structured data format to our API now. For this API, I’ve picked HAL as it provides all the features we need and is reasonably easy to understand. A good explanation of it can be found in Apigility’s HAL primer.

To implement HAL in our response we need to do a number of things:

  1. Add an _links section to the response and include a self link.
  2. Rename the books key to _embedded.
  3. Add an _links section to each book resource within _embedded, again with a self link.
  4. Change the Content-Type to application/hal+json

Let’s take each of these in turn and update the handler:

To add a link section the response we can add it to the JSON object created in our ListBooksHandler. However we need the fully qualified URL.

Sources/BookshelfAPI/ListBooksHandler.swift

After var json = JSON([:]), add:

    let baseURL = "http://" + (request.headers["Host"] ?? "localhost:8090")
    let links = JSON(["self": baseURL + "/books"])
    json["_links"] = links

We would be very surprised if the request’s headers collection did not contain a "Host" element as that’s required by HTTP. However, we get back an Optional from the subscript and the simplest way to unwrap it safely is to use ??. We then append "/books" to our baseURL to create the link to self.

Rename books to _embedded

This is simple. we just change:

    json["books"] = JSON(books.map { $0.toJSON() })

to:

    json["_embedded"] = JSON(books.map { $0.toJSON() })

To add an _links property to each book resource, we edit the closure to books.map():

Change:

    json["_embedded"] = JSON(books.map { $0.toJSON() })

to:

    json["_embedded"] = JSON(books.map {
        var book = $0.toJSON()
        book["_links"] = JSON(["self": baseURL + "/books/" + $0.id])
        return book
    })

The closure is a little more complex now. Instead of just returning the JSON representation of each book instance, we add a "_links" section. As before, we need a fully qualified URL for the self link, so we use baseURL and then append /books/ followed by the id of the specific book. This creates a unique link for each book.

Change the Content-Type

To change the content type, we set the correct header into the response.

Add this line after the response.status(.OK).send(json: json) call as that will set the Content-Type to application/json automatically.

Sources/BookshelfAPI/ListBooksHandler.swift

    response.headers["Content-Type"] = "applicaion/hal+json"

Test our changes.

Build and run the app. We then use curl as usual to check that our changes have worked:

$ curl -i http://localhost:8090/books
HTTP/1.1 200 OK
Content-Type: application/hal+json
Content-Length: 7280
Date: Tue, 02 Aug 2016 14:51:14 GMT
Connection: Close

{
"_embedded" : [
    {
      "title" : "Dragondrums",
      "author" : "Anne McCaffrey",
      "isbn" : "9780689306853",
      "_links" : {
          "self" : "http:\/\/localhost:8090\/books\/64f9cc52d162927b3f95ed1d9403b01b:1-3ae55b53a5f50a2e9059efcab0a3420a"
      }
    },
    {
      "title" : "Dragonflight",
      "author" : "Anne McCaffrey",
      "isbn" : "9780345335463",
      "_links" : {
          "self" : "http:\/\/localhost:8090\/books\/64f9cc52d162927b3f95ed1d94037951:1-15a0278631f2491c6929713b607fec2c"
      }
    }
    [etc.]
  ],
  "count" : 27,
  "_links" : {
    "self" : "http:\/\/localhost:8090\/books"
  }
}

(I’ve removed most of the embedded resources to make it fit!)

As you can see, each book resource in the _embedded array has a unique link that the client can use to operate on that specific book. The id is quite long as we’ve chosen to make it the concatentation of the id and revision from CouchDB, but of course our client doesn’t care about that.

Now that we have a link to a book resource, let’s write the endpoint that will enable a client to retrieve a single book.

Retrieving a single book

To retrieve a single book, the client needs to send a GET request to a URL of the form /books/{id of this book}. We cannot write a separate route handler for every possible book id as we don’t know them all, but fortunately, we don’t have to as Kitura’s router lets us use a placehold in place of the id string and access it within our handler.

Let’s start by registering our new route in main.swift after the registration of /books:

Sources/BookshelfAPI/main.swift

    router.get("/books/:id", handler: getBookHandler)

The route is "/books/:id". The colon before the id tells the router that id is a placeholder and to store the value in the URL into a parameter named id so that the handler can access it later. We name our new handler function getBookHandler and store it in the Handlers directory.

Retrieving a single book from CouchDB

Before we write our handler, let’s look at how we retrieve a book record from CouchDB and wrote a new method, fetchBook(withId:), in our Mapper class. This method goes in BooksMapper.swift after the fetchAll() method:

Sources/BookshelfAPI/BooksMapper.swift

    ///
    /// Fetch a single book using the books/all view
    ///
    func fetchBook(withId id: String) throws -> Book {

        // The id contains both the id and the rev separated by a :, so split
        let parts = id.characters.split { $0 == ":"}.map(String.init)
        let bookId = parts[0]

        var book: Book?
        var error: RetrieveError = RetrieveError.Unknown
        database.retrieve(bookId, callback: { (document: JSON?, err: NSError?) in

            if let document = document {
                let bookId = document["_id"].stringValue + ":" + document["_rev"].stringValue
                book = Book(id: bookId, title: document["title"].stringValue,
                    author: document["author"].stringValue, isbn: document["isbn"].stringValue)
                return
            }

            if let err = err {
                switch err.code {
                    case 404: // not found
                        error = RetrieveError.NotFound
                    default: // some other error
                        Log.error("Oops something went wrong; could not read document.")
                        Log.info("Error: \(err.localizedDescription). Code: \(err.code)")
                }
            }
        })

        if book == nil {
            throw error
        }

        return book!
    }

There’s a lot going on here, so let’s take a look:

    func fetchBook(withId id: String) throws -> Book {

Our method takes the book’s id as a string. We specify that we are going to throw errors and that we’ll return a Book instance on success.

        // The id contains both the id and the rev separated by a :, so split
        let parts = id.characters.split { $0 == ":"}.map(String.init)
        let bookId = parts[0]

The Book’s id is the combination of the CouchDB id and rev values, separated by a “:”, so we split the id on : and assign the first part to bookId which is all we need to retrieve the latest revision of the book.

        var book: Book?
        var error: RetrieveError = RetrieveError.Unknown
        database.retrieve(bookId, callback: { (document: JSON?, err: NSError?) in

The retrieve() call fetches a record from CouchDB when you know the id and as with queryByView a closure is called when the method finishes. This closure passes in a JSON document and an error, one of which will be valid and the other nil.


            if let document = document {
                let bookId = document["_id"].stringValue + ":" + document["_rev"].stringValue
                book = Book(id: bookId, title: document["title"].stringValue,
                    author: document["author"].stringValue, isbn: document["isbn"].stringValue)
                return
            }

If the data was retrieved by CouchDB, then document will be valid and err will be nil, so we unwrap it and then set bookId to the combination of the CouchDB id and rev values and initialise a Book instance. We assign this to the book variable that we defined outside of the closure so that we can return it to the handler.

            if let err = err {
                switch err.code {
                    case 404: // not found
                        error = RetrieveError.NotFound
                    default: // some other error
                        Log.error("Oops something went wrong; could not read document.")
                        Log.info("Error: \(err.localizedDescription). Code: \(err.code)")
                }
            }
        })

If we didn’t have a valid document, then we have an error situation. We assign unwrap err first and then if it’s a 404, we set the Error to NotFound so that we can distinguish it from any other error in the handler. Otherwise, we log what happened as we probably want to know about this error! We don’t log a message for the 404 as that’s not really our problem.

        if book == nil {
            throw error
        }

        return book!
    }

Back in the fetchBook method, after the callback has execute, we throw the error if book is nil, otherwise we know book is valid so we can return it unwrapped.

The Error enum

You probably noticed that we’ve used a couple of RetrieveError contants, so we had better define them. In order to throw an error, we need to define an enum that conforms to the Error protocol. This enum defines the types of errors that we know about and will throw. Add this at the top of the BooksMapper class definition:

Sources/BookshelfAPI/BooksMapper.swift

    class BooksMapper {
        enum RetrieveError: Error {
            case NotFound
            case Unknown
        }

This code defines our enum with two values: NotFound and Unknown that we throw in fetchBook().

The handler

We can now turn out attention to the getBookHandler handler function itself:

Sources/BookshelfAPI/Handlers/GetBookHandler.swift

    import CouchDB
    import Kitura
    import LoggerAPI
    import SwiftyJSON

    func getBookHandler(request: RouterRequest, response: RouterResponse, next: ()->Void) -> Void {
        guard let id: String = request.parameters["id"] else {
            response.status(.notFound).send(json: JSON(["error": "Not Found"]))
            next()
            return
        }

        Log.info("Handling /book/\(id)")

        do {
            let book = try booksMapper.fetchBook(withId: id)

            var json = book.toJSON()

            let baseURL = "http://" + (request.headers["Host"] ?? "localhost:8090")
            let links = JSON(["self": baseURL + "/books/" + book.id])
            json["_links"] = links

            response.status(.OK).send(json: json)
        } catch BooksMapper.RetrieveError.NotFound {
            response.status(.notFound).send(json: JSON(["error": "Not found"]))
        } catch {
            response.status(.internalServerError).send(json: JSON(["error": "Could not service request"]))
        }

        next()
    }

As usual, we’ll break it down and look at what we’ve just written.

    guard let id: String = request.parameters["id"] else {
        response.status(.notFound).send(json: JSON(["error": "Not Found"]))
        next()
        return
    }

The :id marker in the router’s path definition is available to us in the request.parameters array. This is an Optional, so we guard against it and return a 404 (.notFound) status to the client if the id is missing. Of course, this should never happen… but that’s no reason to not handle it properly!

    do {
        let book = try booksMapper.fetchBook(withId: id)

        var json = book.toJSON()

        let baseURL = "http://" + (request.headers["Host"] ?? "localhost:8090")
        let links = JSON(["self": baseURL + "/books/" + book.id])
        json["_links"] = links

        response.status(.OK).send(json: json)
        response.headers["Content-Type"] = "applicaion/hal+json"
    }

The fetchBook method throws an error on failure, so we need to wrap it in a do…catch. If we successfully fetch the book, then we get the JSON representation from the Book instance and then add the HAL self link within the \_links array. This is exactly the same as how we did it for the embedded books in the ListBooksHandler function.

    } catch BooksMapper.RetrieveError.NotFound {
        response.status(.notFound).send(json: JSON(["error": "Not found"]))
    } catch {
        response.status(.internalServerError).send(json: JSON(["error": "Could not service request"]))
    }

We have two error conditions that we want to catch. Firstly, the book’s id may not be found in our database, in which case we set the status code to 400 (.notFound). We also catch any other error condition and return set the status code to 500 (.internalServerError).

    next()

As usual, we call next() in order to execute the next handler in the middleware pipeline.

Test

make and run the app and test with curl. Fetch the /books endpoint as before to look up the URL to a specific book and then fetch it:

$ curl -i http://localhost:8090/books/9a42e19e3dafef8af7fd533c6c002b6a:1-7f2f9f52ed09c30c82ac62012d29fbd0
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 229
Date: Fri, 12 Aug 2016 11:34:21 GMT
Connection: Keep-Alive
Keep-Alive: timeout=60, max=19

{
  "title" : "The Hunger Games",
  "author" : "Suzanne Collins",
  "isbn" : "9780439023528",
  "_links" : {
    "self" : "http:\/\/localhost:8090\/books\/9a42e19e3dafef8af7fd533c6c002b6a:1-7f2f9f52ed09c30c82ac62012d29fbd0"
  }
}

We now have a working endpoint that represents a single book resource. Next up is to be able to create new books in part 6.

Tutorial navigation

GitHub repository: kitura_bookshelfapi