High-performance and Highly Extensible Declarative HTTP Client Library - WebApiClientCore

High-performance and Highly Extensible Declarative HTTP Client Library - WebApiClientCore

The .NET Core version of WebApiClient.JIT/AOT, a high-performance and highly extensible declarative HTTP client library, particularly suitable for RESTful resource requests in microservices and for various malformed HTTP API requests.

Last updated 9/6/2023 12:26 PM
老九
29 min read
Category
.NET
Tags
.NET C# AOT Architecture Design Web API

WebApiClientCore

WebApiClient.JIT/AOT .NET Core version, a high-performance, highly extensible declarative HTTP client library, especially suitable for RESTful resource requests in microservices, as well as various malformed HTTP interface requests.

NuGet

Package Name Description NuGet
WebApiClientCore Core package NuGet
WebApiClientCore.Extensions.OAuths OAuth extension package NuGet
WebApiClientCore.Extensions.NewtonsoftJson Json.Net extension package NuGet
WebApiClientCore.Extensions.JsonRpc JsonRpc call extension package NuGet
WebApiClientCore.OpenApi.SourceGenerator dotnet tool that parses local or remote OpenApi documents to generate WebApiClientCore interface code NuGet

How to Use

[HttpHost("http://localhost:5000/")]
public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    Task<User> GetAsync(string id);

    [HttpPost("api/users")]
    Task<User> PostAsync([JsonContent] User user);
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpApi<IUserApi>();
}

public class MyService
{
    private readonly IUserApi userApi;
    public MyService(IUserApi userApi)
    {
        this.userApi = userApi;
    }
}

QQ Group Assistance

825135345

Please indicate WebApiClient when joining the group. Before asking questions, please carefully read the remaining documentation below to avoid consuming the author's unnecessary repetitive time.

Compile-Time Syntax Analysis

WebApiClientCore.Analyzers provides syntax analysis and hints during coding. If the declared interface inherits the empty method IHttpApi interface, the syntax analysis will take effect. It is recommended that developers enable this feature.

For example, the [Header] attribute can be declared at three places: Interface, Method, and Parameter, but the correct constructor must be used; otherwise, runtime exceptions will occur. With the syntax analysis feature, improper syntax will not be used when declaring interfaces.

/// <summary>
/// Remember to implement IHttpApi
/// </summary>
public interface IUserApi : IHttpApi
{
    ...
}

Interface Configuration & Options

Each interface's options correspond to HttpApiOptions, with the option name being the full name of the interface, which can also be obtained via the HttpApi.GetName() method.

Configuring via IHttpClientBuilder

