Properly Propagating "kubernetes-route-as" in Bridge to Kubernetes

Out with the Old, Bring in the New

Alas! Azure Dev Spaces has fallen! But we welcome its successor: Bridge to Kubernetes. The goal is still to make debugging and integreation testing easy in a complex kubernetes setup. The main difference is that your code now runs locally on your machine.

One thing that hasn't changed is the fact that you have to use header forwarding. The header itself is now called kubernetes-route-as instead of azds-route-as. And once again Microsoft documentation fails to mention the best way to do it.

This is a re-take of my previous blog, but updated for Bridge to Kubernetes.

Routing in Bridge to Kubernetes

To make routing work properly, you need to forward the "kubernetes-route-as" header when making outgoing HTTP calls. That means you have to write code like this for EVERY SINGLE CALL:

var request = new HttpRequestMessage();
request.RequestUri = new Uri("http://mywebapi/api/values/1");
if (this.Request.Headers.ContainsKey("kubernetes-route-as"))
{
    // Propagate the routing header
    request.Headers.Add("kubernetes-route-as", this.Request.Headers["kubernetes-route-as"] as IEnumerable<string>);
}
var response = await client.SendAsync(request);

Unacceptable! To hide this monstrosity, I'll make use of a DelegatingHandler to intercept outgoing HTTP messages and add the "kubernetes-route-as" header on-the-fly.

Talking to the Backend

I made a separate class for all communication with my backend. As a good boy, I used the HttpClientFactory with dependency injection.

BackHttpClient.cs

public class BackHttpClient : IBackHttpClient
{
  private readonly HttpClient client;

  public BackHttpClient(HttpClient client)
  {
    this.client = client;
  }

  public async Task<string> GetValueAsync(int id)
  {
    var response = await client.GetAsync($"api/values/{id}");
    if (response.IsSuccessStatusCode)
    {
      var result = await response.Content.ReadAsStringAsync();
      return result;
    }
    else
    {
      throw new Exception($"Value with id {id} could not be retrieved: {response.ReasonPhrase}");
    }
  }
}

Startup.cs

services.AddHttpClient<IBackHttpClient, BackHttpClient>(client =>
{
  client.BaseAddress = new Uri("http://localhost:63448");
  client.DefaultRequestHeaders.Add("Accept", "application/json");
})

(I used a local service, so you don't have to set up an AKS to run the demo)

Intercepting the Message

Since I don't want to worry about the "kubernetes-route-as" header with every HTTP call, I'm going to intercept every call before it is made.

ASP.NET Core middleware is not going to be of any help here. This middleware is used for incoming HTTP calls, not outgoing. Instead you can add a collection of DelegatingHandler objects to your HttpClient. This builds an outgoing pipeline for the HttpClient similar to the ASP.NET Core middleware.

DelegateHandlers

The following DelegatingHandler will add the "kubernetes-route-as" header to the outgoing request if it was set on the incoming request. Notice that getting the incoming request is not straightforward and will be done in the next part.

RoutingHttpHandler

public class RoutingHttpHandler : DelegatingHandler
{
  protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage outgoingRequest, CancellationToken cancellationToken)
  {
    //var incomingRequest = ???; //TODO

    // Propagate the routing header
    if (incomingRequest.Headers.ContainsKey("kubernetes-route-as"))
    {
      outgoingRequest.Headers.Add("kubernetes-route-as", incomingRequest.Headers["kubernetes-route-as"] as IEnumerable<string>);
    }
    return base.SendAsync(outgoingRequest, cancellationToken);
  }
}

By using AddHttpMessageHandler, I can add the DelegatingHandler to the HttpClient injected into my IBackHttpClient.

Startup.cs

services.AddTransient<RoutingHttpHandler>();

services.AddHttpClient<IBackHttpClient, BackHttpClient>(client =>
{
  client.BaseAddress = new Uri("http://localhost:63448");
  client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHttpMessageHandler<RoutingHttpHandler>();

Getting the HttpContext

The final piece of the puzzle is getting the incoming HTTP Request. Since HttpContext.Current is not available in .NET Core, I will need another trick: the IHttpContextAccessor. This is basically the injectable version of HttpContext.Current, and is the result of Microsoft's continuing effort to replace static singletons with injectable ones.

Startup.cs

services.AddHttpContextAccessor();
services.AddTransient<RoutingHttpHandler>();

services.AddHttpClient<IBackHttpClient, BackHttpClient>(client =>
{
  client.BaseAddress = new Uri("http://localhost:63448");
  client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHttpMessageHandler<RoutingHttpHandler>();

RoutingHttpHandler.cs

public class RoutingHttpHandler : DelegatingHandler
{
  private readonly IHttpContextAccessor httpContextAccessor;

  public RoutingHttpHandler(IHttpContextAccessor httpContextAccessor)
  {
    this.httpContextAccessor = httpContextAccessor;
  }

  protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage outgoingRequest, CancellationToken cancellationToken)
  {
    var incomingRequest = httpContextAccessor.HttpContext.Request;

    // Propagate the  routing header
    if (incomingRequest.Headers.ContainsKey("kubernetes-route-as"))
    {
      outgoingRequest.Headers.Add("kubernetes-route-as", incomingRequest.Headers["kubernetes-route-as"] as IEnumerable<string>);
    }
    return base.SendAsync(outgoingRequest, cancellationToken);
  }
}

Conclusion

With a little bit of effort, you can shield your code from the requirements to use Bridge to Kubernetes, resulting in a more flexible, futureproof application.

UPDATE!

Someone informed me that there is already a NuGet package that does this.

Using this package, the resulting code would be:

services.AddHttpClient<IBackHttpClient, BackHttpClient>(client =>
{
  client.BaseAddress = new Uri("http://localhost:63448");
  client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddHeaderPropagation(options => options.Headers.Add("kubernetes-route-as"));