API Endpoints and RESTful Routing
Over the next few sections of this book we’re going to gradually build up our API so that the endpoints start to look like this:
Method | URL Pattern | Handler | Action |
---|---|---|---|
GET | /v1/healthcheck | healthcheckHandler | Show application information |
GET | /v1/movies | listMoviesHandler | Show the details of all movies |
POST | /v1/movies | createMovieHandler | Create a new movie |
GET | /v1/movies/:id | showMovieHandler | Show the details of a specific movie |
PUT | /v1/movies/:id | editMovieHandler | Update the details of a specific movie |
DELETE | /v1/movies/:id | deleteMovieHandler | Delete a specific movie |
If you’ve built APIs with REST style endpoints before, then the table above probably looks very familiar to you and doesn’t require much explanation. But if you’re new to this, then there are a couple of important things to point out.
The first thing is that requests with the same URL pattern will be routed to different handlers based on the HTTP request method. For both security and semantic correctness, it’s important that we use the appropriate HTTP method for the action that the handler is performing.
In summary:
Method | Usage |
---|---|
GET | Use for actions that retrieve information only and don’t change the state of your application or any data. |
POST | Use for non-idempotent actions that modify state. In the context of a REST API, POST is generally used for actions that create a new resource. |
PUT | Use for idempotent actions that modify the state of a resource at a specific URL. In the context of a REST API, PUT is generally used for actions that replace or update an existing resource. |
PATCH | Use for actions that partially update a resource at a specific URL. It’s OK for the action to be either idempotent or non-idempotent. |
DELETE | Use for actions that delete a resource at a specific URL. |
The other important thing to point out is that our API endpoints will use clean URLs, with wildcard parameters interpolated in the URL path. So — for example — to retrieve the details of a specific movie a client will make a request like GET /v1/movies/1
, instead of appending the movie ID in a query string parameter like GET /v1/movies?id=1
.
Choosing a router
In this book we’re going to use the popular third-party package httprouter
as the router for our application, instead of using http.ServeMux
from the standard-library.
There are two reasons for this:
We want our API to consistently send JSON responses wherever possible. Unfortunately,
http.ServeMux
sends plaintext (non-JSON)404
and405
responses when a matching route cannot be found, and it’s not possible to easily customize these without causing a knock-on effect that inhibits the automatic sending of405
responses. There is an open proposal to improve this in future versions of Go, but for now it’s a pretty significant drawback.Additionally — and less importantly for most applications —
http.ServeMux
does not automatically handleOPTIONS
requests.
Both of these things are supported by httprouter
, along with providing all the other functionality that we need. The httprouter
package itself is stable and well-tested, and as a bonus it’s also extremely fast thanks to its use of a radix tree for URL matching. If you’re building a REST API for public consumption, then httprouter
is a solid choice.
If you’re coding-along with this book, please use go get
to download the latest v1.N.N
release of httprouter
like so:
$ go get github.com/julienschmidt/httprouter@v1 go: downloading github.com/julienschmidt/httprouter v1.3.0 go get: added github.com/julienschmidt/httprouter v1.3.0
To demonstrate how httprouter
works, we’ll start by adding the two endpoints for creating a new movie and showing the details of a specific movie to our codebase. By the end of this chapter, our API endpoints will look like this:
Method | URL Pattern | Handler | Action |
---|---|---|---|
GET | /v1/healthcheck | healthcheckHandler | Show application information |
POST | /v1/movies | createMovieHandler | Create a new movie |
GET | /v1/movies/:id | showMovieHandler | Show the details of a specific movie |
Encapsulating the API routes
To prevent our main()
function from becoming cluttered as the API grows, let’s encapsulate all the routing rules in a new cmd/api/routes.go
file.
If you’re following along, create this new file and add the following code:
$ touch cmd/api/routes.go
package main import ( "net/http" "github.com/julienschmidt/httprouter" ) func (app *application) routes() http.Handler { // Initialize a new httprouter router instance. router := httprouter.New() // Register the relevant methods, URL patterns and handler functions for our // endpoints using the HandlerFunc() method. Note that http.MethodGet and // http.MethodPost are constants which equate to the strings "GET" and "POST" // respectively. router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) // Return the httprouter instance. return router }
There are a couple of benefits to encapsulating our routing rules in this way. The first benefit is that it keeps our main()
function clean and ensures all our routes are defined in one single place. The other big benefit, which we demonstrated in the first Let’s Go book, is that we can now easily access the router in any test code by initializing an application
instance and calling the routes()
method on it.
The next thing that we need to do is update the main()
function to remove the http.ServeMux
declaration, and use the httprouter
instance returned by app.routes()
as our server handler instead. Like so:
package main ... func main() { var cfg config flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) app := &application{ config: cfg, logger: logger, } // Use the httprouter instance returned by app.routes() as the server handler. srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.port), Handler: app.routes(), IdleTimeout: time.Minute, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), } logger.Info("starting server", "addr", srv.Addr, "env", cfg.env) err := srv.ListenAndServe() logger.Error(err.Error()) os.Exit(1) }
Adding the new handler functions
Now that the routing rules are set up, we need to make the createMovieHandler
and showMovieHandler
methods for the new endpoints. The showMovieHandler
is particularly interesting here, because as part of this we want to extract the movie ID parameter from the URL and use it in our HTTP response.
Go ahead and create a new cmd/api/movies.go
file to hold these two new handlers:
$ touch cmd/api/movies.go
And then add the following code:
package main import ( "fmt" "net/http" "strconv" "github.com/julienschmidt/httprouter" ) // Add a createMovieHandler for the "POST /v1/movies" endpoint. For now we simply // return a plain-text placeholder response. func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "create a new movie") } // Add a showMovieHandler for the "GET /v1/movies/:id" endpoint. For now, we retrieve // the interpolated "id" parameter from the current URL and include it in a placeholder // response. func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { // When httprouter is parsing a request, any interpolated URL parameters will be // stored in the request context. We can use the ParamsFromContext() function to // retrieve a slice containing these parameter names and values. params := httprouter.ParamsFromContext(r.Context()) // We can then use the ByName() method to get the value of the "id" parameter from // the slice. In our project all movies will have a unique positive integer ID, but // the value returned by ByName() is always a string. So we try to convert it to a // base 10 integer (with a bit size of 64). If the parameter couldn't be converted, // or is less than 1, we know the ID is invalid so we use the http.NotFound() // function to return a 404 Not Found response. id, err := strconv.ParseInt(params.ByName("id"), 10, 64) if err != nil || id < 1 { http.NotFound(w, r) return } // Otherwise, interpolate the movie ID in a placeholder response. fmt.Fprintf(w, "show the details of movie %d\n", id) }
And with that, we’re now ready to try this out!
Go ahead and restart the API application…
$ go run ./cmd/api time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
Then while the server is running, open a second terminal window and use curl
to make some requests to the different endpoints. If everything is set up correctly, you will see some responses which look similar to this:
$ curl localhost:4000/v1/healthcheck status: available environment: development version: 1.0.0 $ curl -X POST localhost:4000/v1/movies create a new movie $ curl localhost:4000/v1/movies/123 show the details of movie 123
Notice how, in the final example, the value of the movie id
parameter 123
has been successfully retrieved from the URL and included in the response?
You might also want to try making some requests for a particular URL using an unsupported HTTP method. For example, let’s try making a POST
request to /v1/healthcheck
:
$ curl -i -X POST localhost:4000/v1/healthcheck HTTP/1.1 405 Method Not Allowed Allow: GET, OPTIONS Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff Date: Tue, 06 Apr 2021 06:59:04 GMT Content-Length: 19 Method Not Allowed
That’s looking really good. The httprouter
package has automatically sent a 405 Method Not Allowed
response for us, including an Allow
header which lists the HTTP methods that are supported for the endpoint.
Likewise, you can make an OPTIONS
request to a specific URL and httprouter
will send back a response with an Allow
header detailing the supported HTTP methods. Like so:
$ curl -i -X OPTIONS localhost:4000/v1/healthcheck HTTP/1.1 200 OK Allow: GET, OPTIONS Date: Tue, 06 Apr 2021 07:01:29 GMT Content-Length: 0
Lastly, you might want to try making a request to the GET /v1/movies/:id
endpoint with a negative number or a non-numerical id
value in the URL. This should result in a 404 Not Found
response, similar to this:
$ curl -i localhost:4000/v1/movies/abc HTTP/1.1 404 Not Found Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff Date: Tue, 06 Apr 2021 07:02:01 GMT Content-Length: 19 404 page not found
Creating a helper to read ID parameters
The code to extract an id
parameter from a URL like /v1/movies/:id
is something that we’ll need repeatedly in our application, so let’s abstract the logic for this into a small reuseable helper method.
Go ahead and create a new cmd/api/helpers.go
file:
$ touch cmd/api/helpers.go
And add a new readIDParam()
method to the application
struct, like so:
package main import ( "errors" "net/http" "strconv" "github.com/julienschmidt/httprouter" ) // Retrieve the "id" URL parameter from the current request context, then convert it to // an integer and return it. If the operation isn't successful, return 0 and an error. func (app *application) readIDParam(r *http.Request) (int64, error) { params := httprouter.ParamsFromContext(r.Context()) id, err := strconv.ParseInt(params.ByName("id"), 10, 64) if err != nil || id < 1 { return 0, errors.New("invalid id parameter") } return id, nil }
With this helper method in place, the code in our showMovieHandler
can now be made a lot simpler:
package main import ( "fmt" "net/http" ) ... func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { id, err := app.readIDParam(r) if err != nil { http.NotFound(w, r) return } fmt.Fprintf(w, "show the details of movie %d\n", id) }
Additional Information
Conflicting routes
It’s important to be aware that httprouter
doesn’t allow conflicting routes which potentially match the same request. So, for example, you cannot register a route like GET /foo/new
and another route with a parameter segment that conflicts with it — like GET /foo/:id
.
If you’re using a standard REST structure for your API endpoints — like we will be in this book — then this restriction is unlikely to cause you many problems.
In fact, it’s arguably a positive thing. Because conflicting routes aren’t allowed, there are no routing-priority rules that you need to worry about, and it reduces the risk of bugs and unintended behavior in your application.
But if you do need to support conflicting routes (for example, you might need to replicate the endpoints of an existing API exactly for backwards-compatibility), then I would recommend taking a look at chi
, Gorilla mux
or flow
instead. All of these are good routers which do permit conflicting routes.
Customizing httprouter behavior
The httprouter
package provides a few configuration options that you can use to customize the behavior of your application further, including enabling trailing slash redirects and enabling automatic URL path cleaning.
More information about the available settings can be found here.