Gossip4Net.Http.Xml 0.0.4

dotnet add package Gossip4Net.Http.Xml --version 0.0.4
NuGet\Install-Package Gossip4Net.Http.Xml -Version 0.0.4
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Gossip4Net.Http.Xml" Version="0.0.4" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add Gossip4Net.Http.Xml --version 0.0.4
#r "nuget: Gossip4Net.Http.Xml, 0.0.4"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install Gossip4Net.Http.Xml as a Cake Addin
#addin nuget:?package=Gossip4Net.Http.Xml&version=0.0.4

// Install Gossip4Net.Http.Xml as a Cake Tool
#tool nuget:?package=Gossip4Net.Http.Xml&version=0.0.4

Gossip4Net

Gossip4Net is an extensible http client middleware similar to Spring Feign. It allows developers to easily consume APIs using an interface contract, which gets automatically implemented.

Note Please read the Not invented here section, to learn if this is the right library for your use case.

  1. Getting started
    1. Get Gossip4Net
    2. Define your API contract and models
    3. Obtain and configure a HttpGossipBuilder
    4. Let Gossip4Net implement your API interface
    5. Use your API
  2. Not invented here
  3. Feaures
  4. Authentication
  5. Extensibility
    1. The internals
      1. Big picture
      2. Registrations
      3. Request modifiers
      4. Response modifiers
      5. Response constructors
    2. Example (adding XML support)
  6. Testing

Getting started

0. Get Gossip4Net

For standalone usage:

Install-Package Gossip4Net.Http

For usage within ASP.NET core:

Install-Package Gossip4Net.Http.DependencyInjection

For XML support

Install-Package Gossip4Net.Http.Xml

1. Define your API contract and models

namespace MyDemo {
    public record HttpBinResponseDto(IDictionary<string, string> Headers);
    public record PersonRequestDto(string Fistname, string Lastname);

    [HttpApi("https://httpbin.org")]
    public interface HttpBinApi {

        [GetMapping("/get")]
        Task<HttpBinResponseDto> GetAsync();

        [GetMapping("/get")]
        HttpBinResponseDto GetSync();

        [PostMapping("/post")]
        Task PostAsync(Person person);
    }
}

2. Obtain and configure a HttpGossipBuilder

using  Gossip4Net.Http;

namespace MyDemo {
    public class Demo {
        public async Task Startup() {
            IHttpGossipBuilder<HttpBinApi> builder = new HttpGossipBuilder<HttpBinApi>();
            builder.AddDefaultBehavior();
        }
    }
}

For ASP.NET core, dependency injection can be used:

using  Microsoft.Extensions.DependencyInjection;

namespace AspNetDemo {

    public class Startup {

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddGossipHttpClient<HttpBinApi>();
        }
    }
}

3. Let Gossip4Net implement your API interface

namespace MyDemo {
    public class Demo {
        public async Task Startup() {
            IHttpGossipBuilder<HttpBinApi> builder = new HttpGossipBuilder<HttpBinApi>();
            builder.AddDefaultBehavior();

            HttpBinApi api = builder.Build();
        }
    }
}

4. Use your API

using  Gossip4Net.Http;

namespace MyDemo {
    public class Demo {
        public async Task Startup() {
            IHttpGossipBuilder<HttpBinApi> builder = new HttpGossipBuilder<HttpBinApi>();
            builder.AddDefaultBehavior();

            HttpBinApi api = builder.Build();
            HttpBinResponseDto response = await api.GetAync();
        }
    }
}

Not invented here

This is (by far) not the first project enabling developers to consume an API by automatically implementing an interface. In fact, there is an awesome library called Refit, doing just that.

