-
Notifications
You must be signed in to change notification settings - Fork 1
Working with CSharp Client Testing
How to unit-test code that consumes a client generated by Atc.Rest.Api.SourceGenerator.
π See Working with C# Client for how to generate and wire up a client in the first place.
π¦ The HTTP test doubles used on this page ship as a companion package,
Atc.Rest.Api.Client.Testingβ see The package.
There are three places you can put a test double. Pick the one that matches what you are actually trying to verify.
| Seam | You fake | Use it to test | Modes |
|---|---|---|---|
| π Contract | The client / endpoint interface | Your own services that consume the API | Both β |
| π Transport |
HttpMessageHandler / IHttpClientFactory
|
URL building, parameter binding, status mapping, auth handlers | Both β |
| π Server | A real in-memory or containerised API | End-to-end behaviour | Both β |
Rule of thumb: if the class under test is yours, fake the contract. Only drop to the transport seam when the generated client itself is what you are verifying.
| Emoji | Meaning |
|---|---|
| β | Available today |
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyApi.Client\MyApi.Client.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Atc.Rest.Api.Client.Testing" Version="1.*" />
<PackageReference Include="Atc.Test" Version="3.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Atc.Rest.Api.Client.Testing" />
<Using Include="Atc.Test" />
<Using Include="AwesomeAssertions" />
<Using Include="NSubstitute" />
<Using Include="Xunit" />
</ItemGroup>
</Project>π‘
Atc.Testsupplies the[Theory, AutoNSubstituteData]attribute used throughout this page. It auto-creates NSubstitute doubles for every parameter and builds the system under test for you.π¦
Atc.Rest.Api.Client.Testingsupplies the HTTP test doubles used in the transport seam sections below. It has no test-framework dependency, so it works with xUnit, NUnit or MSTest.
This mode already generates an interface and a result interface per operation, and registers them in DI:
Endpoints/
βββ Interfaces/IGetTokenEndpoint.g.cs
βββ Interfaces/IGetTokenEndpointResult.g.cs
βββ GetTokenEndpoint.g.cs
βββ Results/GetTokenEndpointResult.g.cs
So your service depends on the interface:
public sealed class MeteringPointReportService(
IGetTokenEndpoint tokenEndpoint,
IGetMeteringPointsEndpoint meteringPointsEndpoint)
{
public async Task<IReadOnlyList<string>> BuildReportAsync(
string identifier,
CancellationToken cancellationToken)
{
var tokenResult = await tokenEndpoint.ExecuteAsync(cancellationToken: cancellationToken);
if (!tokenResult.IsOk)
{
return [];
}
var result = await meteringPointsEndpoint.ExecuteAsync(
new GetMeteringPointsParameters(Identifier: identifier),
cancellationToken: cancellationToken);
return result.IsOk
? result.OkContent.Result.Select(x => x.MeteringPointId).ToList()
: [];
}
}...and the test needs no HTTP at all:
public class MeteringPointReportServiceTests
{
[Theory, AutoNSubstituteData]
public async Task BuildReportAsync_ReturnsEmpty_WhenTokenEndpointFails(
[Frozen] IGetTokenEndpoint tokenEndpoint,
[Frozen] IGetMeteringPointsEndpoint meteringPointsEndpoint,
MeteringPointReportService sut)
{
// Arrange
var failed = Substitute.For<IGetTokenEndpointResult>();
failed.IsOk.Returns(false);
failed.IsUnauthorized.Returns(true);
tokenEndpoint
.ExecuteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(failed);
// Act
var report = await sut.BuildReportAsync("12345678", TestContext.Current.CancellationToken);
// Assert
report.Should().BeEmpty();
await meteringPointsEndpoint.ReceivedWithAnyArgs(0).ExecuteAsync(default!, default, default);
}
}Every generated {Operation}EndpointResult ships static factory methods β one per documented response β so you can build a real result object without stubbing anything:
var ok = GetMeteringPointsEndpointResult.Ok(new MeteringPointsApiResponse([
new MeteringPoint { MeteringPointId = "571313100000000001" },
]));
meteringPointsEndpoint
.ExecuteAsync(Arg.Any<GetMeteringPointsParameters>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ok);The factory set mirrors the OpenAPI responses for that operation. Responses that carry a body take a content argument; the rest are parameterless:
GetMeteringPointsEndpointResult.Ok(content); // 200 + body
GetMeteringPointsEndpointResult.BadRequest(validationProblem); // 400 + body
GetMeteringPointsEndpointResult.Unauthorized(problemDetails); // 401 + body
GetMeteringPointsEndpointResult.Forbidden(); // 403, no body
GetMeteringPointsEndpointResult.InternalServerError(); // 500, no bodyEach factory sets the matching IsOk / IsBadRequest / IsUnauthorized flag, the HTTP status code, and the typed *Content property β so assertions against the result behave exactly like a real HTTP round-trip.
π§° Prefer these over
Substitute.For<I{Operation}EndpointResult>(). A substitute only returns what you explicitly configure, so a forgottenIsOk.Returns(...)silently yieldsfalseand sends the test down the wrong branch. The generated factories keep every flag internally consistent.
βΉοΈ If your spec declares its own
ProblemDetails/ValidationProblemDetailsschema,EndpointPerOperationmode ignores it in favour of the built-in type and reportsATC_API_SCH021. There is therefore only ever oneProblemDetailstype in scope, and no qualification is needed at the call site. See Marker-Files#-error-response-formats.
{Operation}EndpointResult is generated as a partial class, so anything a test needs repeatedly can live next to it instead of in a helper:
// Your test project β same namespace as the generated result
public partial class GetMeteringPointsEndpointResult
{
public int MeteringPointCount
=> OkContent?.Result?.Count ?? 0;
}There is no marker-file flag for this β result classes are always partial. generatePartialModels is a separate, opt-in setting that applies to models, not to results.
β οΈ Keep such members free of test-only dependencies if the extension file lives in the client project rather than the test project; it is compiled into the shipped assembly like any other partial.
The generator emits an I{ClientName} interface next to every typed client, in the same namespace, and the client implements it. Inject the interface into your own services and mock it freely β no hand-written wrapper required.
[Theory, AutoNSubstituteData]
public async Task BuildReportAsync_ReturnsOneLinePerMeteringPoint(
[Frozen] IMyApiClient client,
MeteringPointReportService sut)
{
client
.GetMeteringPointsAsync(Arg.Any<GetMeteringPointsParameters>(), Arg.Any<CancellationToken>())
.Returns(new MeteringPointsApiResponse([
new MeteringPoint { MeteringPointId = "571313100000000001" },
]));
var report = await sut.BuildReportAsync("12345678", TestContext.Current.CancellationToken);
report.Should().HaveCount(1);
}The interface declares every public operation of the client and nothing else β implementation-only helpers stay off the contract.
π‘ Constructing generated models in tests. Generated models are positional records where no parameter has a default value, so even a test that cares about two fields must supply every argument. Add a small
ModelBuilderfactory to your test project and keep the noise in one place.
When Microsoft.Extensions.Http is referenced, the generator also emits an Add{ClientName}() extension:
services.AddMyApiClient(client => client.BaseAddress = new Uri("https://api.example.com"));
// Resolve through the interface.
var client = provider.GetRequiredService<IMyApiClient>();It is worth asserting this in a test β a DI registration that compiles can still fail at resolve time:
[Fact]
public void AddMyApiClient_RegistersTheInterface()
{
var services = new ServiceCollection();
services.AddMyApiClient(client => client.BaseAddress = new Uri("https://api.example.com"));
using var provider = services.BuildServiceProvider(validateScopes: true);
provider.GetService<IMyApiClient>().Should().NotBeNull();
}Both approaches can test the same service. They are not equivalent in cost or in what they actually prove.
Consider a service that returns an empty report when the API answers 404.
Contract seam β mock IMyApiClient:
[Theory, AutoNSubstituteData]
public async Task BuildReportAsync_ReturnsEmpty_WhenNotFound(
[Frozen] IMyApiClient client,
MeteringPointReportService sut)
{
client
.GetMeteringPointsAsync(Arg.Any<GetMeteringPointsParameters>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Not Found", null, HttpStatusCode.NotFound));
var report = await sut.BuildReportAsync("12345678", TestContext.Current.CancellationToken);
report.Should().BeEmpty();
}Transport seam β stub HttpClient:
[Fact]
public async Task BuildReportAsync_ReturnsEmpty_WhenNotFound()
{
using var handler = new FakeHttpMessageHandler(
() => new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("""{"title":"Not Found","status":404}""", Encoding.UTF8, "application/json"),
});
using var httpClient = handler.CreateClient();
var sut = new MeteringPointReportService(new MyApiClient(httpClient));
var report = await sut.BuildReportAsync("12345678", TestContext.Current.CancellationToken);
report.Should().BeEmpty();
}π Mock I{ClientName}
|
π Stub HttpClient
|
|
|---|---|---|
| Setup | One Substitute.For<T>() line |
Handler + response body + HttpClient + client instance |
| Proves | Your service's branching logic | Binding, serialization, status mapping and the branch |
| Breaks when | The operation signature changes | The URL, payload shape or status mapping changes |
| Speed | No HTTP stack at all | In-memory, still fast |
| Refactor cost | Low β compiler points at every call site | Higher β string URLs and JSON literals are not type-checked |
| Blind spot | Won't catch a wrong route or bad serialization | Won't catch an unused/misused response value |
- Default to mocking the interface. Most tests are about your logic, and the generated interface is the narrowest seam that expresses it. These tests stay readable and survive refactors.
- Add transport tests deliberately, one or two per operation, to pin down the things the interface cannot express: the request URI, the serialized body and the status-to-result mapping.
-
Don't stub
HttpClientto test business logic. Encoding a JSON literal just to drive anifstatement couples the test to the wire format for no benefit β when the spec changes, the test fails for a reason unrelated to what it was written to check. -
Never mock the concrete client class. It is
sealed; that is deliberate, and the interface exists precisely so you don't have to.
Use this to verify the generated client itself: URLs, path/query/header binding, serialization and status handling.
You do not need to hand-roll these test doubles. They ship as a small, framework-agnostic package:
<PackageReference Include="Atc.Rest.Api.Client.Testing" Version="1.*" />global using Atc.Rest.Api.Client.Testing;It targets net10.0, has no test-framework dependency (works with xUnit, NUnit or MSTest) and contains four types:
| Type | Purpose |
|---|---|
FakeHttpMessageHandler |
Records every request and replays canned responses |
StubHttpClientFactory |
IHttpClientFactory over a single HttpClient
|
HttpResponseMessageFactory |
Builds canned HttpResponseMessage instances |
HttpRequestMessageExtensions |
Reads path, query, headers and body off a recorded request |
Records every outgoing request and replays a queue of canned responses. The last response is reused once the queue is exhausted.
// Queued responses - one per call, in order.
var handler = new FakeHttpMessageHandler(
() => HttpResponseMessageFactory.Json(new Foo { Id = 1 }),
() => HttpResponseMessageFactory.StatusCode(HttpStatusCode.NotFound));
var httpClient = handler.CreateClient();
// After the act:
handler.Requests.Should().HaveCount(2);
handler.Request(0).GetPath().Should().Be("/foos");| Member | Description |
|---|---|
new FakeHttpMessageHandler(params Func<HttpResponseMessage>[]) |
Queue of responses, replayed in order |
FromRequests(params Func<HttpRequestMessage, HttpResponseMessage>[]) |
Queue where each response can inspect the incoming request |
RespondTo(string pathSuffix, Func<HttpResponseMessage>) |
Route a response by path - chainable, ideal for multi-call flows |
Requests |
Every recorded HttpRequestMessage
|
Request(int index) |
Indexed accessor with a helpful failure message |
ResponseGate |
Awaited before answering - lets a test hold a request open |
CreateClient(string baseAddress = "https://localhost") |
An HttpClient bound to this handler |
Routing by path instead of by call order keeps a test readable when the client makes several different calls:
var handler = new FakeHttpMessageHandler()
.RespondTo("/token", () => HttpResponseMessageFactory.Json(new { access_token = "abc" }))
.RespondTo("/meteringpoints", () => HttpResponseMessageFactory.Json(meteringPoints));β±οΈ
ResponseGateis the trick for testing concurrency, cancellation and timeout behaviour: assign an uncompletedTaskCompletionSource.Task, assert on the in-flight state, then complete it.
HttpResponseMessageFactory.Json(new Foo { Id = 1 }); // 200 + JSON body
HttpResponseMessageFactory.Json(foo, HttpStatusCode.Created); // explicit status
HttpResponseMessageFactory.Json(foo, HttpStatusCode.OK, jsonOptions); // custom serializer options
HttpResponseMessageFactory.RawJson("""{"id":1,"extra":true}"""); // exact wire payload
HttpResponseMessageFactory.StatusCode(HttpStatusCode.Unauthorized); // status only
HttpResponseMessageFactory.NoContent(); // 204, no body
HttpResponseMessageFactory.ProblemDetails(
HttpStatusCode.BadRequest,
title: "Validation failed",
detail: "'id' must be greater than zero"); // RFC 7807π‘
RawJsonis how you reproduce payloads a strongly typed model cannot express - an unexpectednull, an extra property, or a field the real API returns in the wrong shape.π‘
ProblemDetailswrites raw RFC 7807 JSON with content-typeapplication/problem+json. It deliberately does not reference a generated model, so it works no matter whichProblemDetailstype your spec produces.
EndpointPerOperation resolves its HttpClient through IHttpClientFactory using Constants.HttpClientName, so you need a factory rather than a bare HttpClient:
var handler = new FakeHttpMessageHandler(() => HttpResponseMessageFactory.Json(foo));
var httpClientFactory = new StubHttpClientFactory(handler.CreateClient());
// Assert the client asked for the expected named HttpClient:
httpClientFactory.RequestedNames.Should().ContainSingle().Which.Should().Be(Constants.HttpClientName);Assert on what the generated client actually put on the wire, without hand-parsing URIs:
var request = handler.Request(0);
request.GetPath().Should().Be("/meteringpoints/12345/timeseries");
request.GetQuery().Should().Be("?from=2024-01-01&to=2024-01-31");
request.GetQueryParameter("from").Should().Be("2024-01-01");
request.GetHeader("X-Correlation-Id").Should().NotBeNull();
request.GetBearerToken().Should().Be("abc");
var body = await request.ReadBodyAsync();
var model = await request.ReadBodyAsAsync<CreateFooRequest>();π‘
GetHeaderfalls back toContent.Headers, soContent-Typeand friends resolve too. All helpers return values rather than asserting, so they work with any assertion library.
public class MyApiClientTests
{
[Fact]
public async Task GetMeteringPointsAsync_CallsExpectedUrl()
{
// Arrange
using var handler = new FakeHttpMessageHandler(
() => HttpResponseMessageFactory.Json(
new MeteringPointsApiResponse([new MeteringPoint { MeteringPointId = "571313100000000001" }])));
var sut = new MyApiClient(handler.CreateClient());
// Act
var response = await sut.GetMeteringPointsAsync(
new GetMeteringPointsParameters(Identifier: "12345678"),
TestContext.Current.CancellationToken);
// Assert
response.Result.Should().HaveCount(1);
handler.Requests.Should().ContainSingle()
.Which.RequestUri!.AbsolutePath.Should().Be("/api/meteringpoints/12345678");
}
[Fact]
public async Task NonSuccessStatusCode_ThrowsHttpRequestException()
{
using var handler = new FakeHttpMessageHandler(
() => HttpResponseMessageFactory.StatusCode(HttpStatusCode.Unauthorized));
var sut = new MyApiClient(handler.CreateClient());
var act = async () => await sut.GetTokenAsync(TestContext.Current.CancellationToken);
(await act.Should().ThrowAsync<HttpRequestException>())
.Which.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}
β οΈ InTypedClientmode a non-success status code throwsHttpRequestExceptionβ there is no result wrapper to inspect. See Working with C# Client Β§ Error Handling.
Build the endpoint through a minimal container so IHttpMessageFactory is wired correctly:
public static class TestHost
{
public static (TEndpoint Endpoint, FakeHttpMessageHandler Handler) CreateEndpoint<TInterface, TEndpoint>(
params Func<HttpResponseMessage>[] responses)
where TInterface : class
where TEndpoint : class, TInterface
{
var handler = new FakeHttpMessageHandler(responses);
var services = new ServiceCollection();
services.AddAtcRestClientCore();
services.AddSingleton<IHttpClientFactory>(new StubHttpClientFactory(handler.CreateClient()));
services.AddTransient<TInterface, TEndpoint>();
var endpoint = (TEndpoint)services.BuildServiceProvider().GetRequiredService<TInterface>();
return (endpoint, handler);
}
}[Fact]
public async Task ExecuteAsync_MapsOkResponse()
{
// Arrange
var (sut, handler) = TestHost.CreateEndpoint<IGetTokenEndpoint, GetTokenEndpoint>(
() => HttpResponseMessageFactory.Json(new StringApiResponse("access-token")));
// Act
var result = await sut.ExecuteAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert
result.IsOk.Should().BeTrue();
result.OkContent.Result.Should().Be("access-token");
handler.Requests.Should().ContainSingle()
.Which.RequestUri!.AbsolutePath.Should().Be("/api/token");
}
[Fact]
public async Task ExecuteAsync_MapsUnauthorizedResponse()
{
var (sut, _) = TestHost.CreateEndpoint<IGetTokenEndpoint, GetTokenEndpoint>(
() => HttpResponseMessageFactory.StatusCode(HttpStatusCode.Unauthorized));
var result = await sut.ExecuteAsync(cancellationToken: TestContext.Current.CancellationToken);
result.IsOk.Should().BeFalse();
result.IsUnauthorized.Should().BeTrue();
}Delegating handlers (bearer token, API key) sit between the client and the transport, so chain them onto the fake:
[Fact]
public async Task BearerTokenHandler_AddsAuthorizationHeader()
{
// Arrange
using var fake = new FakeHttpMessageHandler(() => HttpResponseMessageFactory.StatusCode(HttpStatusCode.OK));
var tokenProvider = Substitute.For<ITokenProvider>();
tokenProvider.GetTokenAsync(Arg.Any<CancellationToken>()).Returns("access-token");
var handler = new BearerTokenHandler(tokenProvider) { InnerHandler = fake };
using var client = new HttpClient(handler) { BaseAddress = new Uri("https://localhost") };
var sut = new MyApiClient(client);
// Act
await sut.GetMeteringPointsAsync(
new GetMeteringPointsParameters(Identifier: "12345678"),
TestContext.Current.CancellationToken);
// Assert
fake.Requests.Should().ContainSingle()
.Which.Headers.Authorization.Should().BeEquivalentTo(
new AuthenticationHeaderValue("Bearer", "access-token"));
}EndpointPerOperation generates Add{Namespace}Endpoints(). Assert that every interface resolves β this catches regressions when new operations are added to the specification:
[Fact]
public void AddMyApiEndpoints_RegistersEveryEndpointInterface()
{
// Arrange
var services = new ServiceCollection();
services.AddHttpClient(Constants.HttpClientName);
services.AddMyApiEndpoints();
// Act
using var provider = services.BuildServiceProvider();
// Assert
provider.GetService<IGetTokenEndpoint>().Should().NotBeNull();
provider.GetService<IGetMeteringPointsEndpoint>().Should().NotBeNull();
}Generated models are plain records/classes, so verify only what the specification promises β enum names, required members and JSON property names:
[Theory, AutoNSubstituteData]
public void MeteringPoint_RoundTripsThroughJson(MeteringPoint sut)
{
var json = JsonSerializer.Serialize(sut);
var actual = JsonSerializer.Deserialize<MeteringPoint>(json);
actual.Should().BeEquivalentTo(sut);
}π‘ Prefer snapshot/approval testing over asserting field-by-field when the model is large.
| Goal | Seam | Double |
|---|---|---|
My service branches on a 404
|
π Contract |
XEndpointResult.NotFound() factory |
| My service calls the API exactly once | π Contract | endpoint.Received(1).ExecuteAsync(...) |
| Path/query parameters are bound correctly | π Transport |
handler.Request(0).GetPath() / .GetQueryParameter("x")
|
| Request body serializes as expected | π Transport | await handler.Request(0).ReadBodyAsAsync<T>() |
| Auth header is attached | π Transport | handler.Request(0).GetBearerToken() |
| The API returned a problem detail | π Transport | HttpResponseMessageFactory.ProblemDetails(...) |
| A multi-call flow hits several routes | π Transport | Chain handler.RespondTo("/path", ...)
|
| Retry policy fires | π Transport | Queue multiple responses in the fake |
| Cancellation is honoured | π Transport | Hold ResponseGate open, then cancel |
| Every endpoint is registered | π§© DI | BuildServiceProvider().GetService<T>() |
| Full request/response pipeline | π Server |
WebApplicationFactory / WireMock |
-
Depend on abstractions. In
EndpointPerOperationinjectIXEndpoint. InTypedClientinjectI{ClientName}. Both are generated for you β never take a dependency on the concrete class. - Keep transport tests thin. One or two per operation to prove binding and status mapping. Everything else belongs at the contract seam.
-
Never assume success. Always cover the failure branch β
IsOk == falseforEndpointPerOperation,HttpRequestExceptionforTypedClient. -
Use the generated result factories. In
EndpointPerOperation, build results withXEndpointResult.Ok(...)/.NotFound()rather than substitutingIXEndpointResult. A hand-configured substitute can leave status flags inconsistent; the factories cannot. -
Don't hand-roll test doubles. Reference
Atc.Rest.Api.Client.TestingforFakeHttpMessageHandler,StubHttpClientFactory,HttpResponseMessageFactoryand the request assertion helpers. Keep only spec-specific builders (aModelBuilder, say) in your own test project. -
Regenerate, then run. Generated method names change when
operationIdchanges β a compiling test suite is your rename safety net.
| Sample | Mode | Shows |
|---|---|---|
sample/ThirdParty-Typed-Clients/Eloverblik.ThirdPartyApi.Client.Tests |
TypedClient |
Contract-seam mocking via I{ClientName}, transport tests and Add{ClientName}() DI tests |
sample/ThirdParty-EPO-Clients/Eloverblik.ThirdPartyApi.Client.Tests |
EndpointPerOperation |
Contract-seam mocking, endpoint tests against a fake transport and DI registration tests |
Both samples reference Atc.Rest.Api.Client.Testing rather than carrying their own copies of the HTTP test doubles β they are the acceptance criteria for the package's API surface.
- π Working with C# Client β generating and wiring up the client
- π§ͺ Working with Code Coverage β coverage collection and thresholds
- π§ͺ Working with E2E Testing β the frontend/end-to-end equivalent
- π Working with Resilience β retry and circuit breaker policies
- π Working with Security β OAuth token management
- π Marker Files β
generationMode,httpClientName,clientSuffix
π Home
- πΌ FAQ Business Value
- π Getting Started with Basic
- π οΈ Getting Started with CLI
- π Migration Guide
- β¬οΈ Upgrading to v2
- π Working with OpenAPI
- π³οΈ Working with Nullability
- π οΈ Working with CLI
- π How-To Guides
- π Working with Security
- π¦ Working with Rate Limiting
- π Working with Resilience
- ποΈ Working with Caching
- π’ Working with Versioning
- β Working with Validations
- π Working with Webhooks
- βοΈ Working with Aspire
- π£οΈ Working with Endpoint Definitions
- π Working with Multi-Part Specs
- π§ͺ Working with Code Coverage
- π Working with C# Client
- π§ͺ Working with C# Client Testing
- π¦ Working with TypeScript Client
- πͺ Showcase Demo
- π§ͺ Working with E2E Testing
- βοΈ Working with Configuration
- π Marker Files
- π API Reference
- π Analyzer Rules
- β FAQ and Troubleshooting
- πΊοΈ Roadmap
- π§ Development Notes
- π¦ GitHub Repository
- π₯ NuGet Package
- π Report Issues