Skip to content

icarus-consulting/Yaapii.Http

master
Switch branches/tags
Code

EO principles respected here Build status

Repo Guidelines

Mainly responsible for this repository is DFU398. Please request a review in every single PR from him.

He will try to review the PRs within 1 week and merge applied PRs within 2 weeks with a new release. Main review day is thursday.

Yaapii.Http

Object oriented http client. C# Port of Vatavuk's verano-http.

  1. Creating Requests
  2. Sending Requests
  3. Handling Responses
  4. Unit Testing

Creating Requests

Requests are created from separate parts like a method, an address to send it to, headers or a body. For Example:

new Request(
    new Method("get"),
    new Address("https://example.com"),
    new Header("Accept", "text/html"),
    new Body("some request body")
)

The order of these parts is irrelevant.

Specifying a Request Method

There is a request part that specifies a method:

new Request(
    new Method("get"),
    new Address("https://example.com")
)

Alternatively, there are request classes that come with a predefined method:

new Get(
    new Address("https://example.com")
)

Specifying an Address

An address can either be specified as a whole, using the Address request part, or parts of a URI can be added separately:

new Get(
    new Address("https://example.com/this/is/a/path?aQueryParam=someValue&anotherQueryParam=anotherValue#someFragment")
)
new Get(
    new Scheme("https"),
    new Host("example.com"),
    new Path("this/is/a/path"),
    new QueryParam("aQueryParam", "someValue"),
    new QueryParam("anotherQueryParam", "anotherValue"),    // query params can be added individually
    new QueryParams(                                        // or multiple at once
        "aQueryParam", "someValue",
        "anotherQueryParam", "anotherValue"
    ),
    new Fragment("someFragment")
)

These are the URI parts that can be added individually:

http://user@example.com:8080/this/is/a/path?key=value&key2=value2#someFragment
\__/   \__/ \_________/ \__/ \____________/ \___________________/ \__________/
  |     |        |       |          |                 |                |
Scheme User     Host    Port       Path          QueryParams        Fragment

Only scheme and host are required to form a functioning URI.

Request classes may have constructors that specifically take a URI (or its string representation), so you don't have to add it as a request part:

new Get("https://example.com")
// is equivalent to:
new Get(
    new Address("https://example.com")
)

Request Headers

Headers can be added individually or in bulk. To add several values to the same header, just reuse the same header key:

new Get(
    new Header("User-Agent", "Yaapii.Http"),
    new Header("Accept", "text/html"),
    new Header("Accept", "text/plain")
)
new Get(
    new Headers(
        "User-Agent", "Yaapii.Http",
        "Accept", "text/html",
        "Accept", "text/plain"
    )
)

There are also header classes with a predefined header key:

new Get(
    new Accept("text/html")
)
// is equivalent to
new Get(
    new Header("Accept", "text/html")
)

Some also create the header value for you:

new Get(
    new BasicAuth("user", "password")
)
// is equivalent to
new Get(
    new Header("Authorization", "Basic dXNlcjpwYXNzd29yZA==") // where "dXNlcjpwYXNzd29yZA==" is "user:password" in base 64
)

Request Bodies

You can add a body to a request using the appropriate request part:

new Post(
    new Body("insert request body here")
)

Some body ctors also set a value for the 'Content-Type' header:

new Post(
    new Body(new JObject())
)
// is equivalent to
new Post(
    new Body(new JObject().ToString(), "application/json")
)
// or
new Post(
    new ContentType("application/json"),
    new Body(new JObject().ToString())
)
new Post(
    new Body(new XMLCursor("<root></root>"))
)
// is equivalent to
new Post(
    new Body(new XMLCursor("<root></root>").AsNode().ToString(), "application/xml")
)
// or
new Post(
    new ContentType("application/xml"),
    new Body(new XMLCursor("<root></root>").AsNode().ToString())
)

You can also use specific body classes :

  • TextBody will set the content type header to text/plain,
  • HtmlBody will set the content type header to text/html,
  • FormParam or FormParams will set the content type header to application/x-www-form-urlencoded.

Serialization and Deserialization

