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
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
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/dhttp://a.com/path1/+b/c/d=http://a.com/path1/b/c/dhttp://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/dhttp://a.com/path1+b/c/d=http://a.com/b/c/dhttp://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:
- Use HTTP PATCH request method;
- The request body is a JSON content describing multiple operations;
- 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}