7
\$\begingroup\$

I've been working on a REST-client. The client itself provides only very basic and generic functionality. Everything else is done with extensions. Its raw usage does not need to be convenient but extendable. It should allow to modify all important parameters for each request (like request headers) and protect others (like http method type). Convenience comes through extensions.


There is a follow-up


RestClient

I currently test it with just two methods: GET & POST. Each of them accepts an uri-builder and its own HttpMethodConfiguration. They are basically wrappers for the boilerplate request code. Before each request, first the default headers are set and then the each caller can further customize them.

[PublicAPI]
public interface IRestClient
{
    Task<T> GetAsync<T>([NotNull] UriDynamicPart uriDynamicPart, [CanBeNull] GetMethodConfiguration methodConfiguration);

    Task<T> PostAsync<T>([NotNull] UriDynamicPart uriDynamicPart, [NotNull] object body, [CanBeNull] PostMethodConfiguration methodConfiguration);
}

[PublicAPI]
public class RestClient : IRestClient
{
    private readonly Action<HttpRequestHeaders> _configureDefaultRequestHeaders;

    private readonly HttpClient _client;

    public RestClient(string baseUri, Action<HttpRequestHeaders> configureDefaultRequestHeaders)
    {
        _client = new HttpClient
        {
            BaseAddress = new Uri(baseUri)
        };
        _client.DefaultRequestHeaders.Accept.Clear();
        _configureDefaultRequestHeaders = configureDefaultRequestHeaders;
    }

    public async Task<T> GetAsync<T>(UriDynamicPart uriDynamicPart, GetMethodConfiguration methodConfiguration)
    {
        methodConfiguration = methodConfiguration ?? new GetMethodConfiguration();

        var request = new HttpRequestMessage(HttpMethod.Get, CreateAbsoluteUri(uriDynamicPart));
        _configureDefaultRequestHeaders(request.Headers);
        methodConfiguration.ConfigureRequestHeaders(request.Headers);

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

        return await response.Content.ReadAsAsync<T>(new[] { methodConfiguration.ResponseFormatter }, methodConfiguration.CancellationToken);
    }

    public async Task<T> PostAsync<T>(UriDynamicPart uriDynamicPart, object body, PostMethodConfiguration methodConfiguration)
    {
        methodConfiguration = methodConfiguration ?? new PostMethodConfiguration();

        var request = new HttpRequestMessage(HttpMethod.Post, CreateAbsoluteUri(uriDynamicPart))
        {
            Content = new ObjectContent(body.GetType(), body, methodConfiguration.RequestFormatter)
        };
        _configureDefaultRequestHeaders(request.Headers);
        methodConfiguration.ConfigureRequestHeaders(request.Headers);

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

        if (response.Content.Headers.ContentLength > 0)
        {
            return await response.Content.ReadAsAsync<T>(new[] { methodConfiguration.ResponseFormatter }, methodConfiguration.CancellationToken);
        }

        return await Task.FromResult(default(T));
    }        

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

HTTP-Method configuration

The http-method-configuration classes are very simple and contain just a couple of properties:

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

    [NotNull]
    public Action<HttpRequestHeaders> ConfigureRequestHeaders { get; set; } = headers => { };

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

    public CancellationToken CancellationToken { get; set; } = CancellationToken.None;
}    

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

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

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

public static class GetMethodConfigurationExtensions
{
    public static GetMethodConfiguration SetHeader(this GetMethodConfiguration methodConfiguration, string header, params string[] values)
    {
        methodConfiguration.ConfigureRequestHeaders = methodConfiguration.ConfigureRequestHeaders.Append(headers =>
        {
            headers.Remove(header);
            headers.Add(header, values);
        });
        return methodConfiguration;
    }
}

Convenience extensions

There are three layers of helper extensions that put together make up an intuitive fluent API - I hope. They work like that:

IRestClient
    .ResourceFor -> IRestResource
    .Get/Post([customization]) -> IRestMethod
    ."Action" -> actual result

They are not intended to be used on their own - too much generics - but are bricks for the final API. customization is for ad-hoc changes in single requests.

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

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

// The generic argument is used for building strong extensions for particular resource.
public interface IRestResource<TResource>
{
    IRestClient Client { get; }

    UriDynamicPart UriDynamicPart { get; }
}