The body class allow the serialization of certain data formats into text. These include:

  • Body(IXML xml) uses IXML (see Yaapii.Xml),
  • Body(JToken json) uses JToken (see Newtonsoft.Json),
  • Body(IJSON json) uses IJSON (see Yaapii.JSON),
  • Body(IInput content) uses IInput (Yaapii.Atoms).

For Deserialization, see Extracting Data from a Response

Sending Requests

Setting up an AspNetCoreWire

The AspNetCoreWire relies on a System.Net.Http.HttpClient to send http requests. The HttpClient should be reused when possible, according to https://aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/. For that reason, AspNetCoreClients exists to manage reuseable http clients.

To make sure they are reused, only use one instance of AspNetCoreClients in your application and pass it down to where it is needed via dependency injection.

For example, when setting up a Microsoft.AspNetCore.WebHost, add an instance of AspNetCoreClients to dependency injection of the WebHost:

var host = WebHost.CreateDefaultBuilder();
host.ConfigureServices(svc =>
{
    svc.Add(
        new ServiceDescriptor(
            typeof(IAspHttpClients),
            (prov) => new AspNetCoreClients(),
            ServiceLifetime.Singleton
        )
    );
});

At the place where you want to send a request, retrieve the instance of AspNetCoreClients from dependency injection and pass it to the ctor of AspNetCoreWire. This way AspNetCoreWire will use an existing HttpClient whenever possible.

Communicating with a Wire

You can create a Response that encapsulates an IWire and will send the request when it becomes necessary:

var response =
    new Response(
        new AspNetCoreWire(
            new AspNetCoreClients() // should normally be passed down via dependency injection
        ),
        new Get("https://example.com")
    );
var status = new Status.Of(response);
// no request sent yet due to lazy initialization
var statusInt = status.AsInt(); // request sent

To send a request immediately, you can trigger the lazy initialization like this:

var response =
    new Response(
        new AspNetCoreWire(
            new AspNetCoreClients() // should normally be passed down via dependency injection
        ),
        new Get("https://example.com")
    );
response.GetEnumerator();

Alternatively, you can ask a wire directly for a response:

var response =
    new AspNetCoreWire(
        new AspNetCoreClients() // should normally be passed down via dependency injection
    ).Response(
        new Get("https://example.com")
    );

Calling IWire.Reponse(request) will send the request immediately and return the response.

There is a wire decorator to add extra parts to every request:

new Refined(
    new AspNetCoreWire(
        new AspNetCoreClients() // should normally be passed down via dependency injection
    ),
    new Header("User-Agent", "Yaapii.Http") // will be added to every request
).Response(new Get("https://example.com"))

Handling Responses

Extracting Data from a Response

Most request part classes (bodies and headers) also have a *.Of sub class that can be used to extract information from a response. Header classes will return IEnumerable<string>, since a header can have multiple values. For Example:

var response =
    new Response(
        new AspNetCoreWire(
            new AspNetCoreClients() // should normally be passed down via dependency injection
        ),
        new Get("https://example.com")
    );
var status = new Status.Of(response) // implements INumber, see Yaapii.Atoms
var contentTypes = new ContentType.Of(response) // implements IEnumerable<string>
var contentType = new ContentType.FirstOf(response) // implements IText, see Yaapii.Atoms
var methods = new Header.Of("Allow") // implements IEnumerable<string>
var method = new Header.FirstOf("Allow") // implements IText, see Yaapii.Atoms
var body = new Body.Of(response) // implements IInput, see Yaapii.Atoms

If you need the body in a format other than IInput, there are special body classes, that do the conversion for you:

  • new TextBody.Of(response) does something similar to new TextOf(new Body.Of(response)), but automatically selects the encoding based on the Content-Type header (default is UTF-8, if none is specified)
  • new XmlBody.Of(reponse) does the same as new XMLCursor(new Body.Of(response))
  • new JsonBody.Of(reponse) does the same as new JSONOf(new Body.Of(response))
  • new BytesBody.Of(reponse) does the same as new BytesOf(new Body.Of(response))

Response Verification

The IVerification interface allows to check if a response meets certain criteria. There is also a wire decorator, that will run a given verification for each response coming from the wire:

new Verified(
    new AspNetCoreWire(
        new AspNetCoreClients() // should normally be passed down via dependency injection
    ),
    new Verification(
        res => new Status.Of(res).AsInt() == 200,
        res => new ApplicationException("something went wrong")
    )
).Response(new Get("https://example.com"))

