The final piece of the puzzle is the handlePolls
function, which will use the helpers to understand the incoming request and access the database and generate a meaningful response that will be sent back to the client. We also need to model the poll data that we were working with in the previous chapter.
Create a new file called polls.go
and add the following code:
package main import "gopkg.in/mgo.v2/bson" type poll struct { ID bson.ObjectId `bson:"_id" json:"id"` Title string `json:"title"` Options []string `json:"options"` Results map[string]int `json:"results,omitempty"` APIKey string `json:"apikey"` }
Here, we define a structure called poll
, which has five fields that in turn describe the polls being created and maintained by the code we wrote in the previous chapter. We have also added the APIKey
field, which you probably wouldn't do in the real world but which will allow us to demonstrate how we extract the API key from the context. Each field also has a tag (two in the ID
case), which allows us to provide some extra metadata.
Tags are just a string that follows a field definition within a struct
type on the same line. We use the black tick character to denote literal strings, which means we are free to use double quotes within the tag string itself. The reflect
package allows us to pull out the value associated with any key; in our case, both bson
and json
are examples of keys, and they are each key/value pair separated by a space character. Both the encoding/json
and gopkg.in/mgo.v2/bson
packages allow you to use tags to specify the field name that will be used with encoding and decoding (along with some other properties) rather than having it infer the values from the name of the fields themselves. We are using BSON to talk with the MongoDB database and JSON to talk to the client, so we can actually specify different views of the same struct
type. For example, consider the ID field:
ID bson.ObjectId `bson:"_id" json:"id"`
The name of the field in Go is ID
, the JSON field is id
, and the BSON field is _id
, which is the special identifier field used in MongoDB.
Because our simple path-parsing solution cares only about the path, we have to do some extra work when looking at the kind of RESTful operation the client is making. Specifically, we need to consider the HTTP method so that we know how to handle the request. For example, a GET
call to our /polls/
path should read polls, where a POST
call would create a new one. Some frameworks solve this problem for you by allowing you to map handlers based on more than the path, such as the HTTP method or the presence of specific headers in the request. Since our case is ultra simple, we are going to use a simple switch
case. In polls.go
, add the handlePolls
function:
func (s *Server) handlePolls(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": s.handlePollsGet(w, r) return case "POST": s.handlePollsPost(w, r) return case "DELETE": s.handlePollsDelete(w, r) return } // not found respondHTTPErr(w, r, http.StatusNotFound) }
We switch on the HTTP method and branch our code depending on whether it is GET
, POST
, or DELETE
. If the HTTP method is something else, we just respond with a 404 http.StatusNotFound
error. To make this code compile, you can add the following function stubs underneath the handlePolls
handler:
func (s *Server) handlePollsGet(w http.ResponseWriter, r *http.Request) { respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented")) } func (s *Server) handlePollsPost(w http.ResponseWriter, r *http.Request) { respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented")) } func (s *Server) handlePollsDelete(w http.ResponseWriter, r *http.Request) { respondErr(w, r, http.StatusInternalServerError, errors.New("not implemented")) }
In this section, we learned how to manually parse elements of the requests (the HTTP method) and make decisions in code. This is great for simple cases, but it's worth looking at packages such as Gorilla's mux
package for some more powerful ways of solving these problems. Nevertheless, keeping external dependencies to a minimum is a core philosophy of writing good and contained Go code.
Now it's time to implement the functionality of our web service. Add the following code:
func (s *Server) handlePollsGet(w http.ResponseWriter, r *http.Request) { session := s.db.Copy() defer session.Close() c := session.DB("ballots").C("polls") var q *mgo.Query p := NewPath(r.URL.Path) if p.HasID() { // get specific poll q = c.FindId(bson.ObjectIdHex(p.ID)) } else { // get all polls q = c.Find(nil) } var result []*poll if err := q.All(&result); err != nil { respondErr(w, r, http.StatusInternalServerError, err) return } respond(w, r, http.StatusOK, &result) }
The very first thing we do in each of our sub handler functions is create a copy of the database session that will allow us to interact with MongoDB. We then use mgo
to create an object referring to the polls
collection in the database – if you remember, this is where our polls live.
We then build up an mgo.Query
object by parsing the path. If an ID is present, we use the FindId
method on the polls
collection; otherwise, we pass nil
to the Find
method, which indicates that we want to select all the polls. We are converting the ID from a string to a bson.ObjectId
type with the ObjectIdHex
method so that we can refer to the polls with their numerical (hex) identifiers.
Since the All
method expects to generate a collection of poll
objects, we define the result as []*poll
or a slice of pointers to poll types. Calling the All
method on the query will cause mgo
to use its connection to MongoDB to read all the polls and populate the result
object.
For small scale, such as a small number of polls, this approach is fine, but as the polls grow, we will need to consider a more sophisticated approach. We can page the results by iterating over them using the Iter
method on the query and using the Limit
and Skip
methods, so we do not try to load too much data into the memory or present too much information to users in one go.
Now that we have added some functionality, let's try out our API for the first time. If you are using the same MongoDB instance that we set up in the previous chapter, you should already have some data in the polls
collection; to see our API working properly, you should ensure there are at least two polls in the database.
If you need to add other polls to the database, in a terminal, run the mongo
command to open a database shell that will allow you to interact with MongoDB. Then, enter the following commands to add some test polls:
> use ballots switched to db ballots > db.polls.insert({"title":"Test poll","options": ["one","two","three"]}) > db.polls.insert({"title":"Test poll two","options": ["four","five","six"]})
In a terminal, navigate to your api
folder and build and run the project:
go build -o api ./api
Now make a GET
request to the /polls/
endpoint by navigating to http://localhost:8080/polls/?key=abc123
in your browser; remember to include the trailing slash. The result will be an array of polls in the JSON format.
Copy and paste one of the IDs from the polls list and insert it before the ?
character in the browser to access the data for a specific poll, for example, http://localhost:8080/polls/5415b060a02cd4adb487c3ae?key=abc123
. Note that instead of returning all the polls, it only returns one.
You might have also noticed that although we are only returning a single poll, this poll value is still nested inside an array. This is a deliberate design decision made for two reasons: the first and most important reason is that nesting makes it easier for users of the API to write code to consume the data. If users are always expecting a JSON array, they can write strong types that describe that expectation rather than having one type for single polls and another for collections of polls. As an API designer, this is your decision to make. The second reason we left the object nested in an array is that it makes the API code simpler, allowing us to just change the mgo.Query
object and leave the rest of the code the same.
Clients should be able to make a POST
request to /polls/
in order to create a poll. Let's add the following code inside the POST
case:
func (s *Server) handlePollsPost(w http.ResponseWriter, r *http.Request) { session := s.db.Copy() defer session.Close() c := session.DB("ballots").C("polls") var p poll if err := decodeBody(r, &p); err != nil { respondErr(w, r, http.StatusBadRequest, "failed to read poll from request", err) return } apikey, ok := APIKey(r.Context()) if ok { p.APIKey = apikey } p.ID = bson.NewObjectId() if err := c.Insert(p); err != nil { respondErr(w, r, http.StatusInternalServerError, "failed to insert poll", err) return } w.Header().Set("Location", "polls/"+p.ID.Hex()) respond(w, r, http.StatusCreated, nil) }
After we get a copy of the database session like earlier, we attempt to decode the body of the request that, according to RESTful principles, should contain a representation of the poll object the client wants to create. If an error occurs, we use the respondErr
helper to write the error to the user and immediately exit from the function. We then generate a new unique ID for the poll and use the mgo
package's Insert
method to send it into the database. We then set the Location
header of the response and respond with a 201 http.StatusCreated
message, pointing to the URL from which the newly created poll may be accessed. Some APIs return the object instead of providing a link to it; there is no concrete standard so it's up to you as the designer.
The final piece of functionality we are going to include in our API is the ability to delete polls. By making a request with the DELETE
HTTP method to the URL of a poll (such as/polls/5415b060a02cd4adb487c3ae
), we want to be able to remove the poll from the database and return a 200 Success
response:
func (s *Server) handlePollsDelete(w http.ResponseWriter, r *http.Request) { session := s.db.Copy() defer session.Close() c := session.DB("ballots").C("polls") p := NewPath(r.URL.Path) if !p.HasID() { respondErr(w, r, http.StatusMethodNotAllowed, "Cannot delete all polls.") return } if err := c.RemoveId(bson.ObjectIdHex(p.ID)); err != nil { respondErr(w, r, http.StatusInternalServerError, "failed to delete poll", err) return } respond(w, r, http.StatusOK, nil) // ok }
Similar to the GET
case, we parse the path, but this time, we respond with an error if the path does not contain an ID. For now, we don't want people to be able to delete all polls with one request, and so we use the suitable StatusMethodNotAllowed
code. Then, using the same collection we used in the previous cases, we call RemoveId
, passing the ID in the path after converting it into a bson.ObjectId
type. Assuming things go well, we respond with an http.StatusOK
message with no body.
In order for our DELETE
capability to work over CORS, we must do a little extra work to support the way CORS browsers handle some HTTP methods such as DELETE
. A CORS browser will actually send a preflight request (with an HTTP method of OPTIONS
), asking for permission to make a DELETE
request (listed in the Access-Control-Request-Method
request header), and the API must respond appropriately in order for the request to work. Add another case in the switch
statement for OPTIONS
:
case "OPTIONS": w.Header().Add("Access-Control-Allow-Methods", "DELETE") respond(w, r, http.StatusOK, nil) return
If the browser asks for permission to send a DELETE
request, the API will respond by setting the Access-Control-Allow-Methods
header to DELETE
, thus overriding the default *
value that we set in our withCORS
wrapper handler. In the real world, the value for the Access-Control-Allow-Methods
header will change in response to the request made, but since DELETE
is the only case we are supporting, we can hardcode it for now.
The details of CORS are out of the scope of this book, but it is recommended that you research the particulars online if you intend to build truly accessible web services and APIs. Head over to http://enable-cors.org/ to get started.
Curl is a command-line tool that allows us to make HTTP requests to our service so that we can access it as though we were a real app or client consuming the service.
Windows users do not have access to curl by default and will need to seek an alternative. Check out http://curl.haxx.se/dlwiz/?type=bin or search the Web for Windows curl alternative.
In a terminal, let's read all the polls in the database through our API. Navigate to your api
folder and build and run the project and also ensure MongoDB is running:
go build -o api ./api
We then perform the following steps:
curl
command that uses the -X
flag to denote we want to make a GET
request to the specified URL:curl -X GET http://localhost:8080/polls/?
key=abc123
[{"id":"541727b08ea48e5e5d5bb189","title":"Best
Beatle?",
"options": ["john","paul","george","ringo"]},
{"id":"541728728ea48e5e5d5bb18a","title":"Favorite
language?",
"options": ["go","java","javascript","ruby"]}]
curl --data '{"title":"test","options":
["one","two","three"]}'
-X POST http://localhost:8080/polls/?key=abc123
curl -X GET http://localhost:8080/polls/?
key=abc123
curl -X GET http://localhost:8080/polls/541727b08ea48e5e5d5bb189? key=abc123 [{"id":"541727b08ea48e5e5d5bb189",","title":"Best Beatle?", "options": ["john","paul","george","ringo"]}]
DELETE
request to remove the poll:curl -X DELETE
http://localhost:8080/polls/541727b08ea48e5e5d5bb189?
key=abc123
curl -X GET http://localhost:8080/polls/?key=abc123 [{"id":"541728728ea48e5e5d5bb18a","title":"Favorite language?","options":["go","java","javascript","ruby"]}]
So now that we know that our API is working as expected, it's time to build something that consumes the API properly.
3.136.17.12