Archon HTTP Utilities
Extensions, middleware, and helper methods that make working with HttpClient and Asp.Net MVC easier as an API.
How to Use
Install via nuget; Archon.Http for HTTP clients,
Install-Package Archon.Http
or Archon.AspNetCore for ASP.NET Core MVC.
Install-Package Archon.AspNetCore
If you aren't using ReSharper, make sure to add using Archon.Http;
to the top of your files to get IntelliSense to detect the extension methods.
- Archon HTTP Utilities
- Client utilities (
Archon.Http
) - ASP.NET Core server utilities (
Archon.AspNetCore
)
Archon.Http
)
Client utilities (The Link Concept
The Link
interface should be used to create .net HTTP API clients. It is inspired by the book Designing Evolvable Web APIs with ASP.NET. It implements a link relation. Specifically:
In the web world, media types are used to convey what a resource represents, and a link relation suggests why you should care about that resource.
If you were building an API client for the Github API, rather than providing something like this:
var github = new GithubClient("my-api-key");
var repos = github.GetRepositories("username");
Link
allows you to provide an interface more akin to this:
var client = new HttpClient();
var repos = await client.SendAsync(new GetGithubRepositories("username"));
This uses the native HttpClient
to do what it is good at, sending HTTP requests while at the same time providing a nice porcelain wrapper around the HTTP particulars. However, if you want to be closer to the metal, Link
doesn't try to leakily abstract away the fact that you are making an HTTP request. You can also do something like this:
var client = new HttpClient();
var link = new GetGithubRepositories("username");
HttpResponseMessage response = await api.SendAsync(link.CreateRequest());
if (response.StatusCode == HttpStatusCode.Redirect)
{
//do something cool
}
var repos = await link.ParseResponseAsync(response);
Internally, the HttpClient.SendAsync(Link)
method is calling CreateRequest
and ParseResponseAsync
for your convenience.
A sample implementation of GetGithubRepositories
could look something like this:
public class GetGithubRepositories : Link<IEnumerable<Repo>>
{
public string Username { get; private set; }
public GetGithubRepositories(string username)
{
this.Username = username;
}
public HttpRequestMessage CreateRequest()
{
//the client being used to send this request should have a BaseAddress configured
//this allows us to use relative URLs when building our HttpRequestMessage
return new HttpRequestMessage(HttpMethod.Get, String.Format("repositories/{0}", Username));
}
public async Task<IEnumerable<Repo>> ParseResponseAsync(HttpResponseMessage response)
{
if (response == null)
throw new ArgumentNullException("response");
if (response.StatusCode == HttpStatusCode.NotFound)
return null; //return a null object if we get a not-found response
//see below for this method
await response.EnsureSuccess(); //throw an error on any other non-200 response
//parse the response body into our object we want to return
return await response.Content.ReadAsAsync<IEnumerable<Repo>>();
}
}
Read Chapter 9: Building the Client for free online for more information on the advantages of building client libraries like this.
A Better Ensure Success
The existing EnsureSuccessStatusCode
is pretty terrible when it comes to throwing a useful exception message. It does not provide any way to access the content of the response after the exception is thrown when usually, the response content has the most useful information as to why the request failed.
This new extension method, EnsureSuccess
, will return the response content along with the status code and request URL/method in the exception message making your logs much more useful when making use of HTTP APIs.
//client is an HttpClient and request is an HttpRequestMessage
HttpResponseMessage response = await client.SendAsync(request);
await response.EnsureSuccess();
//if the response failed, it will throw an exception that looks something like this:
//Received HTTP 409 (Conflict) while POSTing URL 'http://example.com/api/thing/'.
//{"Message":"These are not the droids you are looking for."}
Query String Builder
This class allows you to easily create query strings from multiple parameters using Set
, Append
, & Remove
:
var qs = new QueryStringBuilder();
qs.Set("hello", "world");
qs.Set("goodbye", "loneliness");
Console.WriteLine(qs.ToString()); // ?hello=world&goodbye=loneliness
qs = new QueryStringBuilder();
qs.Set("hello", "world");
qs.Set("hello", "universe");
Console.WriteLine(qs.ToString()); // ?hello=universe
qs = new QueryStringBuilder();
qs.Append("hello", "world");
qs.Append("hello", "universe");
Console.WriteLine(qs.ToString()); // ?hello=world&hello=universe
qs = new QueryStringBuilder("https://example.com/?hello=world&homer=simpson");
qs.Set("age", "42");
qs.Set("hello", "universe");
Console.WriteLine(qs.ToString()); // https://example.com?homer=simpson&age=42&hello=universe
qs = new QueryStringBuilder("https://example.com/?hello=world");
qs.Remove("hello");
Console.WriteLine(qs.ToString()); // https://example.com
You can also create a QueryStringBuilder
from an object template:
var qs = new QueryStringBuilder(new
{
name = "Homer",
kids = new string[] { "Bart", "Lisa", "Maggie" }
});
Console.WriteLine(qs.ToString()); // ?name=Homer&kids=Bart&kids=Lisa&kids=Maggie
Read JSON Response
This is an extension method off of HttpContent
that makes it easy to read a JSON response:
HttpResponseMessage response = client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://dogs.example.com/api"));
var dogs = await response.Content.ReadJsonAsync<IEnumerable<Dog>>();
Send Request with JSON Content
This is another extension method that makes it easy to send requests with JSON content:
var req = new HttpRequestMessage(HttpMethod.Post, "https://dogs.example.com/api")
.WithJsonContent(new { name = "Fido", Age = 2 });
var response = await client.SendAsync(req);
Authorization
The Authorization
class abstracts away parsing an authorization HTTP header. It supports Bearer
and Basic
authorization schemes.
var auth = Authorization.Basic("username", "password");
request.Headers.Authorization = auth.AsHeader();
//base64 encodes the username:password and creates a new AuthenticationHeaderValue
var auth = Authorization.Bearer("my-opaque-auth-token");
request.Headers.Authorization = auth.AsHeader();
//creates a new AuthenticationHeaderValue with the token value
GZip Request Compression (client)
If you need to make API calls to pass big-ish chunks of data around all at once, compressing the request payload with GZip can improve performance considerably.
On the client side, you'll need to add an instance of GZipCompressingHandler
as a delegating handler to the main HttpClientHandler
. This will compress the request content as well as adding a Content-Encoding: gzip
header to signify to the server that the request body is compressed.
MailhouseApi api = MailhouseApi.Build("http://localhost:50128/",
() => new GZipCompressingHandler(
new BasicAuthenticationHandler(new HttpClientHandler(), Username, Password), // Modifies headers only
HttpMethod.Post, // POST and PUT typically contain payloads worth compressing,
HttpMethod.Put // while other verbs do not.
)
);
Note:
If you are sending gzipped requests to an Azure App Service, then you won't be able to configure its IIS gateway to dynamically decompress the request, and you'll need to add aGZipResourceFilter
as described below.
Caution:
TheGZipCompressingHandler
must be the last handler to modify the request content, because downstream handlers will only see compressed content. Downstream handlers can still modify headers, however.
Archon.AspNetCore
)
ASP.NET Core server utilities (accept
Parameter in URL to HTTP Accept
Header
Rewrite Adding the AcceptHeaderMiddleware
will rewrite a query string accept
parameter to a proper HTTP Accept
header.
<a href="/api/resource/which/normally/returns/json/but/honors/accept/headers?accept=text/csv">Download as CSV</a>
To use, add app.UseAcceptHeaderRewriter()
to your Startup.Configure
:
// ...register anything else that might affect the request...
app.UseAcceptHeaderRewriter();
app.UseMvc();
auth
Parameter in URL to HTTP Authorization
Header
Rewrite Adding the AuthHeaderMiddleware
will rewrite a query string auth
parameter to a proper HTTP Authorization
header.
<a href="/api/resource/which/requires/sso/auth/on/a/different/domain?auth=SomeBase64String">Fetch authenticated resource</a>
To use, add app.UseAuthHeaderRewriter()
to your Startup.Configure
:
app.UseAuthHeaderRewriter();
// ...register anything else that might affect the request...
app.UseMvc();
Bind CSV Values to Routes
The CsvModelBinder
is a model binder that will take a csv string passed as a query string, route parameter, or request body and turn it into an array of a given type.
https://example.com/mystuff/1,2,3
[HttpGet]
[Route("mystuff/{ids}")]
public HttpResponseMessage DoSomethingWithIds(int[] ids /* or IEnumerable<int> ids */)
{
foreach (var id in ids)
{
//do something
}
}
Register it in Startup.ConfigureServices
:
services.AddMvc().AddMvcOptions(opts =>
{
opts.ModelBinders.Add(new CsvModelBinder());
});
Use JSON Exception Handling
If you are writing an API and want unhandled exceptions handled and returned to the client in a nice JSON way, you'll want to use this middleware. In your Startup.Configure
, call this before everything else, so that it gets first-dibs at answering when an exception goes unhandled:
app.UseJsonExceptionHandling();
// ...literally everything else...
This will handle exceptions in a similar way to how ASP.NET Web API used to handle them.
GZip Request Decompression (server)
ASP.NET Core, on its own, won't handle compressed request content. IIS can do this, but Azure App Services don't provide configurability for the IIS gateway, so instead, the request decompression must be handled by the ASP.NET application. This is simple; just add the GZipResourceFilter
in the Startup object's services.AddMvc()
call:
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddMvc(opts => opts.Filters.Add(typeof(GZipResourceFilter)))
.AddJsonOptions(opts => JsonSettings.Configure(opts.SerializerSettings))
.AddControllersAsServices(); // etc
// ...
}
You can confirm this works using Fiddler to inspect the request body.
Caution:
TheContent-Encoding:
header supports multiple values to signify that multiple encodings were applied in a particular order, butGZipResourceFilter
does not handle this. IfGZipResourceFilter
receives such a request, it will barf.