Поддерживаемые платформы: .NET Core 3.1+
Ознакомьтесь с последними изменениями в журнале изменений.
MyLab.ApiClient
предоставляет возможность создавать клиенты для WEB API
на основе контрактов.
Чтобы описать WEB API
контракт, следует:
- объявить контракт сервиса как интерфейс
- пометить интерфейс атрибутом
ApiAttribute
- объявить асинхронные методы, которые будут соответствовать конечным точкам сервиса
- пометить соответствующими атрибутами (
ApiMethodAttribute
или наследниками) - указать у методов типы возвращаемых параметров в соответствии с содержанием, которое возвращает сервис
- указать у методов аргументы, соответствующие передаваемым в запросе данным
- пометить аргументы соответствующими атрибутами, указывающими на расположение и формат этих данных (наследники
ApiParameterAttribute
)
Описание контракта сервиса:
[Api("api")]
public interface IServiceContract
{
[Post("orders")]
Task<int> CreateOrder([JsonContent] Order order);
}
Описание контракта данных (не требует дополнительной разметки):
public class Order
{
public string Foo { get; set; }
}
Контроллер сервера:
[ApiController]
[Route("api")]
public class OrderController : ControllerBase
{
[HttpPost("orders")]
public IActionResult CreateOrder([FromBody]Order order)
{
//...
return Ok(newOrderId);
}
}
Использование:
HttpClient httpClient = ...
var s = ApiClient<ITestServer>.Create(new SingleHttpClientProvider(httpClient));
var order = new Order{ Foo ="bar" }
int newOrderId = await _client.Request(s => s.CreateOrder(order)).GetResultAsync();
Чтобы начать описание сервиса, объявите его контракт в виде интерфейса.
Используйте ApiAttribute
чтобы отметить интерфейс-контракт сервиса:
[Api]
public interface IService
{
//...
}
В этом атрибуте можно указать базовый путь к сервису, который будет использоваться как базовый для формирования полного адреса запроса с учётом относительных путей конечных точек (методов):
[Api("orders/v1")]
public interface IService
{
//...
}
Все методы контракта API
должны быть асинхронными, т.е. возвращать Task
или Task<>
.
Метод контракта должен быть помечен атрибутом ApiMethodAttribute
или его наследником. Здесь определяется относительный путь и HTTP
-метод. Также у ApiMethodAttribute
есть ряд наследников для основных случаев:
[Api]
public interface IService
{
[ApiMethod("orders", HttpMethod.Get)]
Task GetOrders1();
[Get("orders")]
Task GetOrders2();
[Get]
Task GetOrders3();
[Post]
Task PostOrders();
[Put]
Task PutOrders();
[Head]
Task HeadOrders();
[Delete]
Task DeleteOrders();
}
Аргументы метода определяют данные передаваемые в запросе. Для определения места расположения и формата передаваемых данных, используйте наследников атрибута ApiParameterAttribute
.
Аргумент - часть пути
[Api("company-services/api")]
public interface IService
{
[Get("orders/{id}")]
Task Get([Path]string id);
}
Вызов:
await srv.Get("2");
Результирующий запрос:
GET /company-services/api/orders/2
Аргумент - часть запроса в URL.
[Api("company-services/api")]
public interface IService
{
[Get("orders")]
Task Get([Query]string id);
}
Вызов:
await srv.Get("2");
Результирующий запрос:
GET /company-services/api/orders?id=2
Аргумент - заголовок
[Api("company-services/api")]
public interface IService
{
[Get("orders")]
Task Get([Header("X-Identifier")]string id);
}
Вызов:
await srv.Get("2");
Результирующий запрос:
GET /company-services/api/orders
Headers:
X-Identifier: 2
Аргумент - произвольный список заголовков. Тип параметра должен реализовывать интерфейс IEnumerable<KeyValuePair<string, object>>
;
[Api("company-services/api")]
public interface IService
{
[Get("orders")]
Task Get([HeaderCollection] Dictionary<string, object> headers);
}
Вызов:
var headers = new Dictionary<string, object>
{
{"X-Header-1", "foo"},
{"X-Header-2", "bar"}
}
await srv.Get(headers);
Результирующий запрос:
GET /company-services/api/orders
X-Header-1: foo
X-Header-2: bar
Аргумент - содержательная часть запроса в строковой форме
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([StringContent] int orderId);
}
Вызов:
await srv.Create(2);
Результирующий запрос:
POST /company-services/api/orders
X-Header-1: foo
X-Header-2: bar
Content-Type: text/plain
2
Аргумент - содержательная часть запроса в формате JSON
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([JsonContent] Order order);
}
public class Order
{
public string Id { get; set; }
}
Вызов:
var order = new Order
{
Id = "2"
}
await srv.Create(order);
Результирующий запрос:
POST /company-services/api/orders
Content-Type: application/json
{"Id":"2"}
Аргумент - содержательная часть запроса в формате XML
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([XmlContent] Order order);
}
public class Order
{
public string Id { get; set; }
}
Вызов:
var order = new Order
{
Id = "2"
}
await srv.Create(order);
Результирующий запрос:
POST /company-services/api/orders
Content-Type: application/xml
<Order><Id>2</Id></Order>
Аргумент - содержательная часть запроса в формат URL encoded form
. Для переопределния имён элементов формы, используйте UrlFormItemAttribute
на свойствах объекта формы.
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([FormContent] Order order);
}
public class Order
{
public string Id { get; set; }
[UrlFormItem(Name = "order_number")]
public string Number { get; set; }
}
Вызов:
var order = new Order
{
Id = "2",
Number = "foo"
}
await srv.Create(order);
Результирующий запрос:
POST /company-services/api/orders
Content-Type: application/x-www-form-urlencoded
Id=2&order_number=foo
Аргумент - содержательная часть запроса в бинарном формате
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([BinContent] byte[] orderData);
}
Вызов:
var bin = Encoding.UTF8.GetBytes("foo")
await srv.Create(bin);
Результирующий запрос:
POST /company-services/api/orders
Content-Type: application/octet-stream
foo
Аргумент - содержательная часть запроса в формате multipart-form
. Параметр должен реализовывать интерфейс IMultipartContentParameter
.
[Api("company-services/api")]
public interface IService
{
[Post("orders")]
Task Create([MultipartContent] TestMultipartParameter p);
}
public class TestMultipartParameter : IMultipartContentParameter
{
public string Part1 { get; set; }
public string Part2 { get; set; }
public void AddParts(MultipartFormDataContent content)
{
content.Add(new StringContent(Part1), "part1");
content.Add(new StringContent(Part2), "part2");
}
}
Вызов:
var p = new TestMultipartParameter{ Part1 = "fo", Part2 = "o"}
await srv.Create(p);
Результирующий запрос:
POST /company-services/api/orders
Content-Type: multipart/form-data; boundary="2150a4df-de36-421a-8ef7-028f86f90403"
--2150a4df-de36-421a-8ef7-028f86f90403
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name=part1
fo
--2150a4df-de36-421a-8ef7-028f86f90403
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name=part2
o
--2150a4df-de36-421a-8ef7-028f86f90403--
WEB API
может вернуть как успешный ответ, так и ответ с шибкой. Положительным ответом считаются ответы со статус-кодом 2xx
, а 4xx
и 5xx
- ошибочными. (3xx
при разработке API обычно не используются)
Часто при проектировании WEB API
ответы 2хх, как и 4хх наделяют особым смыслом. Поэтому важно проверять, что статус-код входит в определённое подмножество установленных возможных статус-кодов.
Для этого в MyLab.ApiCLient
есть атрибут ExpectedCodeAttribute
. Отметьте на целевом методе статус-коды, которые ожидаются в ответ на вызов сервера:
[Api]
public interface IService
{
[ExpectedCode(HttpStatusCode.BadRequest)]
[Get("orders/count")]
Task<int> GetOrdersCount();
}
Алгоритм проверки статус-кода выглядит следующим образом:
- если код == 200 - успех
- если код есть в списке, определённом атрибутами
ExpectedCodeAttribute
- успех - ошибка
ResponseCodeException
Тип содержания определяется типом возвращаемым значением соответствующего метода. Поддерживаются следующие типы:
void
- если важен только статус-код ответа- примитивы:
string
,bool
,int
,uint
,double
- типы значений:
DateTime
,TimeSpan
,Guid
- объекты/структуры: только если содержательная часть ответа в формате
XML
,JSON
илиurl-encoded-form
В случае, если содержательная часть ответа отсутствует, метод будет возвращать значения по умолчанию:
null
для ссылочных типов;default()
- для типов значений.
На следующем примере показан вызов сервиса с получением результата:
[Api]
public interface IService
{
[Post("orders")]
Task<int> CreateOrder(Order order);
}
//....
var orderId = await service.Request(s => s.CreateOrder(order)).GetResultAsync();
Вызов сервиса без получения результата:
[Api]
public interface IService
{
[Post("orders")]
Task CreateOrder(Order order);
}
//....
await service.Call(s => s.CreateOrder(order)).CallAsync();
При получении непредвиденного статус-кода, кроме 200 (OK)
, метод GetResultAsync
выдаёт исключение ResponseCodeException
. Это можно использовать следующим образом:
try
{
await service.Request(s => s.CreateOrder(order)).GetResultAsync();
}
catch(ResponseCodeException e) when (e.StatusCode == HttpStatusCode.BadRequest)
{
//when status code = 400
}
catch(ResponseCodeException e) when (e.StatusCode == HttpStatusCode.Forbidden)
{
//when status code = 403
}
Детализация по вызову представляет собой объект, содержащий всё необходимое для составления представления о выполненном запросе и полученном ответе:
/// <summary>
/// Contains detailed service call information with response
/// </summary>
public class CallDetails<T> : CallDetails
{
/// <summary>
/// Expected response content
/// </summary>
public T ResponseContent { get; set; }
}
/// <summary>
/// Contains detailed service call information
/// </summary>
public class CallDetails
{
/// <summary>
/// HTTP status code
/// </summary>
public HttpStatusCode StatusCode { get; set; }
/// <summary>
/// Gets true if status code is unexpected
/// </summary>
public bool IsUnexpectedStatusCode { get; set; }
/// <summary>
/// Text request dump
/// </summary>
public string RequestDump { get; set; }
/// <summary>
/// Text response dump
/// </summary>
public string ResponseDump { get; set; }
/// <summary>
/// Response object
/// </summary>
public HttpResponseMessage ResponseMessage { get; set; }
/// <summary>
/// Request object
/// </summary>
public HttpRequestMessage RequestMessage { get; set; }
}
На следующем примере показан вызов сервиса с получением детализированного результата:
[Api]
public interface IService
{
[Post("orders")]
Task<int> CreateOrder(Order order);
}
//....
CallDetails<int> response = await service.Request(s => s.CreateOrder(order)).GetDetailedAsync();
Вызов сервиса без получения результата:
[Api]
public interface IService
{
[Post("orders")]
Task CreateOrder(Order order);
}
//....
CallDetails response = await service.Request(s => s.CreateOrder(order)).GetDetailedAsync();
В случае, когда метод контракта сервиса не имеет возвращаемого значения, метод GetDetailedAsync
возвращает объект детализации без содержимого ответа: CallDetails
.
При получении непредвиденного статус-кода, кроме 200 (OK)
, метод GetDetailedAsync
не выбрасывает исключение, а устанавливает свойства объекта детализации IsUnexpectedStatusCode
в true
.
var response = await service.Request(s => s.CreateOrder(order)).GetResultAsync();
if (response.IsUnexpectedStatusCode)
{
switch (response.StatusCode)
{
case HttpStatusCode.BadRequest:
//when status code = 400
break;
case HttpStatusCode.Forbidden:
//when status code = 403
break;
default:
throw new ArgumentOutOfRangeException();
}
}
Пример дампа запроса из детализации:
POST http://localhost/test/ping/body/obj/json
Cookie: <empty>
Content-Type: application/json; charset=utf-8
{"TestValue":"foo"}
Пример дампа ответа из детализации:
200 OK
Content-Type: text/plain; charset=utf-8
foo
Особенности DI инъекции:
- определение настроек подключения к удалённым API через конфигурацию;
- регистрация контрактов API на этапе конфигурирования сервисов в
Startup.ConfigureServices
; - сопоставление зарегистрированных контрактов и конфигураций;
- получение клиентов в целевых объектах в качестве зависимостей двумя способами.
Данный механизм основан на использовании фабрики HttpClient-ов.
Целью загрузки конфигурации является создание именованных фабрик http-клиентов в соответствии параметрам из конфигурации.
На примере ниже представлены способы определения конфигураций подключений к API:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddApiClients(r => r.RegisterContract<IApiContract>();
// Simple case - using default section name "Api"
services.ConfigureApiClients(Configuration);
// Or specify custom section name
services.ConfigureApiClients(Configuration, "MyApiSectionName");
// Or create options directly in code
services.ConfigureApiClients(o =>
{
o.List.Add("foo", new ApiConnectionOptions{Url = "http://test.com"})
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
}
}
Объектная модель конфигурации тут.
Пример файла конфигурации:
{
"Api": {
"List": {
"foo": { "Url": "http://foo-test.com" },
"bar": { "Url": "http://bar-test.com" }
}
}
}
Для сопоставления контракта API и настроек конфигурации используется ключ контракта, указываемый в атрибуте ApiAttribute
в поле Key
.
Пример контракта API
с указанным кодом контракта:
[Api("echo", Key = "foo")]
interface ITestServer
{
[Get]
Task<string> Echo([JsonContent]string msg);
}
Конфигурационный файл с сопоставленной записью:
{
"Api": {
"List": {
"foo": { "Url": "http://foo-test.com" }, //<--- here it is
"bar": { "Url": "http://bar-test.com" }
}
}
}
В случае отсутствия указанного ключа используется имя интерфейса контракта (без пространства имён):
{
"Api": {
"List": {
"ITestServer": { "Url": "http://foo-test.com" }, //<--- here it is
"bar": { "Url": "http://bar-test.com" }
}
}
}
Инъекция IApiClientFactory
в объект-потребитель позволяет создавать объекты ApiClient<>
для дальнейшей работы с API
через методы Call
с передачей Expressions
-выражений вызова методов контракта API
.
Это может быть полезно, например, если в дальнейшем нужно получить детали вызова метода API.
Ниже приведён пример класса-потребителя с использованием инъекции IApiClientFactory
:
class TestServiceForApiClientFactory
{
private readonly ApiClient<ITestServer> _server;
public TestServiceForHttpClientFactory(IApiClientFactory apiClientFactory)
{
_server = apiClientFactory.CreateApiClient<ITestServer>();
}
public async Task<string> TestMethod(string msg, ITestOutputHelper log)
{
var resp = await _server.Request(s => s.Echo(msg)).GetDetailedAsync();
log.WriteLine("Resquest dump:");
log.WriteLine(resp.RequestDump);
log.WriteLine("Response dump:");
log.WriteLine(resp.ResponseDump);
return resp.ResponseContent;
}
}
Для создания клиента таким образом, у контракта API
должен быть определён ключ контракта в атрибуте ApiAttribute
и должна быть загружена конфигурация с соответствующим ключом.
Инъекция прозрачного прокси в объект-потребитель позволяет использовать контракт API
так же, как любой другой сервис, добавляемый через DI контейнер. Кроме того, это значительно упрощает тестирование класса-потребителя и избавляет от лишнего погружения в детали реализации зависимости.
Для обеспечения инъекции прозрачных прокси контрактов API необходимо зарегистрировать эти контракты следующим образом:
public void ConfigureServices(IServiceCollection services)
{
// Simple case - using default section name "Api"
services.AddApiClients(
registrar =>
{
registrar.RegisterContract<ITestServer>();
});
}
Для регистрации контракта таким образом, у контракта API
должен быть определён ключ контракта в атрибуте ApiAttribute
и должна быть загружена конфигурация с соответствующим ключом.
Ниже приведён пример использования инъекции прозрачного прокси:
class TestServiceForProxy
{
private readonly ITestServer _server;
public TestServiceForProxy(ITestServer server)
{
_server = server;
}
public Task<string> TestMethod(string msg)
{
return _server.Echo(msg);
}
}
Прозрачное прокси поддерживает возврат детализации (CallDetails
) методом контракта:
[Api("echo")]
interface ITestServer
{
[Get]
Task<CallDetails<string>> CallEchoAndGetDetails([JsonContent] string msg);
[Get]
Task<CallDetails> CallEchoAndGetDetailsWithoutResonse([JsonContent] string msg);
}
//....
CallDetails<string> call = await api.CallEchoAndGetDetails("foo");
CallDetails call = await api.CallEchoAndGetDetailsWithoutResonse("foo");
При написании функциональных и интеграционных тестов, для взаимодействия с сервисом через его контракт API
, используйте класс ApiClient<>
и провайдер DelegateHttpClientProvider
.
Ниже приведены примеры тестов с разным подходом в создании клиентов в зависимости от особенностей взаимодействия:
- можно создать один
api
-клиент на тестовый класс, если в каждом методе, где он используется, будет один вызов сервиса;
public class TestServerBehavior : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly ApiClient<ITestServer> _client;
public TestServerBehavior(
WebApplicationFactory<Startup> webApplicationFactory)
{
var clientProvider = new DelegateHttpClientProvider(
webApplicationFactory.CreateClient);
_client = new ApiClient<ITestServer>(clientProvider);
}
[Fact]
public async Task ShouldReturnPayload()
{
//Arrange
//Act
var result = await _client.Request(s => s.Get()).GetResultAsync();
//Assert
Assert.NotNull(result);
}
[Api("test/resource")]
interface ITestServer
{
[Get]
Task<string> Get();
}
}
- можно создать
HttpClient
в тестовом методе, если будут многократные запросы к сервису.
public class TestServerBehavior : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly WebApplicationFactory<Startup> _webApplicationFactory;
public TestServerBehavior(
WebApplicationFactory<Startup> webApplicationFactory)
{
_webApplicationFactory = webApplicationFactory;
}
[Fact]
public async Task ShouldReturnPayload()
{
//Arrange
var clProvider = new SingleHttpClientProvider(
_webApplicationFactory.CreateClient());
var client = new ApiClient<ITestServer>(clProvider);
//Act
await client.Request(s => s.Post("foo")).GetResultAsync();
var result = await client.Request(s => s.Get()).GetResultAsync();
//Assert
Assert.Equal("foo", result);
}
[Api("test/resource")]
interface ITestServer
{
[Post]
Task Post([StringContent]string str);
[Get]
Task<string> Get();
}
}