Are you using a microservice architecture?
If so, chances are that you need to deploy an API gateway in the long run. An API gateway is a networking software that accepts requests from clients and routes them to your backend services while passing the responses back. It sits between the public Internet and your internal IT infrastructure. On the Internet-facing side, it presents a coherent collection of endpoints to users. Internally, it not only forwards requests to the appropriate microservices, but it usually also performs a number of housekeeping tasks, such as authentication, logging, rate limiting, metering, auditing, and possibly payload transformations.
Sounds like a complex piece of software? Far from it!
In this article, I am going to argue that you can and probably should create your own API gateway. While there are many commercial and open-source solutions available, these are designed for general use and likely include many features you don't need. The selection, deployment, and configuration of such a general-use API gateway are complex, especially if you have specific requirements. If you choose to write your own API gateway, you are not only sure to cover these requirements, but you are also sure that you can add more features in the future that a general-use API gateway may not support.
I am including code snippets for the building blocks of an actual API gateway in Golang. The Go language is very suitable for this kind of task because it is capable of processing large amounts of I/O operations in parallel.
First, let's examine the main functionality and architecture of an API gateway. An API gateway is essentially a web server that performs reverse proxying. If your system is running microservices, there is probably already some component that acts as a reverse proxy server for you, such as Nginx or HAProxy. That component may already perform TLS termination and load-balancing.
The following are common use cases for an API gateway:
Let's look at the web service functionality first. Your API gateway needs to handle incoming HTTP (s) requests. It either sits behind a load balancer that terminates TLS, or it handles load balancing and TLS offloading itself. The Golang net/http
package, which is part of the Go standard library, supports both use cases. We will look at the former case from the perspective of network administration, it is advantageous to separate those two functions. This means our server only needs to deal with unencrypted payloads. In the following example, let's assume that we want to perform authentication. We implement three handlers to that end (Go 1.19 assumed):
import ("net/http"
"log"
"io/ioutil")
http.HandleFunc("/login", func(res http.ResponseWriter, req * http.Request) {
if (req.method != "POST") {
http.NotFound(res, req)
return
}
params: = req.URL.Query()
body, err: = ioutil.ReadAll(req.Body)
if err != nil {
http.Error(res, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// your actual login logic follows here using the data in "body" and "params"
token, err: = myAuth.DoLogin(body, params)
if (err == nil) {
res.WriteHeader(http.StatusOK)
...
} else {
res.WriteHeader(http.StatusUnauthorized)
...
}
}
http.HandleFunc("/logout", func(res http.ResponseWriter, req * http.Request) {
if (req.method != "POST") {
http.NotFound(res, req)
return
}
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
res.WriteHeader(http.StatusUnauthorized)
...
return
}
// end session and remove any state related to user login account
}
http.HandleFunc("/", func(res http.ResponseWriter, req * http.Request) {
log.Printf("incoming request: %s %", req.Host, req.URL.String())
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
res.WriteHeader(http.StatusUnauthorized)
...
return
}
// todo: implement routing and forwarding
}
These three request handlers are all that is needed. Two of them handle authentication and the third one is the default handler that matches any URL and forwards requests to the backend. The Go HTTP library executes each request handler in its own goroutine which are lightweight threads managed by Go. This means that requests are processed concurrently and possibly in parallel if the program is allowed to use more than one CPU core. It also means that this program can handle many simultaneous requests. We don't need a web framework for this task, as we only have to implement three simple request handlers.
The program assumes that the POST /login
endpoint accepts credentials in the request body, so we read the request body into a byte array. Additional parameters can be passed in the query string. Since the http.HandleFunc()
does not distinguish between HTTP methods, we have to do this by ourselves. The handler returns status 200 OK if authentication succeeds, 401 if authentication fails, and 400 if the request body cannot be read.
The actual authentication logic is implemented in myAuth
which is not detailed here. The credentials could be passed to a backend service such as Okta, Auth0 or Keycloak, or you could look up the credentials in a database. The token that is passed back by the DoLogin
method could be a bearer token, a JSON web token, or a session id for use in subsequent requests either in the header or in a cookie. Again, the concrete implementation depends on your security requirements. Its purpose is to authenticate the user in the requests that follow after login.
In the above example, we use the "Authorization" header of the request to read out the token and pass it to the authenticate()
method of myAuth
, so that each request except the request to the login endpoint is authenticated before it is processed. The authentication could be implemented, for example, by looking up the token in a time-limited cache, in a database, or by sending a request to an ID provider service. If parts of your API or your website were public, you could decide to exclude certain endpoints from authentication.
Finally, we have to spin up our web server in the main go routine by calling the ListenAndServe()
function of the HTTP package. The second argument to this function is nil
which means that routing is performed by the DefaultServeMux multiplexer that passes requests to our previously defined handlers. If we wanted to handle HTTPS traffic instead, we would use the ListenAndServeTLS()
method of the HTTP package.
func main() { // route definitions go here...
log.Println("API gateway listening on port 8880")
err: = http.ListenAndServe(":8880"
", nil)
if err != nil {
log.Panic(err)
}
}
The next thing we need to accomplish is to route incoming requests to the appropriate backend endpoints. We might consider creating additional route handlers to replace the default handler using the Go ServeMux
multiplexer. However, this quickly gets repetitive. Since ServeMux
has no support for either HTTP methods, host matching, or URL patterns, it probably also gets tedious. One option is to plug in another third-party multiplexer such as gorilla/mux or httprouter into the listener and let it do the routing. The handler functions in the route definitions would then simply pass the request along with the parsed parameters and target URL to the reverse proxy. Here is an example using httprouter
:
import ("github.com/julienschmidt/httprouter"
"log"
"net/http")
func main() {
router: = httprouter.New() router.GET("/my/:name", function(res http.ResponseWriter, req http.Request, p httprouter.Params)) {
proxy("http://server/endpoint-1", res, req, p)
}
router.POST("/another/route/path", function(res http.ResponseWriter, req * http.Request, p httprouter.Params)) {
proxy("http://server/endpoint-2", res, req, p)
}
... err: = http.ListenAndServe(":8880", router)
if err != nil {
log.Panic(err)
}
}
This approach is limited by the functionality that the respective multiplexer offers, e.g. httprouter in this case. A more flexible approach is to implement routing by ourselves. To that end, we have to parse the parts of the request we are interested in. This could be the URL path, the hostname, query string, request headers, or even the request body. In the following example, we use the default handler to do exactly that. We pass three variables to the lookupTargetURL()
function: the hostname (string), the URL path (string), and the query parameters (map[string][]string). The lookupTargetURL()
function matches this information against a routing table, possibly using regular expressions, and returns the URL of the target endpoint and a boolean flag that informs us whether the endpoint is public or access restricted.
import ("log"
"net/http")
func main() {
...
// default handler
http.HandleFunc("/", func(res http.ResponseWriter, req * http.Request) {
host: = req.Host
path: = req.URL.Path
qs: = req.URL.Query()
log.Printf("incoming request: %s %", host, req.URL.String())
targetURL,
isPublic: = lookupTargetURL(host, path, qs)
if (targetURL == "") {
http.Error(res, "Not Found", 404)
return
}
if (!isPublic) {
if (!myAuth.authenticate(req.Header.Get("Authorization"))) {
http.Error(res, "Unauthorized", 401)
return
}
}
proxy(targetURL, res, req)
}
}
If the API gateway does not find a matching target route, the returned target URL is empty. In this case, a 404 status code is returned. If a matching route is found with access restricted, the API gateway performs authentication before forwarding the request. If this fails, status code 401 is returned. Forwarding is accomplished by invoking the proxy()
function that is detailed below.
The implementation of lookupTargetURL()
depends on your requirements and is not detailed here. Essentially, it matches the arguments against some sort of data structure. This could be a map of patterns, a map of structs, a map of strings, or anything else that fits your needs. The final and possibly most interesting building block of the API gateway is the forwarding function itself. Once again, we make use of the Go standard library functions for this.
import ("log"
"net/http"
"net/url"
"httputil")...func proxy(targetURL string, res http.ResponseWriter, req * http.Request) {
target, err: = url.Parse(targetURL) if (err != nil) {
http.Error(res, "Invalid URL", 500) return
}
proxy: = httputil.NewSingleHostReverseProxy(target) proxy.Director = func(request * http.Request) {
request.URL.Scheme = target.Scheme request.URL.Host = target.Host request.URL.Path = target.Path
}
log.Printf("Forwarding request to %v", target) proxy.ServeHTTP(res, req)
}
We use the NewSingleHostReverseProxy()
function in the httputil
package to create a reverse proxy. It takes a URL struct argument and routes requests to the scheme, host, and (optionally) the base path given in that URL struct. Prior to that, we must parse the targetURL
string returned by our route lookup function using the Parse()
function in the url
package. Note that a reverse proxy object is created here on the fly, one for each request. If we knew the target host and scheme beforehand, we could just create this object once in the main function and thus save some memory resources. The same object would then be reused for each request by calling the proxy.ServeHTTP()
method which executes the relay.
Before we call this function, we must perform a URL rewrite that transforms the original request's URL into the URL that we are forwarding to. This is achieved by assigning a function to proxy.Director
that mutates a clone of the original request. In our case, we assign the parsed scheme, host, and path string variables to the new request. These values were previously obtained by our route look-up function and passed into the proxy function as a string. We could modify other parts of the incoming request as well if we wanted. For example, we could remove sensitive information from the request headers or add new request headers.
Likewise, it is possible to modify the response in the same fashion by assigning a function to proxy.ModifyResponse
and performing mutations on the response object. In most cases, this won't be necessary, as the response object is usually modified by the backend service endpoint. Last but not least, we could assign a function to proxy.ErrorHandler
in order to intercept and handle errors that may occur when trying to connect to a backend service. By default, a 502 Bad Gateway status is returned to the caller, if the backend service cannot be reached.
An API gateway is a powerful tool in your DevOps toolbox. It just takes a few lines of code to create a basic API gateway in Golang. Most of the work will be spent on implementing your routing logic and any housekeeping functions you might need.