Note For the sake of completeness, it has to be mentioned that there is also DHaven.Faux and feign.net heading in the same general direction (although I don't have firsthand experience with them).

Why you should use Refit

Refit is a widespread, stable and fast library, providing similar functionality. If you only intend to use "standard" features, it might well be the better choice for your use case - so go check it out!

Why you should use Gossip4Net

The "issue" with auto-generating libraries is (IMHO), that you are somewhat limited to the functionalities intended by the project's maintainers. Most of the time they will do just fine, but you are probably going to run into trouble the first you need to do something "special".

What do you do now? Getting rid of the library and implementing everything manually (even the "standard" cases)?

In order to avoid having to ask these questions, Gossip4Net is focused on extensibility. Even though this library is also providing some standard features, all of them are added dynamically and in the same way new behaviors could be added by any depending project.

Features

Url mapping

The API-Url can be specified using the [HttpApi] attribute

[HttpApi("https://httpbin.org")]
public interface MyApi {}

and/or using the method mapping attributes (e.g. [GetMapping], [PostMapping], [PatchMapping]):

[HttpApi]
public interface MyApi {
    [GetMapping("https://httpbin.org/get")]
    Task<HttpResponseMessage> GetResponseAync();
}

If both are present and the url specified on the method mapping is relative, it will be combined with the url given in [HttpApi].

[HttpApi("https://httpbin.org")]
public interface MyApi {
    [GetMapping("/get")]
    Task<HttpResponseMessage> GetResponseAync();
}

The above example will result in a call to https://httpbin.org/get.

Example path combinations | Base path | Extension | Result | | --------- | --------- | ------ | | https://localhost | /api | https://localhost/api | | https://localhost/api | /person | https://localhost/api/person | | https://localhost/api | person | https://localhost/person | | https://localhost/api/ | person | https://localhost/api/person | | https://localhost/api/ | /person | https://localhost/person |

Url mapping from code

You can also modify/specify urls directly from your code. This can come in handy, if you for example need to use a different API version depending on the current environment (e.g test, staging, prod).

All following examples will refer to this API definition.

[HttpApi("/get")]
public interface IRelativeHttpBinClient
{
    [GetMapping]
    Task<HttpBinResponse> Get();
}

To archive this, you can choose one of the following methods.

By providing a custom http client

You can specify a custom HttpClient provider by setting the builder's .ClientProvider property. This provider could then set an appropriate BaseAddress.

IHttpGossipBuilder<IRelativeHttpBinClient> builder = new HttpGossipBuilder<IRelativeHttpBinClient>().AddDefaultBehavior(new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
builder.ClientProvider = () => new HttpClient { BaseAddress = new Uri("https://httpbin.org") };

IRelativeHttpBinClient client = builder.Build();
By adding a custom behavior

Gossip4Net focuses on extensibility, which enables you to freely add and remove different behaviors. To get and in-depth insight, refer to Extensibility.

Request urls are handled by the RequestUriModifier, which takes an url and appends it to the currently processed http request. By injecting a custom instance into the request chain, you can modify the url as you like.

Adding a global request modifier using the helper method

IRelativeHttpBinClient client = builder
    .WithRegistrations(r => r.With(new RequestUriModifier("https://httpbin.org")))
    .AddDefaultBehavior(new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    })
    .Build();

Note The order of operation (adding the RequestUriModifier first, then applying the default behavior) is essential. Request modifiers are executed sequentially in order of their registration. If the RequestUriModifier was to be registered last, the url https://httpbin.org would be appended to /get (set by the [HttpApi] attribute).

Path variables

Parameter values can be interpolated into the request path using the [PathVariable] attribute.

[HttpApi("https://httpbin.org")]
public interface MyApi {
    [GetMapping("/{method}")]
    Task<HttpResponseMessage> GetResponseAync([PathVariable] string method);

    [GetMapping("/{operation}")]
    Task<HttpResponseMessage> GetResponseWithExplicitPathVariableNameAync([PathVariable("operation")] string method);
}

By default, the placeholder name is determined by the parameter name (e.g. "method"). It can also be specified manually.

Available properties | Property | Description | Default | |----------|-------------|---------| | Name | The path variable name. | The annotated parameter's name. | | EscapePath | If false, special characters (like / and ?) are not escaped. | true |

Query variables

Parameter values can be send as a query parameter using the [QueryVariable] attribute.

[HttpApi("https://httpbin.org")]
public interface MyApi {
    [GetMapping("/get")]
    HttpResponseMessage GetWithQuery([QueryVariable] string testParam);

    [GetMapping("/get")]
    HttpResponseMessage GetWithExplicitlyNamedQuery([QueryVariable("testParam")] string myValue);
}

By default, the query variable name is determined by the parameter name (e.g. "method"). It can also be specified manually.

Available properties | Property | Description | Default | |----------|-------------|---------| | Name | The query parameter name. | The annotated parameter's name. | | OmitEmpty | If true, the query parameter will be omitted for given null values. | true | | EnumerateUsingMultipleParams | If the parameter type is an IEnumerable and this is set to true, the query parameter name will be repeated for each entry. | true |

Static query values

Similar to [QueryVariable], the [QueryValue(string name, object? value)] attribute can be used to send a static header with every request.

[HttpApi("https://httpbin.org")]
[QueryValue("json", "true")]
public interface MyApi {    
    [QueryValue("max", 100)]
    HttpResponseMessage Get();
}

Available properties | Property | Description | Default | |----------|-------------|---------| | Name | The query parameter name. | none | | Value | The value to be sent. | none | | EnumerateUsingMultipleParams| If the parameter type is an IEnumerable and this is set to true, the query parameter name will be repeated for each entry. | true |

Header variables

Parameter values can be sent as request headers using the [HeaderVariable] attribute.

[HttpApi("https://httpbin.org")]
public interface MyApi {
    [DeleteMapping("/delete")]
    Task<HttpResponseMessage> DeleteAsyncWithHeader([HeaderVariable] string actor);

    [DeleteMapping("/delete")]
    Task<HttpResponseMessage> DeleteAsyncWithExplicitHeader([HeaderVariable("actor")] string myValue);
}

By default, the header name is determined by the parameter name (e.g. "method"). It can also be specified manually.

Available properties | Property | Description | Default | |----------|-------------|---------| | Name | The header name to be used. | The annotated parameter's name. | | OmitEmpty | If true, the header will be omitted for given null values. | true |

Static header values

In order to always send a specific header, the [HeaderValue] attribute can be applied to a method or to the entire interface.

[HttpApi("https://httpbin.org")]
[HeaderValue("Interface-Header", "interface")]
public interface MyApi {
    [GetMapping("/get")]
    [HeaderValue("Method-Header", "method")]
    Task<HttpResponseMessage> GetAyncWithStaticHeader();
}

Raw http response

If a method's return type is HttpResponseMessage or Task<HttpResponseMessage> the raw HttpResponseMessage will be returned.

[HttpApi("https://httpbin.org")]
public interface MyApi {
    [GetMapping("/get")]
    Task<HttpResponseMessage> GetRawResponseAsnc();
}

Note: Even though the response body will not be parsed and the entire http response body is getting returned, other response processing (e.g. checking the response status) still applies.

Async and synchronous requests

You can send both asynchronous and synchronous requests. If a request should be performed asynchronously is determined be the method's return type (being Task<> or Task) only. Aync methods do not have to end with the Async-suffix.

[HttpApi("https://httpbin.org")]
public interface MyApi
{
    [GetMapping("/get")]
    Task<HttpResponseMessage> GetAsync();

    [GetMapping("/get")]
    HttpResponseMessage Get();
}

Void methods

Methods returning either void or Task, will not return anything. Nevertheless, the response will still be received and processed (e.g. checking the response code) by the library. A call will be blocked and a returned Task will not complete until a response is received.

[HttpApi("https://httpbin.org")]
public interface MyApi
{
    [GetMapping("/get")]
    Task GetAsync();

    [GetMapping("/get")]
    void Get();
}

Request body

Parameter values without an attribute will be serialized and sent using the request body.

public class Person {
    public string? Firstname { get; set; }
    public string? Lastname { get; set; }
    public int Age { get; set; }
}

[HttpApi("https://httpbin.org")]
public interface MyApi
{
    [PostMapping("/post")]
    HttpResponseMessage Post(Person p);

    [PostMapping("/post")]
    Task<HttpResponseMessage> PostAsync(Person p);
}

The serialization format depends on the current configuration and can be customized using the HttpGossipBuilder. The default is JSON.

See also:

Response body

Gossip4Net will attempt to return an instance of the method's specified return type. It will be deserialized using the response body. The deserialization format depends on the current configuration and the received response headers. It can be customized using the HttpGossipBuilder.

public record HttpBinResponse(IDictionary<string, string> Headers, string Origin, string Url, IDictionary<string, string> Args);

[HttpApi("https://httpbin.org")]
public interface MyApi
{
    [GetMapping("/get")]
    Task<HttpBinResponse> GetAsync();

    [GetMapping("/get")]
    HttpBinResponse Get();
}

Json (de)serialization

By default, JSON is used to serialize request and response bodies.

JSON serialization is added by the JsonRequestBodyRegistration and JsonResponseConstructorRegistration.

You can configure the JSON serializer using one of the following extension/helper methods.

Constructing a builder and adding default behavior

IHttpGossipBuilder<MyApi> builder = new HttpGossipBuilder<MyApi>()
    .AddDefaultBehavior(new JsonSerializerOptions()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    });

