Creating a Client for Accessing an API in .NET Core (edit)
https://blog.bitscry.com/2019/11/14/creating-a-client-for-accessing-an-api-in-net-core/
While there seems to be plenty of resources and guides out there to help users create an API there seems to be much less guidance on how to create a client for accessing an API.
I’m fine with calling an API using HttpClient or through a library such as RestSharp but I haven’t managed to find much documentation on their best practice usage within an API client as opposed to making one off calls to an API.
The one useful resource I did manage to find was by Exception Not Found and detailed the creation of his Ultimate Rest Client, I’ve used this as the basis for my own standardized REST client, though with a few tweaks to enable Dependency Injection and to enable it to fail fast rather than logging errors and continuing on. Details below though it’s still very much a work in progress…
BaseClient
Design
In the Ultimate Rest Client example above a BaseClient is created that inherits from the RestSharp RestClient, this client deals with the sending of requests via RestSharp which includes authentication, response deserialization, error handling and caching.
The client for interacting with the target API then inherits from this BaseClient and uses the MakeRequest methods provided to make requests.
After initially building my client like this I decided to refactor it so that rather than inheriting from RestClient my BaseClient would instead accept an instance of IRestClient as a parameter in the constructor. I’ve done this so that I can provide the RestClient instance via Dependency Injection if required and so that I can mock it to enable testing.
Error Handling
In the original Ultimate Rest Client if an error occurs it is logged to an ILogger instance and then the BaseClient returns a default object of whatever type was expected. Personally I think this is a bad idea as if there’s an error making an API call I believe the client should fail fast and throw an exception rather than expecting you to deal with default objects further up the chain. For this reason I decided to update the LogError method to ThrowException though I’ve kept an ILogger in the BaseClient incase I want to add some logging in future.
private void ThrowException(Uri BaseUrl, IRestRequest request, IRestResponse response)
{
//Get the values of the parameters passed to the API
string parameters = string.Join(", ", request.Parameters.Select(x => x.Name.ToString() + "=" + ((x.Value == null) ? "NULL" : x.Value)).ToArray());
//Set up the information message with the URL, the status code, and the parameters.
string info = "Request to " + BaseUrl.AbsoluteUri + request.Resource + " failed with status code " + response.StatusCode + ", parameters: "
+ parameters + ", and content: " + response.Content;
//Acquire the actual exception
ApiException apiException;
if (response != null && response.ErrorException != null)
{
apiException = new ApiException(
(int)response.StatusCode,
BaseUrl.AbsoluteUri + request.Resource,
request.Method.ToString(),
string.Join(", ", request.Parameters.Select(x => x.Name.ToString() + "=" + ((x.Value == null) ? "NULL" : x.Value)).ToArray()),
response.Content,
info,
response.ErrorException
);
}
else
{
apiException = new ApiException(
(int)response.StatusCode,
BaseUrl.AbsoluteUri + request.Resource,
request.Method.ToString(),
string.Join(", ", request.Parameters.Select(x => x.Name.ToString() + "=" + ((x.Value == null) ? "NULL" : x.Value)).ToArray()),
response.Content,
info
);
}
// Could log here rather than throwing exception as in original example but probably best to fail fast.
//_logger.LogError(apiException, info);
throw apiException;
}
Caching
The original BaseClient supports caching and although I don’t think this isn’t something I think I’ll need I’ve left it in anyway should it later be required.
public T MakeRequestFromCache<T>(IRestRequest request, string cacheKey, int cacheMinutes = 30) where T : class, new()
{
var item = _cache.Get<T>(cacheKey);
if (item == null) //If the cache doesn't have the item
{
var response = Execute<T>(request); //Get the item from the API call
if (response.StatusCode == System.Net.HttpStatusCode.OK)
{
_cache.Set(cacheKey, response.Data, cacheMinutes); //Set that item into the cache so we can get it next time
item = response.Data;
}
else
{
ThrowException(BaseUrl, request, response);
}
}
return item;
}
Async
For whatever reason async wasn’t used in the original BaseClient, as I tend to create things as async by default unless there’s a pressing reason not to I’ve added async variants of the MakeRequest and Execute methods.
public async Task<T> MakeRequestAsync<T>(IRestRequest request) where T : new()
{
var response = await ExecuteTaskAsync<T>(request);
if (response.IsSuccessful)
{
return response.Data;
}
else
{
ThrowException(BaseUrl, request, response);
return default(T);
}
}
public async override Task<IRestResponse<T>> ExecuteTaskAsync<T>(IRestRequest request)
{
var response = await base.ExecuteTaskAsync<T>(request);
TimeoutCheck(request, response);
return response;
}
DotMailerCoreClient
I am using the BaseClient as the basis for a client that interacts with the dotdigital REST API and I will be passing services through to this client and the base client using Dependency Injection.
Options
I’m using an IOptions interface to pass settings through to the client and base client unlike the Ultimate Rest Client, currently these are only the base URI and an authenticator but this could easily be extended.
public class DotMailerCoreOptions
{
public string BaseUrl { get; set; } = "https://api.dotmailer.com/v2/";
public IAuthenticator Authenticator { get; set; }
}
These options are registered as an action in a helper method based on that from this article which also assists with registering the client with the service collection.
public static class DotMailerCoreServiceCollectionExtensions
{
public static IServiceCollection AddDotMailer(this IServiceCollection collection,
Action<DotMailerCoreOptions> setupAction)
{
if (collection == null) throw new ArgumentNullException(nameof(collection));
if (setupAction == null) throw new ArgumentNullException(nameof(setupAction));
collection.Configure(setupAction);
return collection.AddSingleton<IDotMailerCoreClient, DotMailerCoreClient>();
}
}
The options can then be specified in the ConfigureServices method like so.
// Add client
serviceCollection.AddDotMailer(options =>
{
options.BaseUrl = "https://api.dotmailer.com/v2/";
options.Authenticator = new HttpBasicAuthenticator("demo@apiconnector.com", "demo");
});
JSON Serialization
RestSharp uses it’s own JSON serializer which has quite a few issues however they’ve added in the ability to override it with a serializer of your choice so I have replaced it with the Newtonsoft serializer which I’m more comfortable with using.
// Add client
serviceCollection.AddDotMailer(options =>
{
options.BaseUrl = "https://api.dotmailer.com/v2/";
options.Authenticator = new HttpBasicAuthenticator("demo@apiconnector.com", "demo");
});
public class NewtonsoftJsonRestSerializer : IRestSerializer
{
private readonly JsonSerializer _serializer;
public NewtonsoftJsonRestSerializer()
{
_serializer = new JsonSerializer
{
MissingMemberHandling = MissingMemberHandling.Ignore,
NullValueHandling = NullValueHandling.Include,
DefaultValueHandling = DefaultValueHandling.Include,
DateFormatHandling = DateFormatHandling.IsoDateFormat,
DateTimeZoneHandling = DateTimeZoneHandling.Unspecified
};
_serializer.Converters.Add(
new StringEnumConverter { NamingStrategy = new CamelCaseNamingStrategy() }
);
}
public string Serialize(object obj)
{
using (var stringWriter = new StringWriter())
{
using (var jsonTextWriter = new JsonTextWriter(stringWriter))
{
jsonTextWriter.Formatting = Formatting.Indented;
jsonTextWriter.QuoteChar = '"';
_serializer.Serialize(jsonTextWriter, obj);
var result = stringWriter.ToString();
return result;
}
}
}
public string ContentType { get; set; } = "application/json";
public T Deserialize<T>(RestSharp.IRestResponse response)
{
var content = response.Content;
using (var stringReader = new StringReader(content))
{
using (var jsonTextReader = new JsonTextReader(stringReader))
{
return _serializer.Deserialize<T>(jsonTextReader);
}
}
}
public string Serialize(Parameter parameter) => Serialize(parameter.Value);
public string[] SupportedContentTypes { get; } =
{
"application/json",
"text/json",
"text/x-json",
"text/javascript",
"*+json"
};
public DataFormat DataFormat { get; } = DataFormat.Json;
}
This can be used for serialization and deserialization and passed through to the client via Dependency Injection.
// Add json deserialization service
serviceCollection.AddSingleton<IDeserializer, NewtonsoftJsonRestSerializer>();
// Add json serialization service
serviceCollection.AddSingleton<IRestSerializer, NewtonsoftJsonRestSerializer>();
Tests
I’ve created integration tests to call the endpoints using dummy credentials, some of these methods don’t currently work using these credentials so I’ve added tests to check for these errors.
[Fact]
public async Task AddCampaignAttachment_ReturnsAnErrorResponse()
{
// Arrange
var campaignId = TestFactory.GetCampaignId();
var attatchment = TestFactory.GetCampaignAttatchment();
var client = TestFactory.CreateDotMailerCoreClient();
// Act
var ex = await Assert.ThrowsAsync<ApiException>(() => client.AddCampaignAttachmentAsync(campaignId, attatchment));
// Assert
Assert.Equal((int)HttpStatusCode.Forbidden, ex.HttpStatus);
Assert.Contains("ERROR_FEATURENOTACTIVE", ex.ResponseContent);
}
I’ve also created unit tests where I’ve mocked up the expected RestResponse object returned by the RestClient so this can be returned to the BseClient and then the DotMailerCoreClient.