Pragmatism in the real world

Kitura tutorial part 6: Adding a book

In part 5, we wrote the last endpoint that will read data from the API and send it to the client. We now turn our attention to adding new books. Each book has a unique id provided for us by CouchDB, so we need to POST to the /books collection in order to create a new book. This is a new endpoint in our API, so we follow the same basic process as we did for returning a single book:

  • Implement a create book method in our database mapper
  • Write a handler to process the client’s request to create a book
  • Attach the handler to the router.

Inserting data into CouchDB

Let’s start with the database side. We need a new method, insertBook in our BooksMapper:

Sources/BookshelfAPI/BooksMapper.swift

///
/// Add a book to the database
///
func insertBook(json: JSON) throws -> Book {
    // validate required values
    guard let title = json["title"].string,
        let author = json["author"].string else {
        throw RetrieveError.Invalid("A Book must have a title and an author")
    }
    // optional values
    let isbn = json["isbn"].stringValue

    // create a JSON object to store which contains just the properties we need
    let bookJson = JSON([
        "type": "book",
        "author": author,
        "title": title,
        "isbn": isbn,
    ])

    var book: Book?
    database.create(bookJson) { (id, revision, document, err) in
        if let id = id, let revision = revision, err == nil {
            Log.info("Created book \(title) with id of \(id)")
            let bookId = "\(id):\(revision)"
            book = Book(id: bookId, title: title, author: author, isbn: isbn)
            return
        }

        Log.error("Oops something went wrong; could not create book.")
        if let err = err {
            Log.info("Error: \(err.localizedDescription). Code: \(err.code)")
        }
    }

    if book == nil {
        throw RetrieveError.Unknown
    }

    return book!
}