services
    .AddHttpApi<IUserApi>()
    .ConfigureHttpApi(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi(o =>
    {
        // Non-standard time format that fits local conditions; some interfaces require it to be non-standard
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

Configuration file json:

{
  "IUserApi": {
    "HttpHost": "http://www.webappiclient.com/",
    "UseParameterPropertyValidate": false,
    "UseReturnValuePropertyValidate": false,
    "JsonSerializeOptions": {
      "IgnoreNullValues": true,
      "WriteIndented": false
    }
  }
}

Configuring via IServiceCollection

services
    .ConfigureHttpApi<IUserApi>(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi<IUserApi>(o =>
    {
        // Non-standard time format that fits local conditions; some interfaces require it to be non-standard
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

Data Validation

Parameter Value Validation

For parameter values, ValidationAttribute modifiers are supported to validate values.

public interface IUserApi
{
    [HttpGet("api/users/{email}")]
    Task<User> GetAsync([EmailAddress, Required] string email);
}

Parameter or Return Model Property Validation

public interface IUserApi
{
    [HttpPost("api/users")]
    Task<User> PostAsync([Required][XmlContent] User user);
}

public class User
{
    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Account { get; set; }

    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Password { get; set; }
}

Common Built-in Attributes

Built-in attributes are those provided within the framework; they can be used directly to meet various general scenarios. Developers can also write attributes that meet specific scenario needs and decorate them on interfaces, methods, or parameters.

Return Attributes

Attribute Name Description Remarks
RawReturnAttribute Handles primitive type return values Default active
JsonReturnAttribute Handles Json model return values Default active
XmlReturnAttribute Handles Xml model return values Default active

Common Action Attributes

Attribute Name Description Remarks
HttpHostAttribute Absolute full host domain for request service Lower priority than Options configuration
HttpGetAttribute Declares GET request method and path Supports null, absolute or relative paths
HttpPostAttribute Declares POST request method and path Supports null, absolute or relative paths
HttpPutAttribute Declares PUT request method and path Supports null, absolute or relative paths
HttpDeleteAttribute Declares DELETE request method and path Supports null, absolute or relative paths
HeaderAttribute Declares request headers Constant value
TimeoutAttribute Declares timeout Constant value
FormFieldAttribute Declares form field and value Constant key and value
FormDataTextAttribute Declares FormData form field and value Constant key and value

Common Parameter Attributes

Attribute Name Description Remarks
PathQueryAttribute Key-value pairs of parameter value as URL path or query parameter Default attribute for parameters without explicit attribute
FormContentAttribute Key-value pairs of parameter value as x-www-form-urlencoded form
FormDataContentAttribute Key-value pairs of parameter value as multipart/form-data form
JsonContentAttribute Serializes parameter value as request JSON content
XmlContentAttribute Serializes parameter value as request XML content
UriAttribute Parameter value as request URI Only the first parameter can be modified
ParameterAttribute Aggregated request parameter declaration Does not support fine-grained configuration
HeaderAttribute Parameter value as request header
TimeoutAttribute Parameter value as timeout Value cannot exceed HttpClient's Timeout property
FormFieldAttribute Parameter value as Form form field and value Only supports simple type parameters
FormDataTextAttribute Parameter value as FormData form field and value Only supports simple type parameters

Filter Attributes

Attribute Name Description Remarks
ApiFilterAttribute Abstract class for Filter attributes
LoggingFilterAttribute Filter that outputs request and response content as logs

Self-Explanatory Parameter Types

Type Name Description Remarks
FormDataFile A file item in form-data No attribute needed; equivalent to FileInfo
JsonPatchDocument Represents a JsonPatch request document No attribute needed

URI Concatenation Rules

All URI concatenation is done through the constructor Uri(Uri baseUri, Uri relativeUri).

BaseUri ending with /

  • http://a.com/ + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/ + b/c/d = http://a.com/path1/b/c/d
  • http://a.com/path1/path2/ + b/c/d = http://a.com/path1/path2/b/c/d

BaseUri not ending with /

  • http://a.com + b/c/d = http://a.com/b/c/d
  • http://a.com/path1 + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/path2 + b/c/d = http://a.com/path1/b/c/d

In fact, http://a.com and http://a.com/ are exactly the same; their path is /, so they behave the same. To avoid low-level errors, please use the standard baseUri writing method, i.e., the first method ending with /.

Form Collection Handling

According to OpenApi, a collection in URI Query or Form supports five representation formats:

  • Csv // comma separated
  • Ssv // space separated
  • Tsv // backslash separated
  • Pipes // vertical bar separated
  • Multi // multiple key-value pairs with the same key

For a value like id = new string[]{"001","002"}, the results after processing with PathQueryAttribute and FormContentAttribute are:

CollectionFormat Data
[PathQuery(CollectionFormat = CollectionFormat.Csv)] id=001,002
[PathQuery(CollectionFormat = CollectionFormat.Ssv)] id=001 002
[PathQuery(CollectionFormat = CollectionFormat.Tsv)] id=001\002
[PathQuery(CollectionFormat = CollectionFormat.Pipes)] `id=001
[PathQuery(CollectionFormat = CollectionFormat.Multi)] id=001&id=002

CancellationToken Parameter

Each interface supports declaring one or more parameters of type CancellationToken to support request cancellation operations. CancellationToken.None means never cancel. Creating a CancellationTokenSource can provide a CancellationToken.

[HttpGet("api/users/{id}")]
ITask<User> GetAsync([Required]string id, CancellationToken token = default);

ContentType CharSet

For non-form body content, the default or fallback charset value is UTF8 encoding, which can be adjusted according to server requirements.

Attribute ContentType
[JsonContent] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="utf-8")] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="unicode")] Content-Type: application/json; charset=utf-16

Accept ContentType

This controls what content format the client expects from the server, such as json or xml.

Default Configuration Value

The default configuration is [JsonReturn(0.01), XmlReturn(0.01)], which results in an Accept header value of: Accept: application/json; q=0.01, application/xml; q=0.01

Json Preferred

Explicitly declaring [JsonReturn] on an Interface or Method changes the Accept header to: Accept: application/json, application/xml; q=0.01

Disable json

Declaring [JsonReturn(Enable = false)] on an Interface or Method changes the request Accept header to: Accept: application/xml; q=0.01

Request and Response Logging

Declaring [LoggingFilter] on an Interface or Method will output request and response content to the LoggingFactory. To exclude a specific Method from logging, declare [LoggingFilter(Enable = false)] on that Method.

Default Logging

[LoggingFilter]
public interface IUserApi
{
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);

    // Disable logging
    [LoggingFilter(Enable =false)]
    [HttpPost("api/users/body")]
    Task<User> PostByJsonAsync([Required, JsonContent]User user, CancellationToken token = default);
}

Custom Log Output Target

class MyLoggingAttribute : LoggingFilterAttribute
{
    protected override Task WriteLogAsync(ApiResponseContext context, LogMessage logMessage)
    {
        xxlogger.Log(logMessage.ToIndentedString(spaceCount: 4));
        return Task.CompletedTask;
    }
}

[MyLogging]
public interface IUserApi
{
}

Primitive Type Return Values

When the interface return type is declared as one of the following, it is called a primitive type and will be processed by RawReturnAttribute.

Return Type Description
Task Does not care about response message
Task<HttpResponseMessage> Raw response message type
Task<Stream> Raw response stream
Task<byte[]> Raw response binary data
Task<string> Raw response message text

Interface Declaration Examples

Petstore Interface

This OpenApi document is from petstore.swagger.io, and the code was generated by reverse engineering its OpenApi document using the WebApiClientCore.OpenApi.SourceGenerator tool.

/// <summary>
/// Everything about your Pets
/// </summary>
[LoggingFilter]
[HttpHost("https://petstore.swagger.io/v2/")]
public interface IPetApi : IHttpApi
{
    /// <summary>
    /// Add a new pet to the store
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet")]
    Task AddPetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Update an existing pet
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPut("pet")]
    Task UpdatePetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by status
    /// </summary>
    /// <param name="status">Status values that need to be considered for filter</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/findByStatus")]
    ITask<List<Pet>> FindPetsByStatusAsync([Required] IEnumerable<Anonymous> status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by tags
    /// </summary>
    /// <param name="tags">Tags to filter by</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [Obsolete]
    [HttpGet("pet/findByTags")]
    ITask<List<Pet>> FindPetsByTagsAsync([Required] IEnumerable<string> tags, CancellationToken cancellationToken = default);

    /// <summary>
    /// Find pet by ID
    /// </summary>
    /// <param name="petId">ID of pet to return</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/{petId}")]
    ITask<Pet> GetPetByIdAsync([Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates a pet in the store with form data
    /// </summary>
    /// <param name="petId">ID of pet that needs to be updated</param>
    /// <param name="name">Updated name of the pet</param>
    /// <param name="status">Updated status of the pet</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet/{petId}")]
    Task UpdatePetWithFormAsync([Required] long petId, [FormField] string name, [FormField] string status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes a pet
    /// </summary>
    /// <param name="api_key"></param>
    /// <param name="petId">Pet id to delete</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpDelete("pet/{petId}")]
    Task DeletePetAsync([Header("api_key")] string api_key, [Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// uploads an image
    /// </summary>
    /// <param name="petId">ID of pet to update</param>
    /// <param name="additionalMetadata">Additional data to pass to server</param>
    /// <param name="file">file to upload</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpPost("pet/{petId}/uploadImage")]
    ITask<ApiResponse> UploadFileAsync([Required] long petId, [FormDataText] string additionalMetadata, FormDataFile file, CancellationToken cancellationToken = default);
}

IOAuthClient Interface

This interface is declared in the WebApiClientCore.Extensions.OAuths.IOAuthClient.cs code.

using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using WebApiClientCore.Attributes;

namespace WebApiClientCore.Extensions.OAuths
{
    /// <summary>
    /// Defines the interface for Token client
    /// </summary>
    [LoggingFilter]
    [XmlReturn(Enable = false)]
    [JsonReturn(EnsureMatchAcceptContentType = false, EnsureSuccessStatusCode = false)]
    public interface IOAuthClient
    {
        /// <summary>
        /// Obtains token using client_credentials grant type
        /// </summary>
        /// <param name="endpoint">token request url</param>
        /// <param name="credentials">credentials</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "client_credentials")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] ClientCredentials credentials);

        /// <summary>
        /// Obtains token using password grant type
        /// </summary>
        /// <param name="endpoint">token request url</param>
        /// <param name="credentials">credentials</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "password")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] PasswordCredentials credentials);

        /// <summary>
        /// Refreshes token
        /// </summary>
        /// <param name="endpoint">token request url</param>
        /// <param name="credentials">credentials</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "refresh_token")]
        Task<TokenResult> RefreshTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] RefreshTokenCredentials credentials);
    }
}

Conditional Request Retry

Using ITask<> asynchronous declaration allows the Retry extension. The retry condition can be catching a certain Exception or the response model meeting a certain condition.

public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    ITask<User> GetAsync(string id);
}

var result = await userApi.GetAsync(id: "id001")
    .Retry(maxCount: 3)
    .WhenCatch<HttpRequestException>()
    .WhenResult(r => r.Age <= 0);

Exceptions and Exception Handling

When requesting an interface, no matter what exception occurs, it will eventually throw an HttpRequestException. The inner exception of HttpRequestException is the actual specific exception. This design is to preserve the inner exception's stack trace intact.

Many internal exceptions in WebApiClient are based on the abstract ApiException, meaning in many cases, the thrown exception is an HttpRequestException with an inner ApiException.

try
{
    var model = await api.GetAsync();
}
catch (HttpRequestException ex) when (ex.InnerException is ApiInvalidConfigException configException)
{
    // Request configuration exception
}
catch (HttpRequestException ex) when (ex.InnerException is ApiResponseStatusException statusException)
{
    // Response status code exception
}
catch (HttpRequestException ex) when (ex.InnerException is ApiException apiException)
{
    // Abstract api exception
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException socketException)
{
    // Socket connection layer exception
}
catch (HttpRequestException ex)
{
    // Request exception
}
catch (Exception ex)
{
    // Exception
}

PATCH Requests

Json patch is a standard interaction designed for clients to partially update existing resources on the server. It is described in detail in RFC6902. In layman's terms, the key points are:

  1. Use HTTP PATCH request method;
  2. The request body is a JSON content describing multiple operations;
  3. The request Content-Type is application/json-patch+json.

Declaring a Patch Method

public interface IUserApi
{
    [HttpPatch("api/users/{id}")]
    Task<UserInfo> PatchAsync(string id, JsonPatchDocument<User> doc);
}

Instantiating JsonPatchDocument

var doc = new JsonPatchDocument<User>();
doc.Replace(item => item.Account, "laojiu");
doc.Replace(item => item.Email, "laojiu@qq.com");

Request Content

PATCH /api/users/id001 HTTP/1.1
Host: localhost:6000
User-Agent: WebApiClientCore/1.0.0.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-patch+json

[{"op":"replace","path":"/account","value":"laojiu"},{"op":"replace","path":"/email","value":"laojiu@qq.com"}]

Response Content Caching

Methods configured with the CacheAttribute will cache the current response content. If the expected conditions are met next time, the request will not go to the remote server but will retrieve the cached content from IResponseCacheProvider. Developers can implement their own ResponseCacheProvider.

Declaring the Cache Attribute

public interface IUserApi
{
    // Cache for one minute
    [Cache(60 * 1000)]
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);
}

Default cache conditions: URL (e.g., http://abc.com/a) and specified request headers must match. If you need functionality like [CacheByPath], you can directly inherit ApiCacheAttribute to implement it:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class CacheByAbsolutePathAttribute : ApiCacheAttribute
{
    public CacheByPathAttribute(double expiration) : base(expiration)
    {
    }

    public override Task<string> GetCacheKeyAsync(ApiRequestContext context)
    {
        return Task.FromResult(context.HttpContext.RequestMessage.RequestUri.AbsolutePath);
    }
}

Custom Cache Provider

The default cache provider is in-memory cache. If you want to save the cache elsewhere, you need to implement a custom cache provider and register it to replace the default one.

public class RedisResponseCacheProvider : IResponseCacheProvider
{
    public string Name => nameof(RedisResponseCacheProvider);

    public Task<ResponseCacheResult> GetAsync(string key)
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(string key, ResponseCacheEntry entry, TimeSpan expiration)
    {
        throw new NotImplementedException();
    }
}

// Register RedisResponseCacheProvider
var services = new ServiceCollection();
services.AddSingleton<IResponseCacheProvider, RedisResponseCacheProvider>();

Non-Model Requests

Sometimes we don't necessarily need a strong model. For example, if we already have raw form text content, raw JSON text content, or even a System.Net.Http.HttpContent object, we just need to send these raw contents to the remote server.

Raw Text

[HttpPost]
Task PostAsync([RawStringContent("txt/plain")] string text);

[HttpPost]
Task PostAsync(StringContent text);

Raw JSON

[HttpPost]
Task PostAsync([RawJsonContent] string json);

Raw XML

[HttpPost]
Task PostAsync([RawXmlContent] string xml);

Raw Form Content

[HttpPost]
Task PostAsync([RawFormContent] string form);

Custom Self-Explanatory Parameter Types

In certain edge cases, such as a face comparison API, the input model may not match the transmission model. For example:

Server-required JSON model

{
  "image1": "base64 of image1",
  "image2": "base64 of image2"
}

Client-desired business model

class FaceModel
{
    public Bitmap Image1 {get; set;}
    public Bitmap Image2 {get; set;}
}

We want to pass Bitmap objects when constructing the model instance, but during transmission, they become base64 values. So we need to modify FaceModel to implement the IApiParameter interface:

class FaceModel : IApiParameter
{
    public Bitmap Image1 { get; set; }

    public Bitmap Image2 { get; set; }


    public Task OnRequestAsync(ApiParameterContext context)
    {
        var image1 = GetImageBase64(this.Image1);
        var image2 = GetImageBase64(this.Image2);
        var model = new { image1, image2 };

        var jsonContent = new JsonContent();
        context.HttpContext.RequestMessage.Content = jsonContent;

        var options = context.HttpContext.HttpApiOptions.JsonSerializeOptions;
        var serializer = context.HttpContext.ServiceProvider.GetJsonSerializer();
        serializer.Serialize(jsonContent, model, options);
    }

    private static string GetImageBase64(Bitmap image)
    {
        using var stream = new MemoryStream();
        image.Save(stream, System.Drawing.Imaging.ImageFormat.Jpeg);
        return Convert.ToBase64String(stream.ToArray());
    }
}

Finally, we use the improved FaceModel to make requests:

public interface IFaceApi
{
    [HttpPost("/somePath")]
    Task<HttpResponseMessage> PostAsync(FaceModel faces);
}

Custom Request Content and Response Content Parsing

Besides common XML or JSON response content deserialization into strongly typed result models, you may encounter other binary protocol response contents, such as Google's ProtoBuf binary content.

1 Writing Custom Attributes

Custom Request Content Processing Attribute
public class ProtobufContentAttribute : HttpContentAttribute
{
    public string ContentType { get; set; } = "application/x-protobuf";

    protected override Task SetHttpContentAsync(ApiParameterContext context)
    {
        var stream = new MemoryStream();
        if (context.ParameterValue != null)
        {
            Serializer.NonGeneric.Serialize(stream, context.ParameterValue);
            stream.Position = 0L;
        }

        var content = new StreamContent(stream);
        content.Headers.ContentType = new MediaTypeHeaderValue(this.ContentType);
        context.HttpContext.RequestMessage.Content = content;
        return Task.CompletedTask;
    }
}
Custom Response Content Parsing Attribute
public class ProtobufReturnAttribute : ApiReturnAttribute
{
    public ProtobufReturnAttribute(string acceptContentType = "application/x-protobuf")
        : base(new MediaTypeWithQualityHeaderValue(acceptContentType))
    {
    }

    public override async Task SetResultAsync(ApiResponseContext context)
    {
        if (context.ApiAction.Return.DataType.IsRawType == false)
        {
            var stream = await context.HttpContext.ResponseMessage.Content.ReadAsStreamAsync();
            context.Result = Serializer.NonGeneric.Deserialize(context.ApiAction.Return.DataType.Type, stream);
        }
    }
}

2 Applying Custom Attributes

[ProtobufReturn]
public interface IProtobufApi
{
    [HttpPut("/users/{id}")]
    Task<User> UpdateAsync([Required, PathQuery] string id, [ProtobufContent] User user);
}

Adapting to Malformed Interfaces

In real-world scenarios, we often encounter malformed interfaces that were designed before the RESTful concept. We need to analyze these interfaces and wrap them into friendly client call interfaces.

Unfriendly Parameter Name Aliases

For example, the server requires a Query parameter named field-Name, which is not allowed in C# keywords or variable naming. Use [AliasAsAttribute] to achieve this:

public interface IDeformedApi
{
    [HttpGet("api/users")]
    ITask<string> GetAsync([AliasAs("field-Name")] string fieldName);
}

The final request URI becomes api/users/?field-name=fieldNameValue.

Form Field with JSON Text

Field Value
field1 someValue
field2

The corresponding strongly typed model is:

class Field2
{
    public string Name {get; set;}

    public int Age {get; set;}
}

Normally, we would serialize the field2 instance to JSON text and assign it to the field2 string property. Using the [JsonFormField] attribute automatically handles the JSON serialization of the Field2 type and uses the resulting string as a form field.

public interface IDeformedApi
{
    Task PostAsync([FormField] string field1, [JsonFormField] Field2 field2)
}

Form Submission with Nested Models

Field Value
field1 someValue
field2.name sb
field2.age 18

The corresponding JSON format is:

{
  "field1": "someValue",
  "field2": {
    "name": "sb",
    "age": 18
  }
}

Under normal circumstances, for complex nested data models, application/json should be used, but the interface requires Form submission. We can configure KeyValueSerializeOptions to meet this format requirement:

services.AddHttpApi<
  IDeformedApi>
  ((o) => {
    o.KeyValueSerializeOptions.KeyNamingStyle = KeyNamingStyle.FullName;
  });

Response Without ContentType

The response content looks like JSON, but the server response header does not include ContentType telling the client that the content is JSON. It's like a client not specifying the content format in the request header when using Form or JSON submission and letting the server guess.

The solution is to declare the [JsonReturn] attribute on the Interface or Method and set its EnsureMatchAcceptContentType property to false, indicating that even if the ContentType does not match the expected value, it should still be processed.

[JsonReturn(EnsureMatchAcceptContentType = false)]
public interface IDeformedApi
{
}

Class Signature Parameter or ApiKey Parameter

For example, each request URL dynamically adds an extra parameter called sign, which may be related to the request parameter values and needs to be calculated each time.

We can customize ApiFilterAttribute to implement our own sign functionality, then declare the custom Filter on the Interface or Method.

class SignFilterAttribute : ApiFilterAttribute
{
    public override Task OnRequestAsync(ApiRequestContext context)
    {
        var signService = context.HttpContext.ServiceProvider.GetService<SignService>();
        var sign = signService.SignValue(DateTime.Now);
        context.HttpContext.RequestMessage.AddUrlQuery("sign", sign);
        return Task.CompletedTask;
    }
}

[SignFilter]
public interface IDeformedApi
{
    ...
}

HttpMessageHandler Configuration

HTTP Proxy Configuration

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        UseProxy = true,
        Proxy = new WebProxy
        {
            Address = new Uri("http://proxy.com"),
            Credentials = new NetworkCredential
            {
                UserName = "useranme",
                Password = "pasword"
            }
        }
    });

Client Certificate Configuration

Some servers enable HTTPS mutual authentication to restrict client connections, allowing only clients that hold their issued certificate to connect.

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.ClientCertificates.Add(yourCert);
        return handler;
    });

Maintaining CookieContainer

If the requested interface unfortunately uses Cookie to store authentication information, you need to ensure that the CookieContainer instance does not follow the HttpMessageHandler lifecycle. The default HttpMessageHandler has a minimum lifetime of only 2 minutes.

var cookieContainer = new CookieContainer();
services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.CookieContainer = cookieContainer;
        return handler;
    });

OAuths & Token

Using the WebApiClientCore.Extensions.OAuths extension, you can easily support token acquisition, refresh, and application.

Objects and Concepts

Object Purpose
ITokenProviderFactory Factory for creating token providers, provides creation of token provider via HttpApi interface type
ITokenProvider Token provider, used to obtain a token. On the first request after token expiration, it triggers a re-request or token refresh.
OAuthTokenAttribute Token application attribute. Uses ITokenProviderFactory to create ITokenProvider, then uses ITokenProvider to obtain a token, and finally applies the token to the request message.
OAuthTokenHandler An HTTP message handler, functions the same as OAuthTokenAttribute. Additionally, if the server unexpectedly returns unauthorized (401 status code), it discards the old token, requests a new one, and retries the request once.

OAuth Client Mode

1 Register TokenProvider for Interface
// Register and configure token provider for interface in Client mode
services.AddClientCredentialsTokenProvider<IUserApi>(o =>
{
    o.Endpoint = new Uri("http://localhost:6000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
});
2 Token Application
2.1 Using OAuthToken Attribute

OAuthTokenAttribute belongs to the WebApiClientCore framework layer, making it easy to manipulate request content and response models. For example, it can add the token as a form field to an existing request form, or read the response message after deserialization corresponding to a business model. However, it cannot achieve request retry internally. If the server's token is lost after issuance, using OAuthTokenAttribute will result in a failed request, which cannot be avoided.

/// <summary>
/// User operation interface
/// </summary>
[OAuthToken]
public interface IUserApi
{
    ...
}

The default implementation of OAuthTokenAttribute places the token in the Authorization request header. If your interface needs to put the token elsewhere, such as in the URI query, you need to override OAuthTokenAttribute:

class UriQueryTokenAttribute : OAuthTokenAttribute
{
    protected override void UseTokenResult(ApiRequestContext context, TokenResult tokenResult)
    {
        context.HttpContext.RequestMessage.AddUrlQuery("mytoken", tokenResult.Access_token);
    }
}

[UriQueryToken]
public interface IUserApi
{
    ...
}
2.2 Using OAuthTokenHandler

The strength of OAuthTokenHandler is that it supports multiple attempts within a single request. If the server's token is lost after issuance, OAuthTokenHandler will discard and re-request the token inside the request upon receiving a 401 status code, and retry the request with the new token, thus appearing as a normal request. However, OAuthTokenHandler does not belong to the WebApiClientCore framework layer; inside it, you can only access the raw HttpRequestMessage and HttpResponseMessage. If you need to append the token to the HttpRequestMessage's Content, it is very difficult. Similarly, if you do not use HTTP status codes (401, etc.) as the basis for token invalidity, but instead use a specific field in the business model corresponding to the HttpResponseMessage's Content, it is also very tricky.

// Add OAuthTokenHandler when registering the interface
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler();

The default implementation of OAuthTokenHandler places the token in the Authorization request header. If your interface needs to put the token elsewhere, such as in the URI query, you need to override OAuthTokenHandler:

class UriQueryOAuthTokenHandler : OAuthTokenHandler
{
    /// <summary>
    /// Token application HTTP message handler
    /// </summary>
    /// <param name="tokenProvider">token provider</param>
    public UriQueryOAuthTokenHandler(ITokenProvider tokenProvider)
        : base(tokenProvider)
    {
    }

    /// <summary>
    /// Apply token
    /// </summary>
    /// <param name="request"></param>
    /// <param name="tokenResult"></param>
    protected override void UseTokenResult(HttpRequestMessage request, TokenResult tokenResult)
    {
        var builder = new UriBuilder(request.RequestUri);
        builder.Query += "mytoken=" + Uri.EscapeDataString(tokenResult.Access_token);
        request.RequestUri = builder.Uri;
    }
}


// Add UriQueryOAuthTokenHandler when registering the interface
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler((s, tp) => new UriQueryOAuthTokenHandler(tp));

Token Provider Shared by Multiple Interfaces

You can set a base interface for HTTP interfaces, then configure a TokenProvider for the base interface. For example, the xxx and yyy interfaces below both belong to IBaidu, so you only need to configure a TokenProvider for IBaidu.

public interface IBaidu
{
}

[OAuthToken]
public interface IBaidu_XXX_Api : IBaidu
{
    [HttpGet]
    Task xxxAsync();
}

[OAuthToken]
public interface IBaidu_YYY_Api : IBaidu
{
    [HttpGet]
    Task yyyAsync();
}
// Register and configure token provider options for Password mode
services.AddPasswordCredentialsTokenProvider<IBaidu>(o =>
{
    o.Endpoint = new Uri("http://localhost:5000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
    o.Credentials.Username = "username";
    o.Credentials.Password = "password";
});

Custom TokenProvider

The extension package already includes two standard OAuth token request modes: Client and Password. However, many interface providers implement only the essence of these modes. In such cases, a custom TokenProvider is needed. Suppose the token acquisition interface from the provider is as follows:

public interface ITokenApi
{
    [HttpPost("http://xxx.com/token")]
    Task<TokenResult> RequestTokenAsync([Parameter(Kind.Form)] string clientId, [Parameter(Kind.Form)] string clientSecret);
}
Delegating TokenProvider

The delegating TokenProvider is the simplest implementation, where the token request delegate serves as the implementation logic of the custom TokenProvider:

// Register custom token provider for interface
services.AddTokeProvider<IUserApi>(s =>
{
    return s.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
});
Fully Implemented TokenProvider
// Register CustomTokenProvider for interface
services.AddTokeProvider<IUserApi, CustomTokenProvider>();
class CustomTokenProvider : TokenProvider
{
    public CustomTokenProvider(IServiceProvider serviceProvider)
        : base(serviceProvider)
    {
    }

    protected override Task<TokenResult> RequestTokenAsync(IServiceProvider serviceProvider)
    {
        return serviceProvider.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
    }

    protected override Task<TokenResult> RefreshTokenAsync(IServiceProvider serviceProvider, string refresh_token)
    {
        return this.RequestTokenAsync(serviceProvider);
    }
}
Custom TokenProvider Options

Every TokenProvider has a Name property, which has the same value as the Name of the ITokenProviderBuilder returned by service.AddTokeProvider(). You can read the Options value using the GetOptionsValue() method of TokenProvider, and configure Options through the Name of ITokenProviderBuilder.

NewtonsoftJson for JSON Processing

Undeniably, System.Text.Json will become more widely used due to its performance advantages, but NewtonsoftJson will not exit the stage either.

System.Text.Json is very strict by default, avoiding any guessing or interpretation on behalf of the caller, emphasizing deterministic behavior. The library is intentionally designed for performance and security. Newtonsoft.Json is very flexible by default, and with the default configuration, you will hardly encounter deserialization issues, although many issues are caused by careless JSON structures or type declarations.

Extension Package

The default core package does not include NewtonsoftJson functionality; you need to additionally reference the WebApiClientCore.Extensions.NewtonsoftJson extension package.

Configuration

// ConfigureNewtonsoftJson
services.AddHttpApi<IUserApi>().ConfigureNewtonsoftJson(o =>
{
    o.JsonSerializeOptions.NullValueHandling = NullValueHandling.Ignore;
});

Declaring Attributes

Use [JsonNetReturn] to replace the built-in [JsonReturn], and [JsonNetContent] to replace the built-in [JsonContent].

/// <summary>
/// User operation interface
/// </summary>
[JsonNetReturn]
public interface IUserApi
{
    [HttpPost("/users")]
    Task PostAsync([JsonNetContent] User user);
}

JsonRpc Call

In rare scenarios, developers may encounter JsonRpc call interfaces. Since this protocol is not very popular, WebApiClientCore provides support for this functionality as the WebApiClientCore.Extensions.JsonRpc extension package. Decorate Rpc methods with [JsonRpcMethod] and Rpc parameters with [JsonRpcParam].

JsonRpc Declaration

[HttpHost("http://localhost:5000/jsonrpc")]
public interface IUserApi
{
    [JsonRpcMethod("add")]
    ITask<JsonRpcResult<User>> AddAsync([JsonRpcParam] string name, [JsonRpcParam] int age, CancellationToken token = default);
}

JsonRpc Packet

POST /jsonrpc HTTP/1.1
Host: localhost:5000
User-Agent: WebApiClientCore/1.0.6.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-rpc

{"jsonrpc":"2.0","method":"add","params":["laojiu",18],"id":1}
Keep Exploring

Related Reading

More Articles
Same category / Same tag 2/25/2025

.NET 10 Preview 1 Released

Today .NET 10 Preview 1 was released. I downloaded it immediately, upgraded the Avalonia UI project and blog website. The former passed functional testing and AOT publishing successfully, the latter debugging went fine, but Docker has not been successful yet.

Continue Reading