Creating a builder using a builder helper method

IHttpGossipBuilder<MyApi> builder = HttpGossipBuilder<MyApi>.NewDefaultBuilder(
    new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    });

While using dependency injection

IServiceCollection services = new ServiceCollection();
services.AddGossipHttpClient<MyApi>(
    builder => builder.AddDefaultBehavior(new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    })
);

If you don't intend to use the helper/extension methods, you can add JSON support like this:

IHttpGossipBuilder<MyApi> builder = new HttpGossipBuilder<MyApi>();
JsonSerializerOptions options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
builder.Registrations.RequestAttributes.Add(new JsonRequestBodyRegistration(options));
builder.Registrations.ResponseConstructors.Add(new JsonResponseConstructorRegistration(options));

XML (de)serialization

XML support is provided by Gossip4Net.Http.Xml which must first be added via NuGet.

In order to use it, add it to your gossip builder.

IHttpGossipBuilder<IXmlHttpBinClient> builder = new HttpGossipBuilder<IXmlHttpBinClient>()
    .AddXmlBehavior()
    .AddDefaultBehavior();

There is also an .AddXmlBehavior(bool applyByDefault) overload, allowing you to enable/disable the automatic XML response deserialization for every API method. If applyByDefault is set to false or the parameterless overload is used, methods receiving XML responses need to have the [XmlResponse] attribute.

