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:
@@ -12,10 +12,12 @@
|
|||||||
</Project>
|
</Project>
|
||||||
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
|
<Project Path="src/api/MicCheck.Api/MicCheck.Api.csproj" />
|
||||||
<Project Path="src/MicCheck.AppHost/MicCheck.AppHost.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" />
|
<Project Path="src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" />
|
<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/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj" />
|
||||||
|
<Project Path="tests/client/MicCheck.Client.Tests.Unit/MicCheck.Client.Tests.Unit.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user