Elijah's Blog

Go Web Application Structure - Part 2 - Routing/Serving
Go Web Application Structure - Part 2 - Routing/Serving

Originally posted at https://aaf.engineering/go-web-application-structure-part-2/

  • Part 1: Web Application Structure
  • Part 2: Routing/Serving (you're looking at it!)
  • Part 3: The Database
  • Part 4: Database Migrations & Business Logic

Introduction

Disclaimer

The code for this part is too long to include in its entirety. The sections included in this post may be incomplete; please see the github repo for this section's full code listing.

What's next

Now that we have the skeleton for creating command line commands, we can start by creating a new "serve" command. This is similar to Django's "manage.py runserver" and Rails's "rails server". We will need to add some configuration to allow binding to different ports as well as add some CORS configuration for our API. We will also include GZIP handling.

Application structure

As mentioned in Part 1, we will have many different directories to structure our application, api, and context logic. Before doing that, there are a few important concepts to understand: Application state and Request state. Application state is our global application state that is loaded once when starting our server. Request state is created whenever a request is made to our server. We will use the term "context" as a name for this per request context (or state). Here is a short list of what is contained in each level:

Application:

  • Config (global application config)
  • Store (a connection to our database)
  • Cache (any caching connection)

Context:

  • App (a reference to the global application)
  • Store (a reference to our database connection)
  • User (a user, if any)
  • AccessToken (the user's access token, if any)
  • Logger (the logger to use within our context methods, this will have some fields like request_id preset)

API:

  • App (a reference to the global application)
  • Config (api configuration)
  • Logger (the logger to use within api requests)

Configuration

We just talked about our application structure so now it's important to talk about the configuration we need for our application and api. For now, our api only cares about 2 configuration values:

type Config struct {
	// The port to bind the web application server to
	Port int

	// The number of proxies positioned in front of the API. This is used to interpret
	// X-Forwarded-For headers.
	ProxyCount int
}

We can then get these values with viper like so:

func InitConfig() (*Config, error) {
	config := &Config{
		Port:       viper.GetInt("Port"),
		ProxyCount: viper.GetInt("ProxyCount"),
	}
	return config, nil
}

Our application only cares about one value right now:

type Config struct {
	// A secret string used for session cookies, passwords, etc.
	SecretKey []byte
}

And it retrieves the value as such:

func InitConfig() (*Config, error) {
	config := &Config{
		SecretKey: []byte(viper.GetString("SecretKey")),
	}
	if len(config.SecretKey) == 0 {
		return nil, fmt.Errorf("SecretKey must be set!")
	}
	return config, nil
}

In our .gitignore it is important to note that we do not commit our config.yaml to repository. Even if you are using a private github repo, it is bad practice to commit secret information. This is in case someone accidentally pushes to a public repo or a malicious user gets ahold of your codebase. The best way to handle secrets is to not put them in a file at all and use environment variables instead. This is recommended for production, but we realize that locally it's easier to use a config file. See the Appendix for more information on sensitive configuration values.

When having many configuration values, it is important to document each value. In our case, we included a config.example.yaml which can hold useful default values for each parameter. As seen in our InitConfig method, we do some extra validation for the SecretKey. If this is mistakenly not set in production, malicious users could possibly act as other users.

The "serve" command

Setup

The first things we need to do are create three new directories.

mkdir -p $(go env GOPATH)/src/github.com/<username>/todos/api
mkdir -p $(go env GOPATH)/src/github.com/<username>/todos/app
mkdir -p $(go env GOPATH)/src/github.com/<username>/todos/model

Within the api folder we will have 2 files: api.go and config.go. In app we will have app.go, config.go, and context.go. Lastly, in model, there will just be model.go.

Next, we need to create a serve.go file in our previous cmd directory. Here's a snippet of what it will look like:

package cmd

// ... snip ...

var serveCmd = &cobra.Command{
	Use:   "serve",
	Short: "serves the api",
	RunE: func(cmd *cobra.Command, args []string) error {
		a, err := app.New()
		if err != nil {
			return err
		}

		api, err := api.New(a)
		if err != nil {
			return err
		}

		ctx, cancel := context.WithCancel(context.Background())

		go func() {
			ch := make(chan os.Signal, 1)
			signal.Notify(ch, os.Interrupt)
			<-ch
			logrus.Info("signal caught. shutting down...")
			cancel()
		}()

		var wg sync.WaitGroup

		wg.Add(1)
		go func() {
			defer wg.Done()
			defer cancel()
			serveAPI(ctx, api)
		}()

		wg.Wait()
		return nil
	},
}

func init() {
	rootCmd.AddCommand(serveCmd)
}

We are creating a new cobra command and adding it to our root command. We instantiate our application and api, as previously mentioned, this is done once. We use a sync.WaitGroup in case we want to add more servers/handlers in the future (such as websockets or pprof).

Gracefully shutting down

When a user wishes to shut down the server, it should be handled in a graceful fashion. Without the following code, when a user presses Ctrl+C, our web server would be shut down immediately. This means that resources may not be properly freed and connections may not be properly closed. By using Go's context, we can handle when the context is canceled and do any necessary clean-up before shutting down.

// ... snip ...

        go func() {
			ch := make(chan os.Signal, 1)
			signal.Notify(ch, os.Interrupt)
			<-ch
			logrus.Info("signal caught. shutting down...")
			cancel()
		}()

// ... snip ...

Mux routing

Mux is a package in the Gorilla web toolkit; it is used as a light wrapper around Golang's built-in router. From the Mux website, it adds some nice things like:

  • Requests can be matched based on URL host, path, path prefix, schemes,
    header and query values, HTTP methods or using custom matchers.
  • URL hosts, paths and query values can have variables with an optional
    regular expression.
  • Registered URLs can be built, or "reversed", which helps to maintain
    references to resources.
  • Routes can be used as subrouters: nested routes are only tested if the
    parent route matches. This is useful to define groups of routes that
    share common conditions like a host, a path prefix or other repeated
    attributes. As a bonus, this optimizes request matching.
  • It implements the http.Handler interface so it is compatible with the
    standard http.ServeMux.

What is routing?

Routing is a way to define url patterns, that when matched, run specific parts of your code. Routing can also include defining which HTTP methods are allowed for endpoints. For example, we may only want certain endpoints to be GET-able. This usually only gets complex when working with a REST api, GraphQL usually defines a single endpoint and sometimes one more for Graphiql.

Subrouters

As previously mentioned, Mux supports the use of Subrouters. This allows us to group endpoints together. For example, we will eventually have two subrouters; one will be for our API (/api) endpoints and the other will be for our html based endpoints. In our code, initializing a subrouter is rather straightforward:

func serveAPI(ctx context.Context, api *api.Api) {
    // ... snip ...
    router := mux.NewRouter()
	api.Init(router.PathPrefix("/api").Subrouter())
    // ... snip ...
}

The great thing about subrouters is that they act like a regular router. This means you could create sub-subrouters and even sub-sub-subrouters. This may help if you have many api endpoints. For example, let's say you have the following endpoints:

GET, POST /api/todos/
GET, PATCH, DELETE /api/todos/:id
GET, POST /api/books/
GET, PATCH, DELETE /api/books/:id

If we visualized our subrouters as a tree, it could look like this:

                                 
 router                          
   |                             
   |----/ router                 
   |                             
   |----/api router              
   |    |                        
        |------ /api/todos router
        |                        
        |------ /api/books router

However, just because we can have sub-sub-sub-subrouters doesn't mean we should. Generally, it's good practice to keep the nesting limited to two levels as we have above. This also helps enforce good API design, so that we don't have endpoints like /api/todos/:todoId/users/:userId/friends/:friendId/todos. There are APIs like this and they are a pain to work with.

CORS

When building APIs something to keep in mind is Cross-Origin Resource Sharing. At a very high level, this is which websites we want to allow making requests to our server. If you build your api and have your frontend at http://example.com and you want to make sure that http://evil.com can't call your api from their Javascript, you can enforce some CORS rules that will disallow it. However, there are many ways to get around CORS, there's even a website dedicated to it: https://cors-anywhere.herokuapp.com/. On the flip side of things, if you're making a public API and you want to make it easy for consumers of your API to use it without hacking around it, you can enable some sensible defaults. Specifically for our application, it will look like this:

func serveAPI(ctx context.Context, api *api.API) {
	cors := handlers.CORS(
		handlers.AllowedOrigins([]string{"*"}),
		handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "OPTIONS"}),
		handlers.AllowedHeaders([]string{"Content-Type", "Authorization"}),
	)
    // ... snip ...
}

