Давненько вынашиваю идею поиграться с последней версией .net framework’а и Docker’ом. И так удачно совпало, что есть у меня задача при решении которой я получу практическую пользу. Речь идёт о передаче показаний счётчиков воды из облака Saures в Томский расчётный центр. Немного контекста: Saures — это Российская компания занимающаяся автоматизацией учёта и контроля коммунальных ресурсов. Каталог оборудования компании позволяет организовать очень интересные сценарии. В простейшем случае для учёта потребления воды понадобятся счётчики и контроллер. Контроллер передаёт показания подключенных к нему устройств в «облако» Saures. Доступ к этим данным можно получить в том числе и через API. Именно это я и буду делать в данной статье.
Итак, первым делом я создал решение WaterMeterAutomation и проект библиотеки SauresApi. Для упрощения «общения» с REST API и JSON буду использовать совершенно замечательную библиотеку Flurl. Если быть точнее то Flurl состоит из двух NuGet пакетов: непосредственно Flurl позволяет «строить» (собирать..?) URL, в то время как Flurl.Http — это HTTP-клиент с поддержкой сериализации/десериализации Json. Вот как выглядела первая версия метода LoginAsync в классе Api для вызова метода login API Saures:
public async Task<string> LoginAsync(string email, string password)
{
var result = await _rootUrl.AppendPathSegment("login")
.PostUrlEncodedAsync(new {email, password})
.ReceiveString();
return result;
}
Коротко и наглядно!
Чтобы запустить этот код добавлю в решение XUnit проект интеграционных тестов SauresApi.IntegrationTests. Класс TestApi помечу атрибутом [Trait("Category", "IntegrationTests")]
, предварительно добавив категорию «IntegrationTests» в настройки Resharper’а Options
-> Unit Testing
-> General
-> Skip tests from categories
. Это позволит не запускать интеграционные тесты во время запуска всех тестов решения, поскольку конкретно эти тесты мне нужны как средство отладки во время разработки клиента API и не более.
Код теста максимально прост:
[Fact]
public async Task TestLogin()
{
var api = await _api.LoginAsync(_suaresApiLogin, _suaresApiPassword);
api.Should().NotBeNullOrEmpty();
}
Поставив точку останова в нужном месте и запустив тест в режиме отладки я получу возможность посмотреть правильно ли работает мой код и результат возвращаемый сервером, который, кстати сказать, имеет следующее содержание:
{
"data": {
"api": 1,
"role": 1,
"sid": "92104027-908a-43dd-b18b-b06b918f696d"
},
"errors": [],
"status": "ok"
}
Признаюсь честно: первую объектную обёртку для этого JSON я накидал ручками, но позже вспомнил, что где-то на просторах интернета видел средство создания классов на основе JSON. Первая же ссылка в Google привела на https://json2csharp.com. В дальнейшем все классы я генерировал на этом сайте и затем менял названия.
Далее поковырявшись в документации Saures API пришёл к выводу, что моя конечная цель это метод /object/meters — именно он возвращает список всех измерительных приборов подключенных к контроллеру с их идентификаторами и показаниями. Но для вызова требуется такой параметр как идентификатор объекта
, получить который можно вызвав метод /user/objects. Реализация двух вышеупомянутых методов была аналогична методу login и в конечном итоге класс Api стал выглядеть следующим образом:
public class Api
{
private readonly Url _rootUrl;
public Api(string host)
{
_rootUrl = host.AppendPathSegment("1.0");
}
public async Task<string> LoginAsync(string email, string password)
{
var result = await _rootUrl.AppendPathSegment("login")
.PostUrlEncodedAsync(new {email, password})
.ReceiveJson<Result<LoginData>>();
result.ThrowExceptionIfAny();
return result.data.sid;
}
public async Task<UserObject> GetUserObjects(string sid)
{
var result = await _rootUrl.AppendPathSegment("user").AppendPathSegment("objects")
.SetQueryParam("sid", sid)
.GetJsonAsync<Result<UserObjectsData>>();
result.ThrowExceptionIfAny();
return result.data.objects[0];
}
public async Task<Sensor> GetObjectMeters(string sid, int id)
{
var result = await _rootUrl.AppendPathSegment("object").AppendPathSegment("meters")
.SetQueryParam("sid", sid)
.SetQueryParam("id", id)
.GetJsonAsync<Result<SensorsData>>();
result.ThrowExceptionIfAny();
return result.data.sensors[0];
}
}
Метод ThrowExceptionIfAny бросает ApplicationException если массив errors в результирующем JSON не пустой.
Хранение имени пользователя и пароля в тестовом проекте организовал через переменные окружения (Environment variables). Это позволит использовать эти данные только на моей машине и в то же время они не засветятся в открытом виде в Git или другой системе управления версиями. В будущем этот же подход планирую использовать в главном приложении с поддержкой Docker’а. Финальная версия тестового класса:
[Trait("Category", "IntegrationTests")]
public class TestApi
{
private readonly Api _api;
private readonly string _suaresApiLogin;
private readonly string _suaresApiPassword;
private const string Sid = "0af2b792-2cf9-420c-987e-74e4909c815e";
private const int Id = 12345;
public TestApi()
{
_suaresApiLogin = Environment.GetEnvironmentVariable("SauresApiLogin", EnvironmentVariableTarget.Process);
_suaresApiPassword = Environment.GetEnvironmentVariable("SauresApiPassword", EnvironmentVariableTarget.Process);
_api = new Api("https://api.saures.ru");
}
[Fact]
public async Task TestLogin()
{
var api = await _api.LoginAsync(_suaresApiLogin, _suaresApiPassword);
api.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task TestUserObjects()
{
var result = await _api.GetUserObjects(Sid);
result.id.Should().BeGreaterThan(0);
}
[Fact]
public async Task TestGetObjectMeters()
{
var result = await _api.GetObjectMeters(Sid, Id);
result.meters.Should().HaveCount(4);
}
}
Sid
— идентификатор сессии получаемый при вызове Login. Id
— идентификатор моего объекта, который вроде бы как постоянен, т.е. метод GetUserObjects можно вызвать единожды.
Отдельно хочу упомянуть про метод Should()
в тестах — он принадлежит ещё одному замечательному инструменту Fluent Assertions, который позволяет сделать утверждения в модульных и интеграционных тестах максимально простыми и удобочитаемыми, но в то же время эффективными. Возможно в будущем я остановлюсь на этой теме подробней, поскольку в данном случае потенциала Fluent Assertions попросту не видно.
P.S. в этой статье впервые я использую подсветку синтаксиса кода. Хочу поблагодарить за это Vicky Agravat за разработку дополнения CodeMirror Blocks.