justeattakeaway / httpclient-interception

A .NET library for intercepting server-side HTTP requests
https://tech.just-eat.com/2017/10/02/reliably-testing-http-integrations-in-a-dotnet-application/
Apache License 2.0
351 stars 27 forks source link

Including existing registrations in HttpRequestNotInterceptedException #855

Open drewburlingame opened 1 month ago

drewburlingame commented 1 month ago

When first using this library, we found it difficult to debug issues when a registration wasn't found that we expected. We use Refit and found that sometimes the mismatch was due to order of url params or encoding issues. We also have test infra to configure common calls like auth sequences for various integrations.

We ended up extending IntereceptingHttpMessageHandler to enrich the HttpRequestNotInterceptedException with a list of registered interceptions. Below is the handler we created. It's not perfect since it can't easily report custom matching, but it significantly improved our experience of troubleshooting missing registrations. I've been intending to offer this as a PR but I don't think I'll have time soon so wanted to offer this for anyone it might benefit or would like to use for a PR.


internal class FriendlierErrorInterceptingHttpMessageHandler(HttpClientInterceptorOptions options)
    : InterceptingHttpMessageHandler(options)
{
    internal readonly HttpClientInterceptorOptions Options = options;

    internal Task<HttpResponseMessage> SendAsyncInternal(HttpRequestMessage request,
        CancellationToken cancellationToken) =>
        SendAsync(request, cancellationToken);

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        try
        {
            return await base.SendAsync(request, cancellationToken);
        }
        catch (HttpRequestNotInterceptedException e)
        {
            var registeredMappings = GetRegisteredMappings().ToOrderedDelimitedString(Environment.NewLine);
            var unescapedDataString = Uri.UnescapeDataString(request.RequestUri!.AbsoluteUri);
            var recordableOptions = Options as RecordableHttpClientInterceptorOptions;
            var msg = $"No HTTP response is configured for {recordableOptions?.TestFilePath}" +
                      $"\n\n{request.Method.Method} {request.RequestUri!.AbsoluteUri}" +
                      $"\n({unescapedDataString})";
            throw e.Request is null
                ? new HttpRequestNotInterceptedException($"{msg}\n\nRegistered Mappings:\n\n{registeredMappings}")
                : new HttpRequestNotInterceptedException($"{msg}\n\nRegistered Mappings:\n\n{registeredMappings}", e.Request);
        }
    }

    private IEnumerable<string> GetRegisteredMappings()
    {
        var mappings = (IDictionary)typeof(HttpClientInterceptorOptions)
            .GetField("_mappings", BindingFlags.Instance | BindingFlags.NonPublic)!
            .GetValue(Options)!;

        if(mappings.Count == 0)
            return Array.Empty<string>();

        // Keys prefixed with CUSTOM: will not show the query params of the URI.
        // We will append them at the end of the method.
        var registrations = mappings.Values.Cast<object>().ToCollection();

        // sealed internal class: HttpInterceptionResponse
        var type = registrations.First().GetType();
        Dictionary<string, PropertyInfo> propertyInfos = type
            .GetProperties(BindingFlags.Instance|BindingFlags.NonPublic)
            .ToDictionary(d => d.Name);

        string? GetValue(string propertyName, object o) => propertyInfos[propertyName].GetValue(o)?.ToString();
        string? IfExists(string propertyName, object o, string text) =>
            propertyInfos[propertyName].GetValue(o) != null ? $" {text}" : null;
        string? IfTrue(string propertyName, object o, string text) =>
            ((bool?)propertyInfos[propertyName].GetValue(o)).GetValueOrDefault() ? $" {text}" : null;

        return registrations
            .Select(o => $"{GetValue("Method", o)} {GetValue("RequestUri", o)}" +
                         $"{IfExists("ContentMatcher", o, "+content-matching")}" +
                         $"{IfExists("UserMatcher", o, "+user-matching")}" +
                         $"{IfTrue("IgnoreHost", o, "ignore-host")}" +
                         $"{IfTrue("IgnorePath", o, "ignore-path")}" +
                         $"{IfTrue("IgnoreQuery", o, "ignore-query")}")
            .Concat(HttpRequestInterceptionBuilderExtensions.GetCurrentTestUnescapedQueries());
    }
}
martincostello commented 1 month ago

Thanks for this.

I wonder if a better way to build this in at some point would be to add some APIs related to introspection (which would maybe fit with some one-day aim to improve logging/debugging), and then something like this could build on top of that.

drewburlingame commented 1 month ago

This is definitely a hack and there are better ways to provide this if done from within the framework. Providing this level of detail is also probably not necessary for all consumers of the package. I could see making it an option and/or lazily resolved property on the exception.