Skip to content

Latest commit

 

History

History
460 lines (318 loc) · 10.2 KB

handler-builder-usage.md

File metadata and controls

460 lines (318 loc) · 10.2 KB

FortitudeHandlerBuilder Usage Guide

This document describes each method available on FortitudeHandlerBuilder. Examples focus only on the method being described and assume chaining from fortitudeClient.Accepts().

Entry Point

Accepts()

Starts building a request handler for a FortitudeClient.

fortitudeClient.Accepts()

Request Predicates

Matches(Func<FortitudeRequest, bool>)

Adds a custom predicate that must return true for the request to match.

.Matches(req => req.Route == "/health")

HTTP Method Matching

Method(string method)

Matches a specific HTTP method.

.Method("GET")

Get()

Matches HTTP GET requests.

.Get()

Post()

Matches HTTP POST requests.

.Post()

Put()

Matches HTTP PUT requests.

.Put()

Delete()

Matches HTTP DELETE requests.

.Delete()

Patch()

Matches HTTP PATCH requests.

.Patch()

Options()

Matches HTTP OPTIONS requests.

.Options()

Methods(params string[] methods)

Matches multiple HTTP methods.

.Methods("GET", "POST")

AnyMethod()

Clears method restrictions and matches any HTTP method.

.AnyMethod()

Routing & URL Matching

HttpRoute(string route)

Matches an exact HTTP route.

.HttpRoute("/users")

RouteStartsWith(string prefix)

Matches routes that start with the given prefix.

.RouteStartsWith("/api")

RouteEndsWith(string suffix)

Matches routes that end with the given suffix.

.RouteEndsWith("/status")

Header Matching

Header(string key, string value)

Requires a specific header and value.

.Header("X-Env", "test")

HeaderExists(string key)

Requires a header to exist, regardless of value.

.HeaderExists("X-Request-Id")

Authorization(string value)

Matches an exact Authorization header value.

.Authorization("Basic abc123")

BearerToken(string token)

Matches a Bearer token Authorization header.

.BearerToken("my-token")

Accept(string mediaType)

Matches the Accept header.

.Accept("application/json")

ContentType(string contentType)

Matches the Content-Type header.

.ContentType("application/json")

UserAgent(string userAgent)

Matches the User-Agent header.

.UserAgent("Fortitude-Test")

Query String Matching

QueryParam(string key, string value)

Requires a query parameter with a specific value.

.QueryParam("page", "1")

QueryParamExists(string key)

Requires a query parameter to exist, regardless of value.

.QueryParamExists("debug")

QueryParams(Dictionary<string, string> parameters)

Adds multiple required query parameters.

.QueryParams(new Dictionary<string, string>
{
    ["sort"] = "asc",
    ["limit"] = "10"
})

Body Matching

Body(Func<byte[]?, bool>)

Adds a custom predicate for matching the request body.

.Body(body => Encoding.UTF8.GetString(body!) == "hello")

BodyIsEmpty()

Matches requests with no body or an empty body.

.BodyIsEmpty()

BodyContains(string text)

Matches requests whose UTF-8 body contains the given text.

.BodyContains("hello")

JsonBody()

Matches requests with Content-Type set to application/json.

.JsonBody()

JsonBody(Func<T, bool> predicate)

Deserializes the JSON body and applies a predicate to the result.

.JsonBody<MyDto>(dto => dto.IsActive)

Responders

Returns(Action<FortitudeRequest, FortitudeResponse>)

Registers a synchronous responder.

.Returns((req, res) =>
{
    res.Ok();
})

Returns(Func<FortitudeRequest, FortitudeResponse, Task>)

Registers an asynchronous responder.

.Returns(async (req, res) =>
{
    await Task.Delay(10);
    res.Ok();
})

Simulating latency

WithDelay holds the response back, so the system under test experiences a slow dependency. This is how you exercise timeouts, retries and circuit breakers.

fortitude.Accepts()
    .Get()
    .HttpRoute("/users")
    .WithDelay(TimeSpan.FromSeconds(2))
    .Returns((req, res) => res.Ok(users));

WithDelay also takes milliseconds directly, and WithRandomDelay draws a fresh delay from a range on every request to simulate jitter:

.WithDelay(250)                 // always 250ms
.WithRandomDelay(100, 500)      // uniform between 100ms and 500ms

Two things worth knowing:

  • The server gives up on a handler after Settings:ResponseTimeoutSeconds (5 seconds by default) and returns 504. A delay beyond that is cut short - which is exactly how you test a client's timeout handling, but raise the setting if it is not what you meant.
  • WaitForRequestAsync completes as soon as the request arrives, before the delay elapses, so asserting that the dependency was called does not cost you the simulated latency.

