Skip to content

Working with CSharp Client Testing

David Kallesen edited this page Sep 10, 2026 · 4 revisions

πŸ§ͺ Working with C# 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.


🧭 Pick Your Seam

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

πŸ“¦ Test Project Setup

<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.Test supplies 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.Testing supplies 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.


πŸ”Œ Testing at the Contract Seam

EndpointPerOperation βœ…

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);
	}
}

Faking a successful result

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 body

Each 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 forgotten IsOk.Returns(...) silently yields false and sends the test down the wrong branch. The generated factories keep every flag internally consistent.

ℹ️ If your spec declares its own ProblemDetails / ValidationProblemDetails schema, EndpointPerOperation mode ignores it in favour of the built-in type and reports ATC_API_SCH021. There is therefore only ever one ProblemDetails type in scope, and no qualification is needed at the call site. See Marker-Files#-error-response-formats.

Adding test-facing members to a result

{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.

TypedClient βœ…

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 ModelBuilder factory to your test project and keep the noise in one place.

Registering the client

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();
}

βš–οΈ Interface Mocking vs. Stubbing HttpClient

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();
}

How they compare

πŸ”Œ 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

Which to reach for

  • 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 HttpClient to test business logic. Encoding a JSON literal just to drive an if statement 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.

🚚 Testing at the Transport Seam

Use this to verify the generated client itself: URLs, path/query/header binding, serialization and status handling.

πŸ“¦ The Atc.Rest.Api.Client.Testing package

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

FakeHttpMessageHandler

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));

⏱️ ResponseGate is the trick for testing concurrency, cancellation and timeout behaviour: assign an uncompleted TaskCompletionSource.Task, assert on the in-flight state, then complete it.

HttpResponseMessageFactory

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

πŸ’‘ RawJson is how you reproduce payloads a strongly typed model cannot express - an unexpected null, an extra property, or a field the real API returns in the wrong shape.

πŸ’‘ ProblemDetails writes raw RFC 7807 JSON with content-type application/problem+json. It deliberately does not reference a generated model, so it works no matter which ProblemDetails type your spec produces.

StubHttpClientFactory

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);

HttpRequestMessageExtensions

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>();

πŸ’‘ GetHeader falls back to Content.Headers, so Content-Type and friends resolve too. All helpers return values rather than asserting, so they work with any assertion library.

TypedClient transport test βœ…

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);
	}
}

⚠️ In TypedClient mode a non-success status code throws HttpRequestException β€” there is no result wrapper to inspect. See Working with C# Client Β§ Error Handling.

EndpointPerOperation transport test βœ…

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();
}

πŸ” Testing Auth Handlers

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"));
}

🧩 Testing DI Registration

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();
}

🧾 Testing Generated Models

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.


πŸ“‹ Cheat Sheet

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

βœ… Recommendations

  1. Depend on abstractions. In EndpointPerOperation inject IXEndpoint. In TypedClient inject I{ClientName}. Both are generated for you β€” never take a dependency on the concrete class.
  2. Keep transport tests thin. One or two per operation to prove binding and status mapping. Everything else belongs at the contract seam.
  3. Never assume success. Always cover the failure branch β€” IsOk == false for EndpointPerOperation, HttpRequestException for TypedClient.
  4. Use the generated result factories. In EndpointPerOperation, build results with XEndpointResult.Ok(...) / .NotFound() rather than substituting IXEndpointResult. A hand-configured substitute can leave status flags inconsistent; the factories cannot.
  5. Don't hand-roll test doubles. Reference Atc.Rest.Api.Client.Testing for FakeHttpMessageHandler, StubHttpClientFactory, HttpResponseMessageFactory and the request assertion helpers. Keep only spec-specific builders (a ModelBuilder, say) in your own test project.
  6. Regenerate, then run. Generated method names change when operationId changes β€” a compiling test suite is your rename safety net.

πŸ“‚ Samples

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.


πŸ”— Related Pages

🏠 Home

πŸ’Ό Why This Tool?

πŸ“– Getting Started

βš™οΈ Features

🌐 Frontend

πŸ“‹ Reference


πŸ”— Resources

Clone this wiki locally