If you want to provide a customized XmlSerializer, you may use the .AddXmlBehavior(bool applyByDefault, Func<Type, XmlSerializer> serializerProvider) overload.

All attributes provided by Gossip4Net.Http.Xml are documented below.

[NoXmlResponse]

Can be applied to an API method, if the builder should not register a XML deserializer for this method (even if applyByDefault has been set to true).

[XmlResponse]

Needs to be applied to API methods expected to receive a XML response body, if applyByDefault has not been set to true.

[XmlBody]

Can be applied to a method parameter, in order to serialize it as a XML request body.

Available properties | Property | Description | Default | |----------|-------------|---------| | OmitEmpty | If true and the argument value is null, no request body will be sent. | true |

Authentication

Authentication can be implemented using third-party HttpClient middleware tools (like the common IdentityModel project or another library of your choice). The main entry point for configuring authentication is always the IHttpGossipBuilder<>.ClientProvider property, which holds a factory method returning a fresh HttpClient instance.

Refer to the examples below for suggestions on how certain authentication methods can be configured. If not stated otherwise, IdentityModel project will be used.

Warning The examples are written to be as concise and minimal as possible. Common and required security measures (e.g. obtaining passwords and secrets from secure sources) are omitted for clearity. Please consult the documentation relevant to your setup and tooling, in order to learn about safe ways to handle secrets.

A good starting point might be the following MSDN documentation:

Basic Auth

API definition

[HttpApi("https://httpbin.org")]
public interface IHttpBinStaticBasicAuthApi
{
    [GetMapping("/basic-auth/myuser/mypassword")]
    HttpResponseMessage GetWithBasicAuth();
}

Example usage with basic auth

