3
\$\begingroup\$

My previous RestClient wasn't disposing a lot of resources. I've fixed it (hopefully without introducing new issues) and because a few new patterns emerged I did a lot of refactoring and simplified a few things - in fact I was able to remove more then a hundered lines of code.

It looks like there is a lot of code left but the only complex part is the RestClient. Other classes are only data or contain mostly one-liner convenience extensions.


RestClient

I reduces the main interface to just one method for invoking http-methods. Details are hidden by the HttpMethodContext that provides the required flexibility.

[PublicAPI]
public interface IRestClient
{
    Task<T> InvokeAsync<T>(HttpMethod httpMethod, UriDynamicPart uriDynamicPart, HttpMethodContext context, CancellationToken cancellationToken);
}

[PublicAPI]
public class RestClient : IRestClient
{
    private readonly HttpRequestHeadersConfiguration _defaultHttpRequestHeadersConfiguration;

    private readonly HttpClient _client;

    public RestClient(string baseUri, HttpRequestHeadersConfiguration defaultHttpRequestHeadersConfiguration)
    {
        _client = new HttpClient
        {
            BaseAddress = new Uri(baseUri)
        };
        _client.DefaultRequestHeaders.Accept.Clear();
        _defaultHttpRequestHeadersConfiguration = defaultHttpRequestHeadersConfiguration;
    }

    public async Task<T> InvokeAsync<T>(HttpMethod httpMethod, UriDynamicPart uriDynamicPart, HttpMethodContext context, CancellationToken cancellationToken)
    {
        var response = await SendRequestAsync(httpMethod, uriDynamicPart, context, cancellationToken);

        var hasContent = response.Content.Headers.ContentLength > 0 && !(typeof(T) == typeof(object));
        if (hasContent)
        {
            return await response.Content
                .ReadAsAsync<T>(new[] { context.ResponseFormatter }, cancellationToken)
                .ContinueWith(t =>
                {
                    response.Dispose();
                    return t.Result;
                }, cancellationToken)
                .ConfigureAwait(false);
        }
        else
        {
            return await Task
                .FromResult(default(T))
                .ContinueWith(t =>
                {
                    response.Dispose();
                    return default(T);
                }, cancellationToken);
        }
    }

    private async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, UriDynamicPart uriDynamicPart, HttpMethodContext context, CancellationToken cancellationToken)
    {
        using (var request = new HttpRequestMessage(method, CreateAbsoluteUri(uriDynamicPart)))
        {
            if (!(context.Body is null))
            {
                request.Content = new ObjectContent(context.Body.GetType(), context.Body, context.RequestFormatter);
            }

            _defaultHttpRequestHeadersConfiguration.Apply(request.Headers);
            context.HttpRequestHeadersConfiguration.Apply(request.Headers);

            var response = await _client.SendAsync(request, cancellationToken);
            if (context.EnsureSuccessStatusCode)
            {
                response.EnsureSuccessStatusCode();
            }

            return response;
        }
    }

    private Uri CreateAbsoluteUri(string uriDynamicPart)
    {
        return new Uri(_client.BaseAddress, uriDynamicPart);
    }
}

I moved the http-methods into extensions:

public static class RestClientExtensions
{
    public static Task<T> GetAsync<T>(this IRestClient client, [NotNull] UriDynamicPart uriDynamicPart, [CanBeNull] HttpMethodContext context, CancellationToken cancellationToken)
    {
        return client.InvokeAsync<T>(HttpMethod.Get, uriDynamicPart, context, cancellationToken);
    }

    public static Task<T> PutAsync<T>(this IRestClient client, [NotNull] UriDynamicPart uriDynamicPart, [CanBeNull] HttpMethodContext context, CancellationToken cancellationToken)
    {
        return client.InvokeAsync<T>(HttpMethod.Put, uriDynamicPart, context, cancellationToken);
    }

    public static Task<T> PostAsync<T>(this IRestClient client, [NotNull] UriDynamicPart uriDynamicPart, [CanBeNull] HttpMethodContext context, CancellationToken cancellationToken)
    {
        return client.InvokeAsync<T>(HttpMethod.Post, uriDynamicPart, context, cancellationToken);
    }

    public static Task<T> DeleteAsync<T>(this IRestClient client, [NotNull] UriDynamicPart uriDynamicPart, [CanBeNull] HttpMethodContext context, CancellationToken cancellationToken)
    {
        return client.InvokeAsync<T>(HttpMethod.Delete, uriDynamicPart, context, cancellationToken);
    }
}

HTTP-Method context

Previously, I had five of these classes (after adding support for other http-methods). They were all so similar that it made more sense to use only a single class with all possible parameters.

[PublicAPI]
public class HttpMethodContext
{
    [NotNull]
    public UriDynamicPart UriDynamicPart { get; set; } = new UriDynamicPart();

    [NotNull]
    public HttpRequestHeadersConfiguration HttpRequestHeadersConfiguration { get; set; } = new HttpRequestHeadersConfiguration();

    [CanBeNull]
    public object Body { get; set; }

    [NotNull]
    public MediaTypeFormatter RequestFormatter { get; set; } = new JsonMediaTypeFormatter();

    [NotNull]
    public MediaTypeFormatter ResponseFormatter { get; set; } = new JsonMediaTypeFormatter();

    public bool EnsureSuccessStatusCode { get; set; } = true;
}

Fluent API extensions

Several extensions provide the necessary framework for building concrete requests and calling actions.

The first extension creates an IResourceContext for a specific resource - it stores all the necessary data to chain the second and final extension for executing the http-methods.

