Add MicCheck.Client library with FeatureClient.IsEnabledAsync

Client-side SDK for checking feature flag state against the flags API,
with unit tests mocking HttpMessageHandler.
This commit is contained in:
James Wampler
2026-07-09 12:48:21 -07:00
parent 8262cd2f61
commit 3eebef56a0
7 changed files with 184 additions and 0 deletions
+2
View File
@@ -12,10 +12,12 @@
</Project>
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
<Project Path="src/MicCheck.AppHost/MicCheck.AppHost.csproj" />
<Project Path="src/MicCheck.Client/MicCheck.Client.csproj" />
<Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
<Project Path="tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" />
<Project Path="tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj" />
</Folder>
</Solution>
+40
View File
@@ -0,0 +1,40 @@
namespace MicCheck.Common;
internal static class Guard
{
public static void Null<T>(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<T>(IEnumerable<T> 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>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Net.Http.Json;
using MicCheck.Common;
namespace MicCheck;
public class FeatureClient(HttpClient httpClient) : IFeatureClient
{
public async Task<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default)
{
Guard.Empty(featureName, nameof(featureName));
var flags = await httpClient.GetFromJsonAsync<List<FlagResult>>("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<bool> IsEnabledAsync(string featureName, bool defaultValue = false, CancellationToken cancellationToken = default);
}
+5
View File
@@ -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);
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>MicCheck</RootNamespace>
</PropertyGroup>
</Project>
@@ -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<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.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);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../src/MicCheck.Client/MicCheck.Client.csproj" />
</ItemGroup>
</Project>