public class BasicAuthTest
{
    [Fact]
    public void HttpClientWithBasicAuthShouldWork()
    {
        // Arrange
        IHttpGossipBuilder<IHttpBinStaticBasicAuthApi> gossipBuilder = HttpGossipBuilder<IHttpBinStaticBasicAuthApi>.NewDefaultBuilder();
        gossipBuilder.ClientProvider = () =>
        {
            HttpClient httpClient = new HttpClient();
            httpClient.SetBasicAuthentication("myuser", "mypassword");
            return httpClient;
        };
        IHttpBinStaticBasicAuthApi api = gossipBuilder.Build();

        // Act
        HttpResponseMessage response = api.GetWithBasicAuth();

        // Assert
        response.IsSuccessStatusCode.Should().BeTrue();
    }
}

OpenID with client secret

This example uses a client id and secret in order to authenticate against an OIDC identity provider. The token retrival and management is done automatically by IdentityModel.

API definition

[HttpApi("https://demo.duendesoftware.com/api/test")]
public interface IOidcApi
{
    [GetMapping]
    HttpResponseMessage Get();
}

Example usage with ASP.NET core and IdentityModel.AspNetCore

public class OidcAuthTest
{
    [Fact]
    public void FullExampleShouldWord()
    {
        // Arrange
        ServiceCollection services = new ServiceCollection();
        services.AddAccessTokenManagement(options => // configuring the access token managenment
        {
            options.Client.Clients.Add("demo", new ClientCredentialsTokenRequest
            {
                Address = "https://demo.duendesoftware.com/connect/token",
                ClientId = "m2m",
                ClientSecret = "secret"
            });
        }).ConfigureBackchannelHttpClient();

        services.AddGossipHttpClient<IOidcApi>()
            .AddClientAccessTokenHandler("demo"); // binding the access token handler to the http client used by Gossi4Net

        ServiceProvider sp = services.BuildServiceProvider();
        IOidcApi client = sp.GetRequiredService<IOidcApi>();

        // Act
        HttpResponseMessage result = client.Get();

        // Assert
        result.IsSuccessStatusCode.Should().BeTrue();
    }
}

Extensibility

The internals

Big picture

A request/response-cycle always follows the process outlined below.

actual paremeters are received
            |
            V
   create a plain request
            |
            V
   apply request modifiers
            |
            V
 obtain a HttpClient instance
            |
            V
       send request
            |
            V
  apply response modifiers
            |
            V
  apply response constructor
  (creates an instance of the 
    specified return type)
            |
            V
     return response

The entire behavior is therefore determined by the set of registered request and response modifiers. In order to deterine the behaviors to be applied, the HttpGossipBuilder<> uses different modifier registrations. These are responsible for registering intended behaviors based on certain criteria (e.g. an attribute is present).

    plain HttpGossipBuilder<> is createad
                    |
                    V
    registrations are added to the builder
                    |
                    V
        .Build() method is invoked
                    |
                    V
    the builder scans the API interface
                    |
                    V
    registrations are invoked for each
        present interface member
                    |
                    V
    if applicable, behaviors are returned
        by the invoked registration
                    |
                    V
HttpGossipBuilder adds the returned behavior
        to the request/response cycle
                    |
                    V
 instance of the API interface is returned
Registrations

As outlined above, a registration is responsible for adding specific behaviors based on the API interface to be implemented.

The interface to be implemented in order to register request modifiers, looks like that:

public interface IRequestModifierRegistration
{
    IList<IHttpRequestModifier>? ForParameter(RequestParameterContext parameterContext, IEnumerable<Attribute> attributes);
    IList<IHttpRequestModifier>? ForMethod(RequestMethodContext methodContext, IEnumerable<Attribute> attributes);
    IList<IHttpRequestModifier>? ForType(RequestTypeContext typeContext, IEnumerable<Attribute> attributes);
}

During the "implementation phase", the ForType, ForMethod and ForParameter methods are invoked by the builder. This is done for the interface type itself, each declared method and for each of their parameters. The registration's implementation can now decide, if the discovered member (or one of its attributes) justifies new behaviors to be applied. If so, a list of them are returned.

The IResponseModiferRegistration and IResponseConstructorRegistration are following the same general principle.

If a registration only intends to add behaviors based on an attribute of a given type, the RequestModifierRegistration<TAttribute> class can be inherited.