[PublicAPI]
public class RestResource<TResource> : IRestResource<TResource>
{
    public RestResource([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 interface IRestMethod<out TRestMethod, TResource>
    where TRestMethod : HttpMethodConfiguration
{
    IRestResource<TResource> Resource { get; }

    TRestMethod Configuration { get; }

    Task<TResult> InvokeAsync<TResult>(object body);
}

public abstract class RestMethod<TRestMethod, TResource> : IRestMethod<TRestMethod, TResource>
    where TRestMethod : HttpMethodConfiguration
{
    protected RestMethod(IRestResource<TResource> resource, TRestMethod configuration)
    {
        Resource = resource;
        Configuration = configuration;
    }

    public IRestResource<TResource> Resource { get; }

    public TRestMethod Configuration { get; }

    public abstract Task<TResult> InvokeAsync<TResult>(object body);
}

public class GetMethod<TResource> : RestMethod<GetMethodConfiguration, TResource>
{
    public GetMethod(IRestResource<TResource> resource, GetMethodConfiguration configuration)
        : base(resource, configuration)
    { }

    public override Task<TResult> InvokeAsync<TResult>(object body)
    {
        return Resource.Client.GetAsync<TResult>(Resource.UriDynamicPart, Configuration);
    }
}

public class PostMethod<TResource> : RestMethod<PostMethodConfiguration, TResource>
{
    public PostMethod(IRestResource<TResource> resource, PostMethodConfiguration configuration)
        : base(resource, configuration)
    { }

    public override Task<TResult> InvokeAsync<TResult>(object body)
    {
        return Resource.Client.PostAsync<TResult>(Resource.UriDynamicPart, body, Configuration);
    }
}

public static class RestResourceExtensions
{
    public static GetMethod<TResource> Get<TResource>(this IRestResource<TResource> resource, Func<GetMethodConfiguration, GetMethodConfiguration> configure = null)
    {
        configure = configure ?? (_ => _);
        return new GetMethod<TResource>(resource, configure(new GetMethodConfiguration()));
    }
}

Examples

The code that needs to be written for each request uses a marker interface, here the ITransactions. Without it, it wouldn't be possible to write extensions only for a particular resource so it's passed through the entire chain.

public interface ITransactions { }

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

public static class RequestBuilderExtensions
{
    public static Task<Transaction> NewTransactionAsync(this GetMethod<ITransactions> getMethod)
    {
        return getMethod.InvokeAsync<Transaction>();
    }
}

The actual usage of this framework looks like that:

  • write extensions for the resources
  • setup the client
  • call the extensions
var configureDefaultRequestHeaders =
    (Action<HttpRequestHeaders>)(headers => headers
        .AcceptJson()
        .AddRange(new Dictionary<string, IEnumerable<string>>
        {
            ["X-CustomHeader"] = new[] { "development" },
        })
    );

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

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

It calls a resource at http://localhost:54245/api/transactions via GET.

This example does not override anything but should I need it or add some query-string parameters, I can do it inside any extension for the rest-method just by changing the uri-builder of the configuration:

public static Task<Transaction> NewTransactionAsync(this GetMethod<ITransactions> getMethod, bool encoded)
{
    getMethod
        .Configuration
        .RelativeUriBuilder
        .QueryString
        .Add("Encoded", encoded.ToString());
    return getMethod.InvokeAsync<Transaction>();
}

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\$
4
\$\begingroup\$

I'm working on something of a simple REST client myself and I wonder if my core bit might be able to help your implementation and vice-versa. I'll just post a tiny bit here (the generic GET async method and its internal implementation) (also null checks removed for space)

One of the more interesting things I found in my research is that both HttpRequestMessage and HttpResponseMessage both implement IDisposable. Yes, so does HttpClient, but it's an exception to the rule. HttpContent does as well, but disposing of HttpResponseMessage also disposes of the content. While it was easy in a non-async way to wrap the whole thing in a using, being fully async required the response message to stay alive while the contentFunc was processing. Anyhow, this may or may not be applicable to your scenario, but I'm mentioning it due to your comment and lack of disposal.

    private const string JsonContentType = "application/json";
    private static readonly HttpClient _HttpClient = new HttpClient();

    public static Task<T> GetAsync<T>(this Uri uri)
    {
        return uri.GetDeleteAsync(
            HttpMethod.Get,
            async content => JsonConvert.DeserializeObject<T>(
                await content.ReadAsStringAsync().ConfigureAwait(false)));
    }

    private static async Task<T> GetDeleteAsync<T>(
        this Uri uri,
        HttpMethod httpMethod,
        Func<HttpContent, Task<T>> contentFunc,
        string contentType = JsonContentType,
        bool disposeResponse = true)
    {
        HttpResponseMessage httpResponseMessage;

        using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage(httpMethod, uri))
        {
            httpRequestMessage.Headers.Accept.Clear();
            httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(contentType));
            httpResponseMessage = await _HttpClient.SendAsync(httpRequestMessage).ConfigureAwait(false);
        }

        httpResponseMessage.EnsureSuccessStatusCode();
        if (contentFunc != null)
        {
            return await contentFunc(httpResponseMessage.Content).ContinueWith(previousTask =>
            {
                if (disposeResponse)
                {
                    httpResponseMessage.Dispose();
                }

                return previousTask.Result;
            }).ConfigureAwait(false);
        }

        httpResponseMessage.Dispose();
        return default(T);
    }
|improve this answer|||||
\$\endgroup\$
  • \$\begingroup\$ I'll take a careful look at it. Every inspiration is welcome ;-) \$\endgroup\$ – t3chb0t Feb 16 '18 at 21:38
  • 2
    \$\begingroup\$ Wow, crap, I didn't see that the HttpRequestMessage actually is an IDisposable ;-o \$\endgroup\$ – t3chb0t Feb 16 '18 at 21:40
  • 2
    \$\begingroup\$ I KNOW. I found that weirdness out when I was looking for HttpClient best practices (it's an IDisposable, but it should be used as a singleton). HttpResponseMessage is too, but don't dispose it if you're working with a stream (ReadAsStreamAsync) otherwise the stream will go away. \$\endgroup\$ – Jesse C. Slicer Feb 16 '18 at 21:43
  • \$\begingroup\$ If you wrote a few words about me not disposing the http-messages I could accept your answer because this change resulted in a lot of refactoring so I'd like to post a follow-up soon. \$\endgroup\$ – t3chb0t Feb 16 '18 at 23:03
2
\$\begingroup\$

I asked a very similar question here

The thing that came out of it that I think you should think about is that there's no need to add Get / Post etc. to the interface. You can handle all that with extension methods. The answer to my question helped me with that. Here's the interface that I ended up with. Much simpler.

Code Reference

/// <summary>
/// Dependency Injection abstraction for rest clients. Use the IClientFactory abstraction when more than one client is needed for an application.
/// </summary>
public interface IClient
{
    /// <summary>
    /// Adapter for serialization/deserialization of http body data
    /// </summary>
    ISerializationAdapter SerializationAdapter { get; }

    /// <summary>
    /// Sends a strongly typed request to the server and waits for a strongly typed response
    /// </summary>
    /// <typeparam name="TResponseBody">The expected type of the response body</typeparam>
    /// <typeparam name="TRequestBody">The type of the request body if specified</typeparam>
    /// <param name="request">The request that will be translated to a http request</param>
    /// <returns></returns>
    Task<Response<TResponseBody>> SendAsync<TResponseBody, TRequestBody>(Request<TRequestBody> request);

    /// <summary>
    /// Default headers to be sent with http requests
    /// </summary>
    IHeadersCollection DefaultRequestHeaders { get; }

    /// <summary>
    /// Default timeout for http requests
    /// </summary>
    TimeSpan Timeout { get; set; }

    /// <summary>
    /// Base Uri for the client. Any resources specified on requests will be relative to this.
    /// </summary>
    Uri BaseUri { get; set; }

    /// <summary>
    /// Name of the client
    /// </summary>
    string Name { get; }
}

Here are the extension methods that add PostAsync, GetAsync and so on

public static class CallExtensions
{
    public static Task<Response<TResponseBody>> SendAsync<TResponseBody, TRequestBody>(this IClient client, Request<TRequestBody> request)
    {
        if (client == null) throw new ArgumentNullException(nameof(client));
        return client.SendAsync<TResponseBody, TRequestBody>(request);
    }

    #region Get
    public static Task<Response<TResponseBody>> GetAsync<TResponseBody>(this IClient client)
    {
        return GetAsync<TResponseBody>(client, default(Uri));
    }

    public static Task<Response<TResponseBody>> GetAsync<TResponseBody>(this IClient client, string resource)
    {
        try
        {
            return GetAsync<TResponseBody>(client, resource != null ? new Uri(resource, UriKind.Relative) : null);
        }
        catch (UriFormatException ufe)
        {
            if (ufe.Message == "A relative URI cannot be created because the 'uriString' parameter represents an absolute URI.")
            {
                throw new UriFormatException(Messages.ErrorMessageAbsoluteUriAsString, ufe);
            }

            throw;
        }
    }

    public static Task<Response<TResponseBody>> GetAsync<TResponseBody>(this IClient client, Uri resource = null, IHeadersCollection requestHeaders = null, CancellationToken cancellationToken = default)
    {
        return SendAsync<TResponseBody, object>(client,
            new Request<object>(
                resource,
                default,
                requestHeaders,
                HttpRequestMethod.Get,
                client,
                cancellationToken));
    }
    #endregion

    #region Delete
    public static Task<Response> DeleteAsync(this IClient client, string resource)
    {
        return DeleteAsync(client, resource != null ? new Uri(resource, UriKind.Relative) : null);
    }

    public static async Task<Response> DeleteAsync(this IClient client, Uri resource = null, IHeadersCollection requestHeaders = null, CancellationToken cancellationToken = default)
    {
        var response = (Response)await SendAsync<object, object>(client,
        new Request<object>(
              resource,
            default,
            requestHeaders,
            HttpRequestMethod.Delete,
            client,
            cancellationToken));

        return response;
    }
    #endregion

    #region Put
    public static Task<Response<TResponseBody>> PutAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody)
    {
        return PutAsync<TResponseBody, TRequestBody>(client, requestBody, default);
    }

    public static async Task<Response<TResponseBody>> PutAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody, string resource)
    {
        return await PutAsync<TResponseBody, TRequestBody>(client, requestBody, resource != null ? new Uri(resource, UriKind.Relative) : null);
    }

