MyLab.ApiClient
3.21.31
dotnet add package MyLab.ApiClient --version 3.21.31
NuGet\Install-Package MyLab.ApiClient -Version 3.21.31
<PackageReference Include="MyLab.ApiClient" Version="3.21.31" />
<PackageVersion Include="MyLab.ApiClient" Version="3.21.31" />
<PackageReference Include="MyLab.ApiClient" />
paket add MyLab.ApiClient --version 3.21.31
#r "nuget: MyLab.ApiClient, 3.21.31"
#addin nuget:?package=MyLab.ApiClient&version=3.21.31
#tool nuget:?package=MyLab.ApiClient&version=3.21.31
MyLab.ApiClient
Поддерживаемые платформы: .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
.
PathAttribute
Аргумент - часть пути
[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
QueryAttribute
Аргумент - часть запроса в 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
HeaderAttribute
Аргумент - заголовок
[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
HeaderCollectionAttribute
Аргумент - произвольный список заголовков. Тип параметра должен реализовывать интерфейс 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
StringContentAttribute
Аргумент - содержательная часть запроса в строковой форме
[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
JsonContentAttribute
Аргумент - содержательная часть запроса в формате 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"}
XmlContentAttribute
Аргумент - содержательная часть запроса в формате 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>
FormContentAttribute
Аргумент - содержательная часть запроса в формат 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
BinContentAttribute
Аргумент - содержательная часть запроса в бинарном формате
[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
MultipartContentAttribute
Аргумент - содержательная часть запроса в формате 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 инъекция
Обзор
Особенности 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
Инъекция 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();
}
}
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
.NET Core | netcoreapp3.1 is compatible. |
-
.NETCoreApp 3.1
- Microsoft.Extensions.Configuration.Abstractions (>= 3.1.3)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 3.1.3)
- Microsoft.Extensions.Http (>= 3.1.3)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 3.1.3)
- MyLab.ExpressionTools (>= 1.0.2)
- Newtonsoft.Json (>= 13.0.1)
NuGet packages (13)
Showing the top 5 NuGet packages that depend on MyLab.ApiClient:
Package | Downloads |
---|---|
MyLab.AsyncProcessor.Sdk
Allow to build processor-application for MyLab.AsyncProc |
|
MyLab.TaskApp
.NET Core task-application framework |
|
MyLab.ApiClient.Test
Представляет набор инструментов для написания функциональных и интеграционных тестов на базе `xUnit`, связанных с вызовами `WEB-API` с использованием MyLab.ApiClient. |
|
MyLab.Search.SearcherClient
Package Description |
|
MyLab.Search.IndexerClient
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last updated |
---|---|---|
3.21.31 | 117 | 2/25/2025 |
3.20.30 | 529 | 2/9/2024 |
3.19.30 | 427 | 10/19/2023 |
3.19.28 | 285 | 9/27/2023 |
3.18.27 | 263 | 7/22/2023 |
3.17.27 | 454 | 4/3/2023 |
3.16.27 | 558 | 1/19/2023 |
3.16.26 | 720 | 12/7/2022 |
3.16.25 | 632 | 11/15/2022 |
3.15.25 | 1,578 | 7/1/2022 |
3.15.24 | 511 | 6/30/2022 |
3.14.24 | 767 | 5/30/2022 |
3.13.24 | 1,678 | 2/24/2022 |
3.12.24 | 2,075 | 1/20/2022 |
3.11.24 | 543 | 1/18/2022 |
3.10.22 | 2,290 | 10/27/2021 |
3.9.22 | 535 | 10/8/2021 |
3.9.21 | 1,333 | 9/16/2021 |
3.8.21 | 416 | 9/16/2021 |
3.7.21 | 4,682 | 6/23/2021 |
3.6.21 | 577 | 6/3/2021 |
3.6.20 | 2,262 | 2/17/2021 |
3.6.19 | 978 | 2/2/2021 |
3.6.18 | 943 | 1/29/2021 |
3.6.17 | 1,702 | 12/25/2020 |
3.6.16 | 510 | 12/23/2020 |
3.6.15 | 2,765 | 12/15/2020 |
3.5.15 | 1,874 | 11/20/2020 |
3.5.14 | 1,479 | 11/11/2020 |
3.5.11 | 814 | 11/5/2020 |
3.4.11 | 1,550 | 7/31/2020 |
3.4.10 | 2,962 | 7/7/2020 |
3.4.7 | 893 | 6/3/2020 |
3.4.6 | 3,918 | 4/29/2020 |
3.4.5 | 573 | 4/29/2020 |
3.4.4 | 628 | 4/29/2020 |
3.4.3 | 950 | 4/28/2020 |
3.4.2 | 592 | 4/27/2020 |
3.4.1 | 594 | 4/25/2020 |
3.3.1 | 733 | 4/7/2020 |
3.3.0 | 601 | 4/2/2020 |
3.2.0 | 1,004 | 2/27/2020 |
3.1.0 | 586 | 2/21/2020 |
3.0.0 | 669 | 2/20/2020 |
2.1.4 | 614 | 12/16/2019 |
2.1.3 | 1,840 | 2/9/2019 |
2.1.2 | 1,105 | 11/11/2018 |
2.0.1 | 1,014 | 10/23/2018 |
2.0.0 | 873 | 9/26/2018 |
1.0.0 | 918 | 9/26/2018 |