Request modifiers

A request modifier is responsible for manipulating a request before it gets send. Note that it is only invoked during the request/response cycle following a call to an API interface method.

public interface IHttpRequestModifier
{
    Task<HttpRequestMessage> ApplyAsync(HttpRequestMessage requestMessage, object?[] args);
}

It receivs the request to be modified as well as the actual parameters that have been passed into the interface method. After the relevant modifications are done, it must return the manipulated request message.

Response modifiers

A response modifier is responsible for manipulating a received response, before they are converted in the API interface method's return type.

public interface IHttpResponseModifier
{
    HttpResponseMessage Modify(HttpResponseMessage response);
}

Response constructors

A response constructor is the last instance to be invoked, before returning the final result to the caller. It is responsible for converting the manipulated http response into an object of the return type declared on the API's method.

public interface IResponseConstructor
{
    public Task<ConstructedResponse> ConstructResponseAsync(HttpResponseMessage response);
}

If the implementation has been able to process the response, it should return ConstructuredResponse.Of(...). If it has ben unable, it should usually return ConstructedResponse.Empty instead of throwing an exception (e.g. the JsonResponseConstructor receiving an "application/xml" response).

An exception should only be thrown, if it should have definitely been able to process it (e.g. if the JsonResponseConstructor receivs an "application/json" response, but the body contains syntax errors).

Example (adding XML support)

In this example we will add support for XML request and response bodies. This feature is actually availble with Gossip4Net.Http.Xml, but you could also implement it yourself using the steps outlined below.

Creating a project and adding dependencies

At first, we need to make sure to install all required dependencies. If you want to separate Gossip4Net extensions from your "regular" code, you might as well create a new project within your solution.

In this this example, we are creating a new C# library project called Gossip4Net.Http.Xml and target .NET 6.

Next we need to be sure to install the following NuGet packages:

  • System.Runtime.Serialization.Xml (providing XML serialization)
  • Gossip4Net.Http
Creating a custom ResponseConstructor

The component that converts a http response into a model type is called "ResponseConstructor". In our example, it should check if the response contains XML and then deserialize it into the requested return type.

  1. Create a new class and implement the IResponseConstructorInterface
using Gossip4Net.Http.Modifier.Response;

namespace Gossip4Net.Http.Xml.Modifier.Response
{
    internal class XmlResponseConstructor : IResponseConstructor
    {
        public async Task<ConstructedResponse> ConstructResponseAsync(HttpResponseMessage response)
        {
            throw new NotImplementedException();
        }
    }
}

  1. Next we are going to check if the received response contains XML by checking the content type
using Gossip4Net.Http.Modifier.Response;

namespace Gossip4Net.Http.Xml.Modifier.Response
{
    internal class XmlResponseConstructor : IResponseConstructor
    {
        private static readonly ISet<string> SupportedMediaTypes = new HashSet<string>()
        {
            "application/xml",
            "text/xml"
        };

        public async Task<ConstructedResponse> ConstructResponseAsync(HttpResponseMessage response)
        {
            string? mediaType = response.Content.Headers.ContentType?.MediaType;
            if (mediaType == null || !SupportedMediaTypes.Contains(mediaType))
            {
                
            }
        }
    }
}
  1. If the response doesn't contain XML, we give up by returning ConstructedResponse.Empty.
if (mediaType == null || !SupportedMediaTypes.Contains(mediaType))
{
    return ConstructedResponse.Empty;
}
  1. If it does, we will use a XmlSerializer to deserialize it. As we do not want to create a XmlSerializer everytime a request is executed, we inject an apropriate serializer using the constructor.
using Gossip4Net.Http.Modifier.Response;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Modifier.Response
{
    internal class XmlResponseConstructor : IResponseConstructor
    {
        private readonly XmlSerializer xmlSerializer;

        public XmlResponseConstructor(XmlSerializer xmlSerializer)
        {
            this.xmlSerializer = xmlSerializer;
        }

        private static readonly ISet<string> SupportedMediaTypes = new HashSet<string>()
        {
            "application/xml",
            "text/xml"
        };

        public async Task<ConstructedResponse> ConstructResponseAsync(HttpResponseMessage response)
        {
            string? mediaType = response.Content.Headers.ContentType?.MediaType;
            if (mediaType == null || !SupportedMediaTypes.Contains(mediaType))
            {
                return ConstructedResponse.Empty;
            }
            object? responseObject = xmlSerializer.Deserialize(await response.Content.ReadAsStreamAsync());
        }
    }
}

  1. Once we deserialized the object, we can return it by wrapping it into a ConstructedResponse.