    public static Task<Response<TResponseBody>> PutAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody = default, Uri resource = null, IHeadersCollection requestHeaders = null, CancellationToken cancellationToken = default)
    {
        return SendAsync<TResponseBody, TRequestBody>(client,
            new Request<TRequestBody>(
                resource,
                requestBody,
                headers: requestHeaders,
                HttpRequestMethod.Put,
                client,
                cancellationToken));
    }
    #endregion

    #region Post
    public static Task<Response<TResponseBody>> PostAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody)
    {
        return PostAsync<TResponseBody, TRequestBody>(client, requestBody, default);
    }

    public static Task<Response<TResponseBody>> PostAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody, string resource)
    {
        return PostAsync<TResponseBody, TRequestBody>(client, requestBody, resource != null ? new Uri(resource, UriKind.Relative) : default);
    }

    public static Task<Response<TResponseBody>> PostAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody, Uri resource, IHeadersCollection requestHeaders = null, CancellationToken cancellationToken = default)
    {
        return SendAsync<TResponseBody, TRequestBody>(client,
            new Request<TRequestBody>(
                resource,
                requestBody,
                requestHeaders,
                HttpRequestMethod.Post,
                client,
                cancellationToken));
    }
    #endregion

    #region Patch
    public static Task<Response<TResponseBody>> PatchAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody)
    {
        return PatchAsync<TResponseBody, TRequestBody>(client, requestBody, default);
    }

    public static Task<Response<TResponseBody>> PatchAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody, string resource)
    {
        return PatchAsync<TResponseBody, TRequestBody>(client, requestBody, resource != null ? new Uri(resource, UriKind.Relative) : default);
    }

    public static Task<Response<TResponseBody>> PatchAsync<TResponseBody, TRequestBody>(this IClient client, TRequestBody requestBody, Uri resource, IHeadersCollection requestHeaders = null, CancellationToken cancellationToken = default)
    {
        return SendAsync<TResponseBody, TRequestBody>(client,
            new Request<TRequestBody>(
                resource,
                requestBody,
                requestHeaders,
                HttpRequestMethod.Patch,
                client,
                cancellationToken));
    }
    #endregion
}
|improve this answer|||||
\$\endgroup\$

Your Answer

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

Not the answer you're looking for?Browse other questions tagged or ask your own question.