Skip to content

Typed clients

A generated API client is a test parameter. The harness builds it over an HttpClient whose handler runs the pipeline in-process, so a test drives the service the way a consumer will, without a socket.

csharp
[HardenedTest]
public async Task GetTodo_ReturnsTheTodo(TodosClient client) {
    var todo = await client.Todos[1].GetAsync().Returns<Ok<ClientModels.Todo>>();

    Assert.Equal("Read the generated code", todo.Value.Title);
}

TodosClient is the Kiota client the hardened-web template generates from the exported document. Generated clients covers how the client is produced and shipped. This page is how a test gets one.

Naming the generator

One assembly attribute per generator, from that generator's testing package:

csharp
// Bootstrap.cs
using Hardened.Kiota.Testing;

[assembly: KiotaTesting]
PackageAttributeRecognises
Hardened.Kiota.Testing[KiotaTesting]A Kiota client, whose one constructor takes an IRequestAdapter
Hardened.Refit.Testing[RefitTesting]A Refit interface, generated by Refitter or written by hand

After the attribute, every client of that generator's shape is a test parameter. Nothing is written per client, so a second service in the solution costs nothing. A solution with both generators declares both attributes.

The model namespace

The generated models are named after the document's schemas, and the schemas are named after the application's own types. A test project that references both has two Todo types. The scaffold's Usings.cs aliases the client's:

csharp
global using ClientModels = Todos.Client.Models;

So new Todo(7, "ship it", false) in a test is the application's record, and ClientModels.Todo is what the client returned.

How a client is built

Three routes, tried in order. The first that can build the type does:

  1. A public ITestClientFactory<T> in the test assembly, for that one client.
  2. The route a generator's testing package registered, for every client of that generator's shape.
  3. A public constructor taking exactly one HttpClient, which is what NSwag's output and most hand-written clients have. No package and no factory is needed.

A parameter type none of the three can build fails the test naming all three.

A factory of your own

A client that needs a real authentication provider, or a delegating handler of its own, gets a factory. The factory wins over the generator's route for that one client, and the route keeps building the rest:

csharp
public sealed class SignedTodosClientFactory : ITestClientFactory<TodosClient> {
    public TodosClient Create(HttpClient http) =>
        new(new HttpClientRequestAdapter(new SigningAuthenticationProvider(), httpClient: http) {
            BaseUrl = "http://harness"
        });
}

app.CreateClient<T>() builds a client the same way inside a test, with a credential of the test's choosing.

The HttpClient underneath

app.CreateHttpClient() returns the HttpClient the routes build over. Its handler, PipelineHttpMessageHandler, turns the request's method, path, headers, cookies and body bytes into an execution context, runs the chain, and turns the response back with its status, headers, Set-Cookie and body.

csharp
[HardenedTest]
public async Task MalformedJsonAnswersBadRequest(ITestWebApp app) {
    using var http = app.CreateHttpClient();
    using var content = new StringContent("{\"name\":", Encoding.UTF8, "application/json");
    using var response = await http.PostAsync("/registration", content);

    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

Its BaseAddress is http://harness/, which the handler ignores and a client that builds relative URLs resolves against. The path is decoded the way Kestrel decodes one: %20 is a space and %2F stays as written.

Mocks and credentials

A [Mock] is registered in the container the handler resolves from, so a client reaching that handler sees the substitute. A credential is an attribute on the client parameter, and two parameters of one client type with different attributes are two clients.

Refit

The route builds the interface with Refit over the harness's HttpClient. For Returns<T>() to read the status and the headers, every operation returns Task<IApiResponse<T>>. Refitter writes that with --use-api-response, which the scaffold's .refitter file sets as returnIApiResponse. A method declared Task<T> throws for a refusal and returns the body alone for a success, and Returns<T>() refuses that success by name because the status is gone.

Refit has no error mapping, so an error body arrives as text and is read as the expectation's type argument through the client's own RefitSettings.

What the client cannot send

A generated client makes the requests the document allows. A request the document forbids, such as a string in an int path parameter, goes through ITestWebApp:

csharp
[HardenedTest]
public async Task GetTodo_MalformedId_IsBadRequest(ITestWebApp app) {
    (await app.Get("/todos/not-a-number")).Assert.BadRequest();
}

What Kiota does and does not generate from a Hardened document is on Generated clients.

Next

Released under the MIT License.