return new ConstructedResponse(responseObject);
  1. The final implementation should now look like this:
using Gossip4Net.Http.Modifier.Response;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Modifier.Response
{
    internal class XmlResponseConstructor : IResponseConstructor
    {
        private readonly XmlSerializer xmlSerializer;

        public XmlResponseConstructor(XmlSerializer xmlSerializer)
        {
            this.xmlSerializer = xmlSerializer;
        }

        private static readonly ISet<string> SupportedMediaTypes = new HashSet<string>()
        {
            "application/xml",
            "text/xml"
        };

        public async Task<ConstructedResponse> ConstructResponseAsync(HttpResponseMessage response)
        {
            string? mediaType = response.Content.Headers.ContentType?.MediaType;
            if (mediaType == null || !SupportedMediaTypes.Contains(mediaType))
            {
                return ConstructedResponse.Empty;
            }
            object? responseObject = xmlSerializer.Deserialize(await response.Content.ReadAsStreamAsync());
            return new ConstructedResponse(responseObject);
        }
    }
}
Creating a response constructor registration
  1. In order to use our new XmlResponseConstructor, we need to be able to inject it into the request/response process. To do so, we create an implementation of IResponseConstructorRegistration.
using Gossip4Net.Http.Builder.Response;
using Gossip4Net.Http.Modifier.Response;
using Gossip4Net.Http.Modifier.Response.Registration;

namespace Gossip4Net.Http.Xml.Modifier.Response.Registration
{
    public class XmlResponseConstructorRegistration : IResponseConstructorRegistration
    {
        public IList<IResponseConstructor>? ForMethod(ResponseMethodContext responseMethod)
        {
            throw new NotImplementedException();
        }
    }
}
  1. As methods returning void or Task don't need to process the response body at all, we don't want to return a response constructor in these cases. Instead we can simply return null (or an empty list).
public IList<IResponseConstructor>? ForMethod(ResponseMethodContext responseMethod)
{
    if (responseMethod.IsVoid)
    {
        return null;
    }
}
  1. Otherwise we create a new XmlSerializer instance, capable of serializing the returned type.
XmlSerializer xmlSerializer = new XmlSerializer(responseMethod.ProxyReturnType);
  1. Now we can create a new instance of XmlResponseConstructor and return it. The implementation should now look like this:
using Gossip4Net.Http.Builder.Response;
using Gossip4Net.Http.Modifier.Response;
using Gossip4Net.Http.Modifier.Response.Registration;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Modifier.Response.Registration
{
    public class XmlResponseConstructorRegistration : IResponseConstructorRegistration
    {
        public IList<IResponseConstructor>? ForMethod(ResponseMethodContext responseMethod)
        {
            if (responseMethod.IsVoid)
            {
                return null;
            }
            XmlSerializer xmlSerializer = new XmlSerializer(responseMethod.ProxyReturnType);
            return new List<IResponseConstructor>
            {
                new XmlResponseConstructor(xmlSerializer)
            };
        }
    }
}
Adding XML response support to the builder & testing it
  1. First we need to define our API interface and models
using FluentAssertions;
using Gossip4Net.Http.Attributes;
using Gossip4Net.Http.Attributes.Mappings;
using Gossip4Net.Http.Xml.Modifier.Response.Registration;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Test
{
    [XmlRoot("slideshow")]
    public record Slideshow([property: XmlAttribute("title")] string Title, [property: XmlAttribute("author")] string Author)
    {
        // Default constructor required by XmlSerializer
        public Slideshow() : this(string.Empty, string.Empty) { }
    }

    [HttpApi("https://httpbin.org")]
    public interface IXmlHttpBinClient {

        [GetMapping("/xml")]
        Task<Slideshow> GetSlideshow();
    }
}
  1. Next we create a test case and a builder targeting our API definition
