Service Pattern for accessing an external Api

Home / Service Pattern for accessing an external Api

A friend of mine asked me earlier today what a good pattern for accessing an Api from within a class library would look like. In .NET, I generally like to wrap this type of functionality within a service that can be injected.


The nice thing about .NET is that it has many framework methods for performing HTTP and TCP/IP network requests. Prior to .NET 3.x, I recall that the the preferred method was using the WebRequest class. However, that class is obsolete. Enter HttpClient.

HttpClient is similar to WebRequest, but it provides more granular control and sending/receiving asynchronously. In my wrapper service, I utilize HttpClient, but provide type specifications for things like deserilization and parameterization for global/common parameters.

Diving right into code, my service looks like this:

public interface IApiService
{
	T GetFromApi<T>(string url, Dictionary<string, string> kvp = null, bool useDefaultCredentials = false);
	V UpdateToApi<T, V>(string url, T obj, HttpMethod verb = null, bool isFormPost = false, bool useDefaultCredentials = false)
}

The first method is intended to be used for any GETs from an Api. It takes a URL and an optional Dictionary<string, string> of key/value pairs. Those key/value pairs with be appended to the URL as query parameters. You’ll also notice the useDefaultCredentials parameter. I use this within the implementation to be able to use different credentials.

My expectation of the GetFromApi call is that it will request JSON and then deserialize the response to whatever class type I have specified – including dynamic types. The implementation for that looks like this:

public class ApiService : IApiService
{
    private ILog _log;
    private TimeSpan _timeout;
    private string _headerName;
    private string _apiUrl = string.Empty;
    private string _apiToken = string.Empty;
    private string _apiUsername = string.Empty;
    private string _jsonMediaType = "application/json";
    private string _formMediaType = "application/x-www-form-urlencoded";

    public ApiService(ILog log, string apiUrl, string apiToken, string apiUsername, int timeout = 20000)
    {
        _log = log;
        _timeout = TimeSpan.FromMilliseconds(timeout);
        _apiUrl = apiUrl;
        _apiToken = apiToken;
        _apiUsername = apiUsername;
        _headerName = "X-Some-Auth-Header";
    }