A lot of this code will seem familiar as the patterns are similar to the code we wrote for fetchBook. Let’s have a look:

    func insertBook(json: JSON) throws -> Book {

The data we recieve is going to be in JSON format, so we’ll pass that JSON straight through to this method and extract the properties we need.

        // validate required values
        guard let title = json["title"].string,
            let author = json["author"].string else {
            throw RetrieveError.Invalid("A Book must have a title and an author")
        }
        // optional values
        let isbn = json["isbn"].stringValue

There are three values in the data received from the client that we care about: "title", "author" and "isbn". The first two must exist, so we use SwiftJSON’s string property which will return nil if the property doesn’t exist. We wrap the let statements in a guard statement and throw an error on failure. This is a new value in our RetrieveError enum. The isbn value doesn’t have to exist, so we can just extract it from the JSON using stringValue which will set it to an empty string if it doesn’t exist.

        // create a JSON object to store which contains just the properties we need
        let bookJson = JSON([
            "type": "book",
            "author": author,
            "title": title,
            "isbn": isbn,
        ])

We want to control the data that is stored into CouchDB, so we create a JSON object that contains just the data that we want.

        var book: Book?
        database.create(bookJson) { (id, revision, document, err) in
            if let id = id, let revision = revision, err == nil {
                Log.info("Created book \(title) with id of \(id)")
                let bookId = "\(id):\(revision)"
                book = Book(id: bookId, title: title, author: author, isbn: isbn)
                return
            }

This code is very similar to all the other CouchDB interactions that we have done. The create() method takes a JSON object and a callback that is called when the work is done. This callback takes four paramters: the id and revision of the newly created object along with a document JSON that has the same data and and error object if the insertion failed.

As usual, we unwrap the data we want (id and revision in this case) while testing that err is nil which means that the insert worked. We can then create a Book object as assign it to our book var that lives outside the callback.

            Log.error("Oops something went wrong; could not create book.")
            if let err = err {
                Log.info("Error: \(err.localizedDescription). Code: \(err.code)")
            }
        }

If there’s an error, we log it.

        if book == nil {
            throw RetrieveError.Unknown
        }

        return book!

Back outside the callback from create, we test if book is nil. If it is, then we didn’t store the data. There’s no need to tell the client any details as it’s our problem, so we throw an Unknown error.

Finally, in the successful case, the book is not nil, so we force unwrap it (as we know it’s valid) and return it.

RetrieveError.Invalid

We implemented a new error: Invalid in this case. This needs to be added to the RetrieveError enum at the top of BooksMapper.swift

Update the RetrieveError definition so that it looks like this:

Sources/BookshelfAPI/BooksMapper.swift

    enum RetrieveError: Error {
        case NotFound
        case Invalid(String)
        case Unknown
    }

This new entry to is slightly different as we’ve defined Invalid as taking a String. This means that we can pass in a message when we throw (as above) and collect it in the catch statement like this:

    do {
        // call a method that throws an .Invalid
    } catch BooksMapper.RetrieveError.Invalid(let message) {
        // message is available here
    }

We can now store new records into the database, so let’s write a handler that can use it!

The route handler to create a book

To create a book, the client is going to POST to /books. When this happens, we want a handler function to be called; we’ll call this function createBookHandler.

The code to attach this function to the router goes in main.swift after the router.get('/books/:id) line:

Sources/BookshelfAPI/main.swift

router.post("/books", handler: createBookHandler)

As Kitura’s router is HTTP aware, we can specify that we accept a different HTTP verb to the same endpoint path. In this case we choose the post() method as we want this handler to only be called if the client uses the POST verb. i.e. we now have two different handlers for the "/books" endpoint, but one is called when then the client uses GET and the other executes when the client uses POST.

Let’s write the handler:

Sources/BookshelfAPI/Handlers/CreateBookHandler.swift

import CouchDB
import Kitura
import LoggerAPI
import SwiftyJSON

func createBookHandler(request: RouterRequest, response: RouterResponse, next: ()->Void) -> Void {
    Log.info("Handling a post to /books")

    let contentType = request.headers["Content-Type"] ?? "";
    guard let rawData = try! request.readString(),
        contentType.hasPrefix("application/json") else {
        Log.info("No data")
        response.status(.badRequest).send(json: JSON(["error": "No data received"]))
        next()
        return
    }

    let jsonData = JSON(data: rawData)
    do {
        let book = try booksMapper.insertBook(json: jsonData)

        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"
    } catch BooksMapper.RetrieveError.Invalid(let message) {
        response.status(.badRequest).send(json: JSON(["error": message]))
    } catch {
        response.status(.internalServerError).send(json: JSON(["error": "Could not service request"]))
    }

    next()
}

As usual we start with a number of imports and then declare the function with the usual signature for middleware.

    let contentType = request.headers["Content-Type"] ?? "";
    guard let rawData = try! request.readString(),
        contentType.hasPrefix("application/json") else {
        Log.info("No data")
        response.status(.badRequest).send(json: JSON(["error": "No data received"]))
        next()
        return
    }

When data is POSTed to the application, we use the readString method to get at the body content. As this returns an Optional we use a guard and also check that the content-type is application/json as that’s all we are prepared to decode. If there is no data or the content-type is wrong, we send a response with a 400 status code (.badRequest) and an error message.

    do {
        let jsonData = JSON(data: rawData)
        let book = try booksMapper.insertBook(json: jsonData)

        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"
    }

We convert the data into JSON and then call our new insertBook method. As this method can throw a RetrieveError, we call it with the try modifier and wrap it in a do...catch statement. If the insertion is successful, then we get back a Book object, and use the toJSON() method as in the other handlers to create a JSON object for sending back to the client. Also, we create the self link and attach the HAL _links element to the JSON data.

To complete the request we set the status to .OK and send the json data with the correct application/hal+json content type header.

    } catch BooksMapper.RetrieveError.Invalid(let message) {
        response.status(.badRequest).send(json: JSON(["error": message]))
    } catch {
        response.status(.internalServerError).send(json: JSON(["error": "Could not service request"]))
    }

The .Invalid RetrieveError enum takes a string, so we can send a useful message back to the client We therefore explicitly catch .Invalid and send a .badRequest to the client as the client should be able to fix the problem and try again.

As the do...catch statement must be inclusive, we also add a catchall where we set the response status to .internalServerError as in this case, the client cannot retry until we fix whatever went wrong.

        next()

As always for a handler, we call next() to continue the middleware pipeline so that Kitura will send the response back to the client.

Testing

We’re all done with the code to add a new book, so we turn to curl to test it:

$ curl -i -X POST http://localhost:8090/books -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell"}'
HTTP/1.1 200 OK
Content-Length: 193
Content-Type: applicaion/hal+json
Date: Fri, 05 Aug 2016 16:34:04 GMT
Connection: Close

{
  "author": "George Orwell",
  "title": "1984",
  "isbn": "",
  "_links": {
      "self": "http://localhost:8090/books/06271bc9a112cc69669fce06670103d0:1-8acc0af9d3a16dad67015e6ac31155ac"
  }
}

You can then use $ curl -s -i http://localhost:8090/books | grep -B 2 -A 4 Orwell to prove it inserted.

Wrap up

That’s it. To round out the API, we need to add PUT and DELETE support to the /book/:id path. These follow the same patterns as we’ve used in the three endpoints that we’ve written so far, so I’ve left them as an exercise for you, dear reader!

There’s more to Kitura than I’ve covered here. The biggest missing element is middleware, though there are other useful features too. In time, this tutorial may expand to cover these topics.

Tutorial navigation

GitHub repository: kitura_bookshelfapi