Building a Testable and Extensible REST Client as an Abstraction

Manuel Riezebosch
Jan 13 · 4 min read

We’re building something that relies on the Azure DevOps REST API heavily, all the data we need is there. If such a thing is your primary source and you want to discover what you’re building driven by tests, you want something you can mock. You’ll need to keep all kinds of edge cases in your data source otherwise, which is hard to maintain.

What?

A testable and extensible REST client to interact with the Azure DevOps REST API. By testable, I mean something easy to mock and extensible that we can add additional requests later on without changing the clients’ interface.

Why?

I know there is an official SDK. But in my experience, it is a) hard to mock, b) hard to use, and c) probably incomplete. The authentication docs only have recommendations for non-interactive client-side applications, and in the authentication with a PAT sample, the low-level HTTP client is used…

How?

Requests

The request contains all the information required to make a request. That is:

  1. The name of the resource
  2. Query params
  3. Type information for the input and output
  4. Information that is needed to construct the (full) URL
  5. Additional headers
public class Request<TInput, TResponse>
{
public string Organization { get; set; }
public string Resource { get; set; }
public IDictionary<string, object> QueryParams { get; }
public IDictionary<string, object> Headers { get; }
public virtual Uri Uri(string organization) =>
new Uri($"https://dev.azure.com/{Organization}/");
}
public class Request<T> : Request<T, T>
{
}

The latter is mostly used since with proper REST, your input and output will have the same shape. Here is all the information you need to make a request, apart from authorization, which we’ll deal with on the client.

This setup enables us to create derived classes for different endpoints. Release Management, for example, uses a different URL:

public class ReleaseManagementRequest<TInput, TResponse> : 
Request<TInput, TResponse>
{
public override Uri Uri(string organization) =>
new Uri($"https://vsrm.dev.azure.com/{organization}/");
}
public class ReleaseManagementRequest<T> :
ReleaseManagementRequest<T, T>
{
}

Constructing a request message is mainly done with a factory method describing the resource and accepting (optional) parameters to shape the request:

public static class Builds
{
public static Request<BuildDefinition> BuildDefinition(
string project,
string id) =>
new Request<BuildDefinition>(
$"{project}/_apis/build/definitions/{id}");
}

Data

For data, we do not distinguish between response or input since with REST. We only care about state transfer. To request specific data, we construct a request message with factory-method in a consumer-driven contract testing way: with integration tests validating the response.

[Fact]
public async Task QueryBuild()
{
var build = await _client.GetAsync(
Requests.Builds.Build("some-project", "24010"));
build.Should().BeEquivalentTo(new Build
{
Id = 24010,
Project = new Response.Project()
{
Description = "the description",
Id = "...",
Name = "some-project",
Url = new Uri("...")
},
Result = "succeeded",
Definition = new Definition
{
Id = 1704,
Name = "demo-build-or-release"
}
});
}

This approach ensures that we will notice any breaking changes on the API quickly. Now you only add properties you need because everything you add comes with the cost of manual labor.

⚠️ One major drawback of this approach is that you’ll need to be very careful when you need to post updates since you may forget a property or two, which will then get lost.

Client

Opposed to the more traditional request/response clients, loaded with domain-specific methods, our client only takes care of authorization, invoking a request and transforming the input and output objects. It is merely a domain-specific wrapper on top of another client that knows how to make HTTP or REST requests. In my case, that’ll be Flurl:

public class AzureDevOpsClient : IAzureDevOpsClient
{
private readonly string _organization;
private readonly string _token;
public AzureDevOpsClient(string organization, string token)
{
_organization = organization;
_token = token;
}
static AzureDevOpsClient()
{
// Some one time rest client customizations,
// like contract resolver settings.
}
public Task<TResponse> GetAsync<TResponse>
(Request<TResponse> request) =>
await new Url(request.Uri(_organization))
.AllowHttpStatus(HttpStatusCode.NotFound)
.AppendPathSegment(request.Resource)
.SetQueryParams(request.QueryParams)
.WithBasicAuth(string.Empty, _token)
.GetJsonAsync<TResponse>()
.ConfigureAwait(false);
}

Wrap Up

The model we came up with is exceptionally flexible and testable:

// Arrange
var fixture = new Fixture();
var client = Substitute.For<IAzureDevOpsClient>();
client
.GetAsync(Arg.Any<Request<Build>>())
.Returns(fixture.Create<Build>());
// Act
var target = new SomethingUsingClient(client);
var result = target.Execute();
// Assert
result.Should().Be(true);

You can add and create additional request and data classes on the fly without changing the client nor its interface:

client.GetAsync(new Request<Something>("_apis/some-resource/1234");

The one thing you need to be very careful of is updating data since you need a complete model then, and it is tough to determine if you are only looking at the data.

var definition = await client.GetAsync(
Requests.Builds.Definition("some-project", "24010"));
await client.PostAsync(
Requests.Builds.Definition("some-project", "24010"),
definition);

Some made-up example, what happens if some properties on the definition class are missing?

Next

Some nuts that were hard to crack and I will probably address later:

  1. POST & PATCH
  2. Continuation or Streaming API
  3. 404 as an expected value

An extract (with a little rewrite) of the code I’ve been talking about is published here: https://github.com/riezebosch/AzureDevOpsRest. Maybe it’ll end up in a NuGet package one day.

It is also about Test Driving the Design for an integration component by using (only?) integration tests.

 by the author.

Manuel Riezebosch

Written by

Tweet, tweet, tweedle

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade