Request Routing with Consul & Go
Write an API Gateway with Go.
I’ve always struggled to understand the utility of an API gateway, until recently. A team, I worked with, was tasked with deploying two microservices to the same subdomain. One microservice handled the user interface, while the other handled backend requests.
They were able to complete the task with an API gateway. This use case has helped me understand the utility of an API gateway. It just clicked.
A gateway can route requests with a specific path prefix to the corresponding microservice. For instance, any request path prefixed with /api
will be forwarded to the backend server.
One benefit of this is that you no longer need a CORS policy to get your frontend to perform HTTP requests to the backend.
In this post, I want to write a self-configuring API gateway with Go and Consul. The gateway should be able to:
- Pull services, with tag
gateway
, from the Consul registry. - Assign a request path prefix to a service, based on the service’s Id.
Updating the Service
In a previous post, I wrote a service that would self register with Consul. For this post, I’ll need to update a few things in it:
- Update the
AgentServiceRegistration
in theregisterServiceWithConsul
function. I’ll populate a field namedTags
and have it include taggateway
. With this update, I can have Consul only return services with this tag. Here is the revisedregisterServiceWithConsul
function:
// registerServiceWithConsul registers the service with Consul
func registerServiceWithConsul(
serviceID,
serviceName,
serviceAddress string,
servicePort int,
consulClient *api.Client,
) error {
// Create a Consul client
// Define the service registration
registration := &api.AgentServiceRegistration{
ID: serviceID,
Name: serviceName,
Address: serviceAddress,
Port: servicePort,
Check: &api.AgentServiceCheck{
HTTP: fmt.Sprintf("http://%s:%d/health", serviceAddress, servicePort),
Interval: "10s",
Timeout: "5s",
},
Tags: []string{"gateway"}, // New line
}
...
}
- The next update will consist of adding a new handler to path
dump_post
. This handler will dump the headers and request body to the terminal. I will use this endpoint to see if the proxy is sending the entire request. Here is the code for that will register the handler:
http.HandleFunc("/dump_post", func(w http.ResponseWriter, r *http.Request) {
log.Println("Request Headers:")
// looping over request headers
for name, values := range r.Header {
for _, value := range values {
log.Printf("\t\t%s: %s", name, value)
}
}
// Log the request method and URL
log.Printf("Request Method: %s, Request URL: %s\n", r.Method, r.URL.String())
// Dump the request body if it's a POST or PUT request
if r.Method == "POST" || r.Method == "PUT" {
body, err := io.ReadAll(r.Body)
if err != nil {
log.Println("Error reading request body:", err)
http.Error(w, "Unable to read request body", http.StatusInternalServerError)
return
}
// Log the body content
log.Println("Request Body:")
log.Println("\t\t", string(body))
}
})
You may find the full revised version of the “self-announcing” service in the sources section below. Now, I think my service is ready for testing with the gateway.
Building a Gateway
To start building the gateway, I’ll mount a handler to a path generated from an eligible service’s ID:
- The handler path will be a result of prefixing and suffixing the service ID with a forward slash (
/
). The second slash is required to ensure that all paths prefixed with the service ID are routed correctly.
The handler will route requests with the ReverseProxy
type from Go’s httputil
package. The ReverseProxy
type has a field named Rewrite
. This field is a function and enables me to customize how I want a request proxied. In this case, I’ll have it:
- Trim the path received, and remove the part of it that is the service ID.
- Construct a URL with: the path set to the trimmed path, the host set to the service’s local address and port.
- Pass the client’s IP to the service with other
X-Forwarded-*
headers.
ReverseProxy
also has a method named ServeHTTP
, which I’ll pass as the HandlerFunc
when I’m registering the path prefix of a service.
Here is the function responsible for registering a service retrieved from Consul:
func registerHandler(
serviceId string,
service *api.AgentService, // AgentService type from Consul API
) {
pathname := fmt.Sprintf("/%s", serviceId)
// when setting host need address:port
address := fmt.Sprintf("%s:%v", service.Address, service.Port)
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
url := url.URL{
Scheme: "http",
Host: address,
// r.In represents the inbound request
Path: strings.TrimPrefix(r.In.URL.Path, pathname),
}
log.Println("forwarding:", url.Path, "-->", url.String())
r.SetXForwarded()
// r.Out represents the outbound request
// setting the URL will tell the proxy where to
// to look for a response.
r.Out.URL = &url
},
}
http.HandleFunc(
pathname+"/",
proxy.ServeHTTP,
)
}
If you noticed, I’m registering the service to the default HTTP request multiplexer. This should be limited to demo use cases only and is not recommended in production environments.
With the handler registration function, essentially the proxy, I can begin writing the main function.
Putting it Together
To test out this proxy, I’ll need a main function that:
- Constructs the Consul client. Here is the code performing this:
consulAddress := "http://localhost:8500"
config := &api.Config{
Address: consulAddress, // Consul address
}
client, err := api.NewClient(config)
if err != nil {
log.Fatalf("Failed to create Consul client: %v", err)
}
- Query the Consul registry for services tagged with
gateway
. This will be done with a Consul Filter expression. In this case, the expression will beTags contains “gateway”
. Here is the code performing this:
// filter expression to return only services with
// with gateway
filter := `Tags contains "gateway"`
services, err := client.Agent().ServicesWithFilter(filter)
if err != nil {
log.Fatalf("Failed to fetch filtered services: %v", err)
}
- Loop over the returned services and call the
registerHandler
function on each cycle to register a handler for the given service’s path prefix. Here is the code performing this:
// Print the filtered services
// services returned as map[string]*api.AgentService
for serviceId, service := range services {
registerHandler(
serviceId,
service,
)
fmt.Printf("Proxying path: /%s, Address: %s, Port: %d, Tags: %v\n",
serviceId, service.Address, service.Port, service.Tags)
}
- Lastly, launch the HTTP server, with the default MUX, at port 7000:
http.ListenAndServe(":7000", nil)
Upon running this main function I should see each service registered with this custom Gateway. You can view the entirety of the main function by clicking on the Source code link in the sources section below.
With the main function in place, I can begin the test.
The Test
For this test, I’ll start the service and then the gateway. The service will register itself with Consul, after which the gateway will retrieve the service and assign a route prefix to it.
I’ll then perform a request to my service’s /dump_post
route through the gateway. Here the request I’ll perform:
curl -X POST http://localhost:7000/example-service-1/dump_post \
-H "Content-Type: application/json" \
-H "Custom-Header: TestHeaderValue" \
-d '{"key": "value", "another_key": "another_value"}'
After the request, I’ll inspect the data logged by the service for any discrepancies.
Here is a GIF of the test:

As expected, none of the original data sent has been modified and a few headers were added to the request.
Conclusion
It was an interesting experience writing a Go proxy with Consul. There are a few things to note about this implementation:
- Host names and API route prefixes are determined at startup. And changes to your registry are applied on gateway startup. It would be tempting to look into a real time variation.
- Configuration is hard coded. For example: Consul API address, server port number.
- Requests are being requested over HTTP and not HTTPS.
I have not yet tested the performance of this implementation compared to other reverse proxy servers. I thought it was cool concept to create an API gateway with just the standard library and the Consul registry.
Thank you for reading!
Sources
- Revised “self-announcing” service code — https://github.com/cheikhsimsol/go_proj/tree/main/consul/sv1_gateway
- Consul Filters — https://developer.hashicorp.com/consul/api-docs/features/filtering
- Service Discovery with Consul (previous post) — https://medium.com/p/b3ec7bc24ec5
- Source code — https://github.com/cheikhsimsol/go_proj/blob/main/consul/gateway
- Consul Go client — https://pkg.go.dev/github.com/hashicorp/consul/api