From 3eebef56a0b11ee1f2512f7467f255ba73c193fd Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 9 Jul 2026 12:48:21 -0700 Subject: [PATCH] Add MicCheck.Client library with FeatureClient.IsEnabledAsync Client-side SDK for checking feature flag state against the flags API, with unit tests mocking HttpMessageHandler. --- MicCheck.slnx | 2 + src/MicCheck.Client/Common/Guard.cs | 40 ++++++++++ src/MicCheck.Client/FeatureClient.cs | 22 ++++++ src/MicCheck.Client/FlagResult.cs | 5 ++ src/MicCheck.Client/MicCheck.Client.csproj | 12 +++ .../FeatureClientTests.cs | 79 +++++++++++++++++++ .../MicCheck.Client.Tests.Unit.csproj | 24 ++++++ 7 files changed, 184 insertions(+) create mode 100644 src/MicCheck.Client/Common/Guard.cs create mode 100644 src/MicCheck.Client/FeatureClient.cs create mode 100644 src/MicCheck.Client/FlagResult.cs create mode 100644 src/MicCheck.Client/MicCheck.Client.csproj create mode 100644 tests/client/MicCheck.Client.Tests.Unit/FeatureClientTests.cs create mode 100644 tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj diff --git a/MicCheck.slnx b/MicCheck.slnx index ca6f511..f519904 100755 --- a/MicCheck.slnx +++ b/MicCheck.slnx @@ -12,10 +12,12 @@ + + diff --git a/src/MicCheck.Client/Common/Guard.cs b/src/MicCheck.Client/Common/Guard.cs new file mode 100644 index 0000000..fdb60ac --- /dev/null +++ b/src/MicCheck.Client/Common/Guard.cs @@ -0,0 +1,40 @@ +namespace MicCheck.Common; + +internal static class Guard +{ + public static void Null(T t, string parameterName) where T : class + { + if (t is null) + throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null"); + } + + public static void Empty(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Empty(IEnumerable collection, string parameterName) + { + if (collection == null || !collection.Any()) + throw new ArgumentException($"{parameterName} can not be empty", parameterName); + } + + public static void Negative(int value, string parameterName) + { + if (value < 0) + throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero"); + } + + public static void NegativeOrZero(int value, string parameterName) + { + if (value <= 0) + throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero"); + } + + public static void Default(T value, string parameterName) + { + if (EqualityComparer.Default.Equals(value, default)) + throw new ArgumentException($"{parameterName} can not be a default value", parameterName); + } +} diff --git a/src/MicCheck.Client/FeatureClient.cs b/src/MicCheck.Client/FeatureClient.cs new file mode 100644 index 0000000..b1c0968 --- /dev/null +++ b/src/MicCheck.Client/FeatureClient.cs @@ -0,0 +1,22 @@ +using System.Net.Http.Json; +using MicCheck.Common; + +namespace MicCheck; + +public class FeatureClient(HttpClient httpClient) : IFeatureClient +{ + public async Task IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default) + { + Guard.Empty(featureName, nameof(featureName)); + + var flags = await httpClient.GetFromJsonAsync>("api/v1/flags", cancellationToken); + var match = flags?.FirstOrDefault(flag => string.Equals(flag.Feature.Name, featureName, StringComparison.OrdinalIgnoreCase)); + + return match?.Enabled ?? defaultValue; + } +} + +public interface IFeatureClient +{ + Task IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default); +} diff --git a/src/MicCheck.Client/FlagResult.cs b/src/MicCheck.Client/FlagResult.cs new file mode 100644 index 0000000..75b6a85 --- /dev/null +++ b/src/MicCheck.Client/FlagResult.cs @@ -0,0 +1,5 @@ +namespace MicCheck; + +public record FlagResult(int Id, FeatureSummary Feature, bool Enabled, string? FeatureStateValue); + +public record FeatureSummary(int Id, string Name, string Type); diff --git a/src/MicCheck.Client/MicCheck.Client.csproj b/src/MicCheck.Client/MicCheck.Client.csproj new file mode 100644 index 0000000..62a79b9 --- /dev/null +++ b/src/MicCheck.Client/MicCheck.Client.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + enable + enable + latest + true + MicCheck + + + diff --git a/tests/client/MicCheck.Client.Tests.Unit/FeatureClientTests.cs b/tests/client/MicCheck.Client.Tests.Unit/FeatureClientTests.cs new file mode 100644 index 0000000..70ef6aa --- /dev/null +++ b/tests/client/MicCheck.Client.Tests.Unit/FeatureClientTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text; +using Moq; +using Moq.Protected; +using NUnit.Framework; + +namespace MicCheck.Client.Tests.Unit; + +[TestFixture] +public class FeatureClientTests +{ + [Test] + public async Task WhenFeatureIsEnabled_ThenIsEnabledAsyncReturnsTrue() + { + var client = CreateClient(HttpStatusCode.OK, """ + [{"id":1,"feature":{"id":1,"name":"my-feature","type":"STANDARD"},"enabled":true,"featureStateValue":null}] + """); + + var result = await client.IsEnabledAsync("my-feature"); + + Assert.That(result, Is.True); + } + + [Test] + public async Task WhenFeatureIsDisabled_ThenIsEnabledAsyncReturnsFalse() + { + var client = CreateClient(HttpStatusCode.OK, """ + [{"id":1,"feature":{"id":1,"name":"my-feature","type":"STANDARD"},"enabled":false,"featureStateValue":null}] + """); + + var result = await client.IsEnabledAsync("my-feature"); + + Assert.That(result, Is.False); + } + + [Test] + public async Task WhenFeatureNameDiffersOnlyByCase_ThenIsEnabledAsyncStillMatches() + { + var client = CreateClient(HttpStatusCode.OK, """ + [{"id":1,"feature":{"id":1,"name":"My-Feature","type":"STANDARD"},"enabled":true,"featureStateValue":null}] + """); + + var result = await client.IsEnabledAsync("my-feature"); + + Assert.That(result, Is.True); + } + + [Test] + public async Task WhenFeatureIsNotFound_ThenIsEnabledAsyncReturnsProvidedDefaultValue() + { + var client = CreateClient(HttpStatusCode.OK, "[]"); + + var result = await client.IsEnabledAsync("missing-feature", defaultValue: true); + + Assert.That(result, Is.True); + } + + [Test] + public void WhenFeatureNameIsEmpty_ThenIsEnabledAsyncThrowsArgumentException() + { + var client = CreateClient(HttpStatusCode.OK, "[]"); + + Assert.That(async () => await client.IsEnabledAsync(" "), Throws.ArgumentException); + } + + private static FeatureClient CreateClient(HttpStatusCode responseStatus, string responseBody) + { + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(responseStatus) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(handler.Object) { BaseAddress = new Uri("https://example.com") }; + return new FeatureClient(httpClient); + } +} diff --git a/tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj b/tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj new file mode 100644 index 0000000..6679bf6 --- /dev/null +++ b/tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + latest + false + true + + + + + + + + + + + + + + +