Блоґ

Home / Думки, нариси, різні випадки

Режим "заглушки" для HTTP-клієнта

Режим "заглушки" для HTTP-клієнта

неділя, 23 квітня 2023 р.

http client

Comments

Наш застосунок має Azure-функцію, яка реагує на повідомлення з service bus, яке надсилається туди при створенні певної сутності. Коли це трапляється, ми маємо виконати конфігурацію цієї сутності на кількох різних сервісах — зконфігурувати дещо в базі даних, покласти новий секрет в keyvault, зробити десяток викликів кількох API і таке інше. Так сталося, що в нас нема повноцінного DEV-середовища, яке покривало б усю залучену інфраструктуру — під цим я маю на увазі можливість в процесі налагодження виконувати запити на створення/редагування/видалення сутностей так, щоб це не впливало на реальний бізнес. У нас є QA та PROD, але щойно якусь нову рису буде задеплоєно бодай на QA, вона починає брати участь в певних бізнес-процесах, де підставних даних вже бажано уникати, не кажучи про PROD.

Отже, для продовження розробки таким чином, щоб не сильно турбувати бізнес, було ясно, що потрібен якийсь режим емуляції для цієї функції — я назвав його режимом заглушки. З деяким спрощенням це можна описати як ситуацію, коли верхній рівень застосунку залишатиметься у невіданні щодо самого існування цього режиму заглушки, але десь на нижчих рівнях буде аналізуватися якийсь конфігураційний параметр і буде відбуватися або дійсний виклик зовнішнього сервісу, або одразу повертатимуться підставні дані.

Однак в цьому випадку інфраструктурний рівень застосунку буде важко покривати юніт-тестами. Під кожен окремий HTTP-запит ми матимемо впровадити розгалужування і перевіряти поведінку, яка не є частиною застосунку як такого. Коли якісь вимогу зміняться і очікувана відповідь зміниться також, ці зміни буде потрібно відбити в двох різних місцях — в реальному потоку коду і там, де обробляється "заглушка". Більше того, кожен метод, який робитиме виклик зовнішнього АПІ, матиме перевіряти параметр, який не є частиною бізнесу, але радше є однією з характеристик контексту запуску. Зрештою, все по почало мати для мене не надто добрий вигляд, отже я почав міркувати про якийсь інакший підхід.

Було очевидно, що, якщо я хочу мати мати цей режим заглушки в коді, десь в ньому має бути умова, яка спрямовуватиме вихідний HTTP-запит або на реальний зовнішній АПІ, або одразу ж повертатиме підроблену відповідь. Я просто хотів зробити так, аби вихідному коду була максимально притаманна бізнес-логіка застосунку, і аби необхідність реалізації "заглушки" мінімально впливала на покриття юніт-тестами. Отже, вирішення цієї задачі, яке я зрештою впровадив, полягало у використанні http message handlers, які конфігуруються для застосування з HttpClient.

Http message handlers — це доволі крута штука, яка може бути використана для налаштування поведінки http-клієнта без впливу на код, який його використовує. З певного погляду http-клієнт можна сприймати як елемент функціоналу, який має власний middleware, щось подібне до апі-контроллерів та їхнього middleware. Http-клієнт починає ланцюг запиту з моменту, коли викликається його метод Send/SendAsync зі зконфігурованим http-повідомленням, і далі цей запит обробляється обробником http-повідеомлення (http message handler), який може бути або тим, що йде за замовчанням, або створеним для цього випадку. Якщо замовчуваний обробник повідомлення, що робить реальний виклик назовні, буде заміщено замовним обробником, який просто повертатиме підроблену відповідь, мету буде досягнуто — код, який викликатиме http-клієнта, залишатиметься в невіданні щодо джерела надходження відповіді, чи вона надійде від реального зовнішнього АПІ, чи буде повернена замовним обробником. І саме це я і впровадив у своєму коді.

Отже, маємо три принципові частини для впровадження цього підходу:

1 - Код, що підробляє відповідь

Ця частина, власне, може бути впроваджена безліччю способів. Загалом, це просто обробник, який приймає параметр HttpRequestMessage, якось його аналізує, аби зрозуміти, який саме ендпоінт з якими параметрами викликано, і повертає відповідний HttpResponseMessage. В моєму випадку було достатньо реалізувати його як простий Func-делегат:


public static class Loopback
{
    public static readonly Func Handlers = req =>
    {
        // POST geospatial data store
        if (req.Method == HttpMethod.Post)
        {
            if (req.RequestUri!.AbsolutePath == "/DataStores")
            {
                var json =
                    $$"""
                    {
                      "id": "{{Guid.NewGuid()}}",
                      "dataStoreType": "ms",
                      "name": "Operation.loopbackMock",
                      "displayName": "Operation loopback mock"
                    }
                    """;

                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(json)
                    {
                        Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
                    }
                };
            }
		}
		
		// ..other similar pieces
	}
}

Як видно, аби належним чином підробляти реальну відповідь АПІ, ми маємо можливість надавати її у вигляді складного json-об'єкта, у вигляді простого значення або навіть просто статус-кода HTTP.

2 - Власний обробник повідомлень, дуже простой клас


public class LoopbackMessageHandler : AbstractLoopbackMessageHandler
{
    public LoopbackMessageHandler(Func handler) : base(handler) { }
}

3 - конфігурація

В цій частині ми аналізуємо певний параметр конфігурації, аби зрозуміти, чи застосунок запущено в режимі заглушки. Якщо так, ми конфігуруємо первинний обробник http-повідомлень для http-клієнта.


[ExcludeFromCodeCoverage]
public static class BaseHttpClientsDependencies
{
    public static void InitializeBaseHttpClients(this IServiceCollection services, bool loopbackMode = true)
    {
        var client = services.AddHttpClient((sp, c) =>
        {
			// Another Http client to communicate with identity provider and obtain jwt token
			// it does this upon initialization, registered as Scoped dependency, so it is enough
			// to get it from the dependency container to have fresh jwt
            var auth0HttpClient = sp.GetRequiredService();

			// Get some settings from respective Options
            var settings = sp.GetRequiredService>().Value;

			// Set the base address
            c.BaseAddress = new Uri(settings.ApiHost ?? throw new ArgumentNullException(nameof(settings.ApiHost)));

			// Set the bearer token
            c.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", auth0HttpClient.BearerToken);
        });

		// Finally, if loopback mode is set, configure primary http message handler
        if (loopbackMode) client.ConfigurePrimaryHttpMessageHandler(_ => new LoopbackMessageHandler(Loopback.Handlers));
   }
}

І це, загалом, все. Будь-який метод, для якого буде зконфігуровано залежність від інтерфейсу IBaseHttpClient, отримає екземпляр класу, з яким надалі працюватиме, і при цьому цей метод залишатиметься у невіданні щодо того, чи цей клієнт надсилатиме реальні запити на зовнішній АПІ, чи він просто повертатиме підроблені відповіді.

Випадки, коли цей підхід може стати у пригоді:

  • коли розробка відбувається в якомусь гетерогенному середовищі, де деякі сервіси бажано виключити з реальної взаємодії з застосунком, який розробляється, аби не "наводнювати" їх тестовими даними;
  • коли потрібна можливість проведення інтеграційних тестів без зачіпання зовнішніх АПІ-сервісів.

Дякую за увагу!

© theyur.dev. All Rights Reserved. Designed by HTML Codex