    /// <summary>
    /// Get JSON data from an API endpoint
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="url"></param>
    /// <param name="kvp"></param>
    /// <returns></returns>
    private T GetFromApi<T>(string url, Dictionary<string, string> kvp = null, bool useDefaultCredentials = false)
    {
        T retValue;
        var json = string.Empty;
        Stopwatch sw = new Stopwatch();
        sw.Start();

        var requestUri = kvp == null ?
            new Uri(url) :
            new Uri(string.Format("{0}?{1}",
                url,
                string.Join("&",
                    kvp.Keys
                    .Where(key => !string.IsNullOrWhiteSpace(kvp[key]))
                    .Select(key => string.Format("{0}={1}", HttpUtility.UrlEncode(key), HttpUtility.UrlEncode(kvp[key]))))
                )
            );
        try
        {
            using (var client = new HttpClient() { Timeout = _timeout })
            {
                var request = new HttpRequestMessage()
                {
                    RequestUri = requestUri,
                    Method = HttpMethod.Get
                };

                AddAuthHeader(request, useDefaultCredentials);
                HttpResponseMessage response = client.SendAsync(request).Result;

                if (!response.IsSuccessStatusCode)
                {
                    _log.Debug(string.Format("API Get Request Bad Status: {0}, Status Code {1}, UserName: {2},  CommTime: {3}",
                        requestUri.ToString(),
                        response.StatusCode,
                        _apiUsername,
                        sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff"))
                    );

                    // Assume a failure here means a dynamic was requested
                    if (response.StatusCode == HttpStatusCode.Unauthorized)
                    {
                        throw new HttpException((int)response.StatusCode, response.StatusCode.ToString());
                    }
                    else
                    {
                        return default(T);
                    }
                }

                Task<Stream> streamTask = response.Content.ReadAsStreamAsync();
                Stream stream = streamTask.Result;
                var sr = new StreamReader(stream);
                json = sr.ReadToEnd();
                var type = typeof(T);
                if (type.IsPrimitive || type == typeof(Decimal) || type == typeof(string))
                {
                    json = HttpUtility.UrlDecode(json);
                    retValue = (T)Convert.ChangeType(json, typeof(T));
                }
                else
                {
                    if (type == typeof(ExpandoObject))
                    {
                        var converter = new ExpandoObjectConverter();
                        var obj = JsonConvert.DeserializeObject<T>(json, converter);
                        return obj;
                    }
                    retValue = JsonConvert.DeserializeObject<T>(json);
                }
            }

            _log.Debug(string.Format("API Get Request Success: {0}, UserName: {1}, Response: {2}, CommTime: {3}",
                requestUri.ToString(),
                _apiUsername,
                json,
                sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff"))
            );
        }
        catch (AggregateException ex)
        {
            if (ex.InnerException is TaskCanceledException)
            {
                _log.Debug(string.Format("API Get Request failed: {0}, UserName: {1},  CommTime: {2}",
                    requestUri.ToString(), _apiUsername, sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff")), ex.InnerException);
                throw ex.InnerException;
            }
            else
            {
                _log.Debug(string.Format("API Get Request failed: {0}, UserName: {1}, CommTime: {2}",
                    requestUri.ToString(), _apiUsername, sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff")), ex);
                throw ex;
            }
        }
        catch (Exception ex)
        {
            throw ex;
        }

        return retValue;
    }
}

There are a few points of note in the above code. Newtonsoft’s JSON.NET is used for deserialization. I use Log4Net’s ILog to log any failures.

The basic flow of using HttpClient for a GET response is to instantiate the client specifying timeout, instantiate an HttpRequestMessage with the URL/data, attach any headers like authorization headers to the request message, and, finally, send the HttpRequestMessage with HttpClient’s SendAsync method.

After the SendAsync completes, the response can be read from the response using ReadAsStreamAsync. You can see this is basically converted to a string, assuming it is JSON, and then deserialized using the JSON.Convert methods. You can see from the code that primitives, complex types, and dynamics (treated as ExpandoObject) are handled.

One import distinction about the exception handling is that HttpClient with throw an AggregateException and the inner exception will be a TaskCanceledException is a timeout occurs. A typical call to this method would look like this:

var url = string.Format("{0}/{1}", _apiUrl, "products/search/");
var values = new Dictionary<string, string>() {
    { "q", query },
    { "per_page", pageSize.ToString()},
    { "page", pageNum.ToString() }
};

var results = apiService.GetFromApi<ApiSearchResults>(url, values);

The next basic call handles PUT/POST scenarios by allowing the user to specify the HttpVerb. The type specification also takes the input class type and output class type. I also have a flag to toggle between treating the PUT/POST as sending Form or JSON data. This allows for passing in an object that you’d like placed within the body of the request as the appropriate data type.

/// <summary>
/// PUT or POST to an Api endpoint and return a JSON response
/// </summary>
/// <typeparam name="T">Type of object being posted</typeparam>
/// <typeparam name="V">Type of object to deserialize response into</typeparam>
/// <param name="url"></param>
/// <param name="obj">Data to send to Api</param>
/// <param name="verb">HttpVerb to use</param>
/// <param name="isFormPost">Boolean to indicate whether to send data as JSON or FORM post</param>
/// <returns></returns>
private V UpdateToApi<T, V>(string url, T obj, HttpMethod verb = null, bool isFormPost = false, bool useDefaultCredentials = false)
{
    Stopwatch sw = new Stopwatch();
    sw.Start();

    var requestContent = string.Empty;

    try
    {
        using (var client = new HttpClient(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }) { Timeout = _timeout })
        {
            var json = JsonConvert.SerializeObject(obj);

            var request = new HttpRequestMessage()
            {
                RequestUri = new Uri(url),
                Method = verb == HttpMethod.Put ? HttpMethod.Put : HttpMethod.Post
            };

            if (isFormPost)
            {
                var jObj = (JObject)JsonConvert.DeserializeObject(json);
                var queryParams = String.Join("&",
                                jObj.Children().Cast<JProperty>()
                                .Select(jp => jp.Name + "=" + HttpUtility.UrlEncode(jp.Value.ToString())));
                request.Content = new StringContent(queryParams, Encoding.ASCII, _formMediaType);

                // For logging
                requestContent = string.Format("Content: {0}, Type: {1}, Verb: {2}", queryParams, _formMediaType, verb.ToString());
            }
            else
            {
                request.Content = new StringContent(json, Encoding.UTF8, _jsonMediaType);

                // For logging
                requestContent = string.Format("Content: {0}, Type: {1}, Verb: {2}", json, _formMediaType, verb.ToString());
            }

            // Remove passwords from log
            requestContent = requestContent.Replace("myPassword", "********");

            request.Headers.Clear(); // Remove default headers
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(_jsonMediaType));
            request.Content.Headers.ContentType = isFormPost ?
                new MediaTypeHeaderValue(_formMediaType) :
                new MediaTypeHeaderValue(_jsonMediaType);

            AddAuthHeader(request, useDefaultCredentials);
            HttpResponseMessage response = client.SendAsync(request).Result;
            V model = default(V);
            if (!response.IsSuccessStatusCode)
            {                       
                _log.Debug(string.Format("API Update Request Bad Status: {0}, Status Code {1}, Content: {2}, UserName: {3},  CommTime: {4}",
                    url,
                    response.StatusCode,
                    requestContent,
                    _apiUsername,
                    sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff"))
                );

                return model;
            }

            Task<Stream> streamTask = response.Content.ReadAsStreamAsync();
            Stream stream = streamTask.Result;
            var sr = new StreamReader(stream);
            json = sr.ReadToEnd();
            var type = typeof(V);
            if (type.IsPrimitive || type == typeof(Decimal) || type == typeof(string))
            {
                json = HttpUtility.UrlDecode(json);
                model = (V)Convert.ChangeType(json, typeof(V));
            }
            else
            {
                model = JsonConvert.DeserializeObject<V>(json);
            }

            _log.Debug(string.Format("API Update Request Success: {0}, UserName: {1}, PostContent: {2}, ReturnContent: {3},  CommTime: {4}",
                url,
                _apiUsername,
                requestContent,
                json,
                sw.Elapsed.ToString(@"hh\:mm\:ss\.ffff"))
            );

            return model;
        }
    }
    catch (AggregateException ex)
    {
        if (ex.InnerException is TaskCanceledException)
        {
            throw ex.InnerException;
        }
        else
        {
            throw ex;
        }
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

The UpdateToApi follows the same flow as the GetFromApi method. The differences are in how the header and body of the request are built and how the input object is serialized. The body can be form data or JSON data and is built using .NET’s StringContent. This necessitates attaching the appropriate media type to the request. Otherwise, the flow is quite similar.

You’ll notice a few references to an AddAuthHeader method. This is a simple method that attaches a header to each request. This could be a Bearer token or whatever header you use for authorization. I have an enableDefaultCredentials flag too. While it’s not really used in this example code, it could be used to toggle the way the authorization header is built based on the currently logged in user.

/// <summary>
/// Attach our header to the request for authorization
/// </summary>
/// <param name="request"></param>
/// <param name="useDefaultCredentials"></param>
/// <returns></returns>
private HttpRequestMessage AddAuthHeader(HttpRequestMessage request, bool useDefaultCredentials = false)
{
    var headerUsername = _apiUsername;
    var headerName = _headerName;
    var headerValue = string.Format("token={0};username={1}", _apiToken, headerUsername);
    request.Headers.Add(headerName, headerValue);
    return request;
}

Finally, since I mentioned that I generally use a service to make injection easy, this is how I typically inject the service with Ninject:

kernel.Bind<IApiService>().To<ApiService>()
    .WithConstructorArgument("apiUrl", AppSettings.ApiUrl)
    .WithConstructorArgument("apiToken", AppSettings.ApiToken)
    .WithConstructorArgument("apiUsername", AppSettings.ApiUsername)
    .WithConstructorArgument("timeout", AppSettings.ApiTimeout);

Leave a Reply