You're reading a sample of this book. Get the full version here.
Let's Go Further Getting Started › API Endpoints and RESTful Routing
Previous · Contents · Next
Chapter 2.3.

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:

Both of these things are supported by httprouter, along with providing all the other functionality that we need. The 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
File: 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:

File: cmd/api/main.go
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:

File: cmd/api/movies.go
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:

File: cmd/api/helpers.go
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:

File: cmd/api/movies.go
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.