using FluentAssertions;
using Gossip4Net.Http.Attributes;
using Gossip4Net.Http.Attributes.Mappings;
using Gossip4Net.Http.Xml.Modifier.Response.Registration;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Test
{
    public class GossipWithXmlTest
    {
        [Fact]
        public async Task XmlResponseBodiesShouldGetDeserialized()
        {
            // Arrange
            IHttpGossipBuilder<IXmlHttpBinClient> builder = new HttpGossipBuilder<IXmlHttpBinClient>();
        }
    }
}
  1. Now we need to add the XmlResponseConstructorRegistration.
// Arrange
IHttpGossipBuilder<IXmlHttpBinClient> builder = new HttpGossipBuilder<IXmlHttpBinClient>()
    .WithRegistrations(reg => reg.With(new XmlResponseConstructorRegistration()));
  1. Don't forget to add the default behavior (as the builder would instead lack any other "basic" feature).
// Arrange
IHttpGossipBuilder<IXmlHttpBinClient> builder = new HttpGossipBuilder<IXmlHttpBinClient>()
    .WithRegistrations(reg => reg.With(new XmlResponseConstructorRegistration()))
    .AddDefaultBehavior();
  1. Use the .Build() method to implement your API definition and test the call.
using FluentAssertions;
using Gossip4Net.Http.Attributes;
using Gossip4Net.Http.Attributes.Mappings;
using Gossip4Net.Http.Xml.Modifier.Response.Registration;
using System.Xml.Serialization;

namespace Gossip4Net.Http.Xml.Test
{
    public class GossipWithXmlTest
    {
        [Fact]
        public async Task XmlResponseBodiesShouldGetDeserialized()
        {
            // Arrange
            IHttpGossipBuilder<IXmlHttpBinClient> builder = new HttpGossipBuilder<IXmlHttpBinClient>()
                .WithRegistrations(reg => reg.With(new XmlResponseConstructorRegistration()))
                .AddDefaultBehavior();

            IXmlHttpBinClient client = builder.Build();

            // Act
            Slideshow slideshow = await client.GetSlideshow();

            // Assert
            slideshow.Title.Should().Be("Sample Slide Show");
            slideshow.Author.Should().Be("Yours Truly");
        }
    }
}

Congratulations - you've just implemented support for deserializing XML responses.

Testing

Testing a component that relies on the API is as easy as just implementing/mocking the API interface.

Assuming your API definition, model and service are looking like these:

namespace Demo
{
    public record ExampleResponse(
        IDictionary<string, string> Headers,
        string Origin,
        string Url,
        IDictionary<string, string> Args);

    [HttpApi("https://httpbin.org")]
    public interface IExampleApi
    {
        [GetMapping("/get")]
        Task<ExampleResponse> Get();
    }

    public class ExampleService
    {
        private readonly IExampleApi exampleApi;

        public ExampleService(IExampleApi exampleApi)
        {
            this.exampleApi = exampleApi;
        }

        public async Task<int> CountHeaders()
        {
            return (await exampleApi.Get()).Headers.Count;
        }
    }
}

Then your service test could look tike that (using Moq and FluentAssertions):

public class DemoMockTest
{
    [Fact]
    public async Task CountHeadersShouldReturnNumberOfReceivedHeaders()
    {
        // Arrange
        Mock<IExampleApi> apiMock = new Mock<IExampleApi>();
        apiMock.Setup(api => api.Get())
            .ReturnsAsync(new ExampleResponse(
                Headers: new Dictionary<string, string> { { "Content-Type", "Example" }, { "Foo", "Bar" } },
                Origin: "a string",
                Url: "a url",
                Args: new Dictionary<string, string>()
            ));
        
        IExampleApi exampleApi = apiMock.Object;
        ExampleService serviceUnderTest = new ExampleService(exampleApi);

        // Act
        int headerCount = await serviceUnderTest.CountHeaders();

        // Assert
        headerCount.Should().Be(2);
    }
}
Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.0.4 140 10/6/2022