This means we will allow all origins access to our API with the following restrictions:

  • They can use the following HTTP methods: GET, HEAD, POST, OPTIONS
  • The following headers can be sent in a request: Content-Type and Authorization. Note that the Accept, Accept-Language, and Content-Language are always allowed.

With this being said, the big danger of CORS is when using cookies for authentication. Wildcard (*) CORS should never be used with cookie-based auth. The workaround mentioned above won't allow attackers to use CORS with cookies.

GZIP

GZIP is one of the most popular compression algorithms. The details for the purposes of this blog post are irrelevant, all you need to know is that we can use GZIP to send smaller responses from our server. This is especially important for mobile clients who would much rather receive a 10KB response than a 100KB response. Adding this compression is usually the responsibility of a reverse proxy like NGINX, however, we can add it directly to our application since we won't be using a reverse proxy. To do this, it's as simple as one extra function call for each endpoint we create:

func (api *API) Init(router *mux.Router) {
	router.Handle("/hello", gziphandler.GzipHandler(api.handler(api.RootHandler))).Methods("GET")
}

Calling gziphandler.GzipHandler will ensure that our API response is compressed when being sent back to the requester. Most HTTP clients support GZIP compression without a problem, but if you're worried about it, the GZIP library will properly parse the Accept-Encoding header and only send a compressed response if gzip is provided in that header.

