Properly Propagating "azds-route-as" in Azure Dev Spaces

Routing in Azure Dev Spaces

Azure Dev Spaces is pure dopamine, it makes debugging and integreation testing a breeze in a complex kubernetes setup. And the best thing is: you don't need to modify anything in your code to make it work.

Except for one thing...

To make routing work properly, you need to forward the "azds-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("azds-route-as"))
{
    // Propagate the dev space routing header
    request.Headers.Add("azds-route-as", this.Request.Headers["azds-route-as"] as IEnumerable<string>);
}
var response = await client.SendAsync(request);

(this is a snippet of the official tutorial)

Unacceptable! To hide this monstrosity, I'll make use of a DelegatingHandler to intercept outgoing HTTP messages and add the "azds-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 "azds-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 "azds-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 dev space routing header
    if (incomingRequest.Headers.ContainsKey("azds-route-as"))
    {
      outgoingRequest.Headers.Add("azds-route-as", incomingRequest.Headers["azds-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 dev space routing header
    if (incomingRequest.Headers.ContainsKey("azds-route-as"))
    {
      outgoingRequest.Headers.Add("azds-route-as", incomingRequest.Headers["azds-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 Azure Dev Spaces, resulting in a more flexible, futureproof application.

You can find all the code here.