In YAML the same options live on the response:

response:
  status: 200
  delayMs: 2000
response:
  status: 200
  delay:
    minMs: 100
    maxMs: 500

Limiting how many times a handler answers

Times(n), and its shorthands Once() and Twice(), stop a handler matching once it has answered that many requests. An exhausted handler steps aside and the next matching one takes over, which is how retry behaviour is expressed:

// Registered first, so it is the fallback.
fortitude.Accepts().Get().HttpRoute("/flaky")
    .Returns((req, res) => res.Ok("recovered"));

// Registered later, so it wins - until it has answered twice.
fortitude.Accepts().Get().HttpRoute("/flaky")
    .Twice()
    .Returns((req, res) => res.InternalServerError("down"));

The first two calls get a 500, the third gets "recovered". CallCount and IsExhausted on the returned handler let you assert on it afterwards.

With no fallback registered, requests after the limit fall through to 501 Not Implemented.

Under genuinely concurrent requests the limit is best effort - two requests can both pass the check before either is counted. Sequential use, the normal case in tests, is exact.

Returning a different response each time

ReturnsInSequence walks a list of responders, repeating the last one once the sequence runs out. Useful for polling flows:

fortitude.Accepts().Get().HttpRoute("/jobs/1")
    .ReturnsInSequence(
        (req, res) => res.Ok(new { status = "pending" }),
        (req, res) => res.Ok(new { status = "pending" }),
        (req, res) => res.Ok(new { status = "complete" }));

Controlling which handler wins

When several handlers match the same request, the most recently registered one answers. That default is usually what you want - it lets a test override a fixture set up in a base class.

Priority(n) overrides it explicitly. Higher wins, the default is 0, and negatives are useful for a catch-all that should only apply when nothing else matches:

fortitude.Accepts().Get().RouteStartsWith("/")
    .Priority(-100)
    .Returns((req, res) => res.NotFound("no handler for this route"));

Injecting faults

An error status is the easy case for a client to handle. ReturnsFault produces the harder ones: a dependency that goes away mid-request, or answers with something unusable.

fortitude.Accepts().Get().HttpRoute("/users").ReturnsFault(HttpFault.ConnectionReset);
Fault What the caller sees
ConnectionReset The connection is aborted with no response at all - a socket error, not a status code. In intercept mode this surfaces as HttpRequestException.
EmptyBody A success status with no content, where content was expected.
MalformedJson application/json whose body is truncated mid-object and will not parse.
RandomBytes application/json whose body is binary noise.

To fail only some of the time, FailsRandomly wraps the normal responder:

fortitude.Accepts().Get().HttpRoute("/users")
    .FailsRandomly(0.2)                       // 20% of calls get a 500
    .Returns((req, res) => res.Ok(users));

The failure response is configurable, which is handy for rate limiting:

.FailsRandomly(0.1, (req, res) => res.TooManyRequests("slow down", retryAfterSeconds: 30))

Faults combine with the other modifiers, so a handler can fail the first call and then behave:

fortitude.Accepts().Get().HttpRoute("/users")
    .Once()
    .ReturnsFault(HttpFault.ConnectionReset);

Stateful flows

Sometimes the same route has to answer differently depending on what has already happened - a job that reports queued, then running, then complete. A scenario is a named state machine that lets handlers express that without knowing about each other.

fortitude.Accepts().Get().HttpRoute("/jobs/1")
    .InScenario("job").WhenState("Started").ThenState("Running")
    .Returns((req, res) => res.Ok(new { status = "queued" }));

fortitude.Accepts().Get().HttpRoute("/jobs/1")
    .InScenario("job").WhenState("Running").ThenState("Done")
    .Returns((req, res) => res.Ok(new { status = "running" }));

fortitude.Accepts().Get().HttpRoute("/jobs/1")
    .InScenario("job").WhenState("Done")
    .Returns((req, res) => res.Ok(new { status = "complete" }));

Every scenario begins in Started (FortitudeScenarios.InitialState). A handler matches only while the scenario is in its WhenState, and moves it to ThenState once it has answered. Omit WhenState to match in any state; omit ThenState to leave the state alone. A scenario with neither is refused, since it could only be a mistake.

fortitude.Scenarios exposes the state directly, which is useful for setting up midway through a flow or resetting between test cases:

fortitude.Scenarios.GetState("job");        // "Running"
fortitude.Scenarios.SetState("job", "Done");
fortitude.Scenarios.Reset("job");           // back to "Started"
fortitude.Scenarios.ResetAll();

Scenarios work the same way in YAML, where handlers across different files can share one flow:

handlers:
  - match:
      route: /job
      scenario:
        name: job
        whenState: Started
        thenState: Running
    response:
      status: 200
      body:
        text: queued