There is a less verbose way to verify the status code:

new Verified(
    new AspNetCoreWire(
        new AspNetCoreClients() // should normally be passed down via dependency injection
    ),
    new ExpectedStatus(200)
).Response(new Get("https://example.com"))

Unit Testing

Fake Classes

A lot of classes can be tested without sending actual http requests. A fake wire is available to pretend sending requests and receiving responses in unit tests:

new FkWire(req =>
{
    // ... do something with the request and return a response
    return new Response.Of(200, "OK");
}).Response(
    // ... your request here
)

Mocking different Paths

MatchingWire allows to mock the behavior of a real web server more closely by routing requests to different wires, depending on the content of the requests. This is done using wire templates (classes implementing ITemplate), that encapsulate a check for certain criteria that requests have to meet, and a wire that should be used to respond to these requests. MatchingWire finds the first template that applies to a request and asks it for a response to that request. The simplest use for this is returning different responses for different paths:

new MatchingWire( // implements IWire
    new Match( // implements ITemplate
        new Path("/regular-path"),
        new FkWire(200, "OK")
    ),
    new Match(
        "/regular-path",    // does the same as the first template, but is less verbose
        new FkWire(200, "OK")
    ),
    new Match(
        "/top-secret",
        new FkWire(403, "Forbidden")
    )
)

Current ITemplate implementations are:

  • Match, applies to a request, if the request matches all specified request parts, e.g. path, method, headers, query params, etc.
  • Conditional applies to a request, if a given function returns true for the request.

See the HttpMock example code below for more examples of how to use these templates.

HttpMock

Should you need to test with actual http requests, a mock server is provided through HttpMock. It encapsulates jrharmon's MockHttpServer in a way that allows it to process incoming requests using an IWire. It's an IScalar (see Yaapii.Atoms) returning a MockServer (see MockHttpServer). Calling HttpMock.Value() for the first time will initialize the server. It can either use one wire to handle all requests, regardless of the requested path, or respond to specific paths with a different wire for each path. Routing is done using the MatchingWire and templates described above.

using( var server =
    new HttpMock(1337,
        new FkWire() // will handle all paths
    ).Value()
)
{
    // ... testing code goes here
}
using( var server =
    new HttpMock(1337,
        new Match("", 
            new FkWire(200, "OK") // will handle http://localhost:1337/
        ),
        new Match("bad/request",
            new FkWire(400, "Bad Request") // will handle http://localhost:1337/bad/request
        ),
        new Match("coffee",
            new Method("brew"), // see RFC 2324, not supported by AspNetCoreWire
            new FkWire(418, "I'm a teapot") // will handle http://localhost:1337/coffee, if the method is "BREW"
        ),
        new Match(
            new Parts.Joined(
                new Body("important data"),
                new Method("put")
            ),
            new FkWire(200, "OK") // will handle PUT requests with the specified body, if they didn't match an earlier template
        ),
        new Conditional(
            req => new LengthOf<string>(new BearerTokenAuth.Of(req)).Value() > 0,
            new FkWire(200, "OK")  // will handle requests that have any bearer token authorization header, if they didn't match an earlier template
        )
    ).Value()
)
{
    // ... testing code goes here
}

Always dispose the HttpMock or the MockServer it returned!

No need to dispose both, disposing HttpMock will just dispose the MockServer returned by HttpMock.Value(). This means calling HttpMock.Dispose() will do the same thing as calling HttpMock.Value().Dispose. If not disposed, it will keep running in the background, listening for requests, keeping the port occupied. The easiest way to do this is to put either new HttpMock or HttpMock.Value() in a using block as follows:

using( var httpMock =
    new HttpMock(1337,
        new FkWire()
    )
)
{
    var server = httpMock.Value(); // this would start the server
    // ... testing code goes here
} // this disposes the server

using( var server =
    new HttpMock(1337,
        new FkWire()
    ).Value() // start the server immediately
)
{
    var port = server.Port;
    // ... testing code goes here
} // this disposes the server

Do not give an AspNetCoreWire to HttpMock!

HttpMock is for receiving requests, not sending them. Using a wire that sends requests to handle the requests received by HttpMock would cause each received request to just be sent again, causing an infinite loop.