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:
- Add an
_links
section to the response and include aself
link. - Rename the
books
key to_embedded
. - Add an
_links
section to each book resource within_embedded
, again with aself
link. - Change the
Content-Type
toapplication/hal+json
Let’s take each of these in turn and update the handler:
Add _links section
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() })
Add _links section to each book resource
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
- Part 1: Getting Started with Kitura
- Part 2: CouchDB
- Part 3: List books
- Part 4: Configuration
- Part 5: Hypermedia
- Part 6: Adding a book
GitHub repository: kitura_bookshelfapi