Logging requests

logging

When there is a request to our server we should be able to see the response and if there's an error any associated traceback information. To do this we need to wrap the http.ResponseWriter with our own statusCodeRecorder. This will allow us to get the status code that was returned from our handlers. We can also log how long the request took, this is helpful for getting a glance of how endpoints are performing. After we've created our statusCodeRecorder we can use it like so:

// ... snip ...
		defer func() {
			statusCode := w.(*statusCodeRecorder).StatusCode
			if statusCode == 0 {
				statusCode = 200
			}
            duration := time.Since(beginTime)

			logger := ctx.Logger.WithFields(logrus.Fields{
				"duration":    duration,
				"status_code": statusCode,
				"remote":      ctx.RemoteAddress,
			})
			logger.Info(r.Method + " " + r.URL.RequestURI())
		}()
/// ... snip ...

Handling unexpected errors

We want to make sure our server doesn't die if there is an unexpected error. Go doesn't have exceptions like other languages, so we can't put a big try/catch around our code. Instead, we need to use the recover function. From this, we can log the stack trace of the error and return a 500 response from our api. This will simply be:

// ... snip ...
        defer func() {
			if r := recover(); r != nil {
				ctx.Logger.Error(fmt.Errorf("%v: %s", r, debug.Stack()))
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
		}()
// ... snip ...

Unique request id

To generate a unique request id we can use the following code found in model/model.go.

package model

// ... snip ...

type Id []byte

func NewId() Id {
	ret := make(Id, 20)
	if _, err := rand.Read(ret); err != nil {
		panic(err)
	}
	return ret
}

This is in model because we will eventually use this for generating model primary keys.

Hello world!

For now, let's define a simple api handler, just to see that everything we've built thus far is working properly.

func (api *API) RootHandler(ctx *app.Context, w http.ResponseWriter, r *http.Request) error {
	_, err := w.Write([]byte(`{"hello" : "world"}`))
    return err
}

And we need to add it to our API.Init method:

func (api *API) Init(router *mux.Router) {
	router.Handle("/hello", gziphandler.GzipHandler(api.handler(api.RootHandler))).Methods("GET")
}

Now if we build our code and run our server, you can go to http://127.0.0.1:9090/api/hello and you should get the following output:

api

Conclusion

In this post, we covered a lot. You should now have a basic working web server, logging, configuration, and an understanding of separation of application vs request state. You can view the full code for this part here and if you have any questions or need clarification, feel free to post in the comments below.

While you're in the engineering blog mood, you should check out this post about our thoughts on the recent AWS re:Invent conference.


Photo by Kevin Ku from Pexels


Appendix - Sensitive Configuration

Using environment variables for your sensitive configuration values isn't foolproof. There is a possibility of accidentally exposing these values in various ways, such as your CI/CD. At AAF, we use AWS's Parameter Store which allows for secure parameters. The environment variables we set look something like this ssm:SuperSecretValue, which are then retrieved from the Parameter Store at runtime.

Newer
Older