Request Routing with Consul & Go

Write an API Gateway with Go.

Cheikh seck
Dev Genius
Published in
6 min readJan 6, 2025

--

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.

Ave Calvar unsplash.com

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 the registerServiceWithConsul function. I’ll populate a field named Tags and have it include tag gateway. With this update, I can have Consul only return services with this tag. Here is the revised registerServiceWithConsul 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 be Tags 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

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Written by Cheikh seck

[Beta] Checkout my AI agents: https://zeroaigency.web.app/ Available for hire as a technical writer or software developer: cheeikhseck@gmail.com

Responses (2)

Write a response