public static class ResourceContextFactory
{
    public static IResourceContext<TResource> ToResource<TResource>(this IRestClient client, string name = null)
    {
        // We get the resource name either from the attribute or the name of the interface without the "I" prefix.
        var resourceName =
            name 
            ?? typeof(TResource)
                .GetCustomAttribute<ResourceNameAttribute>()
                ?.ToString()
            ?? Regex.Replace(typeof(TResource).Name, "^I", string.Empty);

        return (IResourceContext<TResource>)Activator.CreateInstance(typeof(ResourceContext<TResource>), new object[] { client, resourceName });
    }
}

// ReSharper disable once UnusedTypeParameter - The generic argument is used for building strong extensions.
public interface IResourceContext<TResource>
{
    IRestClient Client { get; }

    UriDynamicPart UriDynamicPart { get; }

    HttpMethodContext MethodContext { get; }

    Task<TResult> GetAsync<TResult>(CancellationToken cancellationToken);

    Task<TResult> PutAsync<TResult>(CancellationToken cancellationToken);

    Task<TResult> PostAsync<TResult>(CancellationToken cancellationToken);

    Task<TResult> DeleteAsync<TResult>(CancellationToken cancellationToken);
}

[PublicAPI]
public class ResourceContext<TResource> : IResourceContext<TResource>
{
    public ResourceContext([NotNull] IRestClient client, params string[] path)
    {
        Client = client ?? throw new ArgumentNullException(nameof(client));
        UriDynamicPart = new UriDynamicPart(path);
    }

    [NotNull]
    public IRestClient Client { get; }

    public UriDynamicPart UriDynamicPart { get; }

    public HttpMethodContext MethodContext { get; } = new HttpMethodContext();

    public Task<TResult> GetAsync<TResult>(CancellationToken cancellationToken)
    {
        return Client.GetAsync<TResult>(UriDynamicPart, MethodContext, cancellationToken);
    }

    public Task<TResult> PutAsync<TResult>(CancellationToken cancellationToken)
    {
        return Client.PutAsync<TResult>(UriDynamicPart, MethodContext, cancellationToken);
    }

    public Task<TResult> PostAsync<TResult>(CancellationToken cancellationToken)
    {
        return Client.PostAsync<TResult>(UriDynamicPart, MethodContext, cancellationToken);
    }

    public Task<TResult> DeleteAsync<TResult>(CancellationToken cancellationToken)
    {
        return Client.DeleteAsync<TResult>(UriDynamicPart, MethodContext, cancellationToken);
    }
}

HTTP-Request-Headers configuration

This new and immutable class allows me to chain http-request-headers configuration for the RestClient.

public class HttpRequestHeadersConfiguration
{
    private readonly Action<HttpRequestHeaders> _action;

    public HttpRequestHeadersConfiguration(Action<HttpRequestHeaders> action)
    {
        _action = action;
    }

    public HttpRequestHeadersConfiguration() : this(_ => { }) { }

    public void Apply(HttpRequestHeaders headers)
    {
        _action(headers);
    }

    public static implicit operator HttpRequestHeadersConfiguration(Action<HttpRequestHeaders> action)
    {
        return new HttpRequestHeadersConfiguration(action);
    }

    public static HttpRequestHeadersConfiguration operator +(HttpRequestHeadersConfiguration configuration, Action<HttpRequestHeaders> action)
    {
        return configuration._action.Append(action);
    }
}

Exmaples

I implement resource APIs by creating a marker interface that stands for a resource I want to access - they have the same name as the controller (without the I). This creates a strong context that I can now use to write extensions for invoking actions on the web-service.

(I was also thinking of generating them with a T4 script later.)

public interface ITransactions { }

public static class TransactionsClient
{
    public static IResourceContext<ITransactions> Transactions(this IRestClient client)
    {
        return client.ToResource<ITransactions>();
    }
}

public static class TransactionClientExtensions
{
    public static Task<Transaction> NewTransactionAsync(this IResourceContext<ITransactions> resource)
    {
        return resource.GetAsync<Transaction>(CancellationToken.None);
    }
}

In my application I first create a default header customization and use it to initialize the client:

var configureDefaultRequestHeaders =
    new HttpRequestHeadersConfiguration()
        .Clear()
        .AcceptJson()
        .AddRange(new Dictionary<string, IEnumerable<string>>
        {
            ["X-Test"] = new[] { "development" }
        });

var client = new RestClient("http://localhost:54245/api/", configureDefaultRequestHeaders);

then I call the extensions to acces a resource:

var t = await client.Transactions().NewTransactionAsync();

Should I want to customize the request for a specific call, I would do it just after calling .Transactions(). Here I can chain extensions for adding other headers etc.


What do you think of this client? Is it intuitive and easy to use? Is it easy to extend?

I deliberately did not any null-checks (yet).

\$\endgroup\$
  • 1
    \$\begingroup\$ Is there a reason you are using Activator.CreateInstance(typeof(ResourceContext<TResource>), new object[] { client, resourceName }) instead of new ResourceContext<TResource>(...)? I've been staring at it for a few minutes and it appears that the constructor should be directly accessible and then no need for a cast. I just woke up and am groggy so I might be missing something here, otherwise I'd point this out as part of an answer. Also what prevents calling of ToResource with a non-Attributed non-interface type (or a class that legitimately begins with the letter I)? \$\endgroup\$ – pinkfloydx33 Feb 17 '18 at 15:19
  • \$\begingroup\$ @pinkfloydx33 You're right, I can use the constructor directly. Thanks for pointing this out. There was a reason before but there is none anymore and apparently I didn't notice it. I used a new on some T so I wasn't able to just new it with parameters. Now that I have only one type the Activator is no longer necessary. I'm not sure if I understand you question about ToResource. You mean it's not guarded and can produce invalid results? I haven't given too much thought to it yet and I'm hoping that I'm not going to break by sticking to my own convention ;-) \$\endgroup\$ – t3chb0t Feb 17 '18 at 15:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.