DEV Community

Ayman
Ayman

Posted on

Building a WPF API Client: HttpClient, Local Storage, and Lessons from QA


NorthAPI is a local-first REST API client for Windows, built with C#, .NET 10, and WPF. It sends HTTP requests, displays responses, saves collections and history locally, and generates C# and cURL examples.

Its latest documented release QA run finished with 61 tests executed, 60 passed, and one failed.

The failure was useful: a generated cURL request looked correct, but its Unicode body differed from the original when executed through the tested native Windows cURL. Compiling the C# examples had not established that both generators reproduced the same request.

This article walks through the implementation and the checks that exposed those gaps. NorthAPI is still a preview; the release recommendation remains BLOCKED.

NorthAPI workspace with saved requests, environment selection, and a JSON response

Actual application capture using an isolated local demo server and synthetic data.

1. Separate the editor from the HTTP engine

The current UI uses reusable WPF UserControls with code-behind for interactions. It is not a full MVVM sample. The useful separation is between controls and the services that build, send, store, and export requests.

Component Responsibility
RequestEditor Capture settings, handle Send/Cancel, and present results.
BodyEditor / AuthorizationEditor Edit and restore their sections of a request.
RequestDefinition Serializable settings, including unresolved environment references.
RequestBuilder Resolve a copy, construct the URI and body, and apply headers/auth.
HttpRequestEngine Execute HTTP, enforce a deadline, and read a bounded preview.
ResponseViewer Render status, timing, size, body, and headers.
WorkspaceStore Persist collections, environments, and history.
CodeGenerator Produce examples from the request definition used by Send.

The request path is:

WPF editor
  -> RequestDefinition snapshot
  -> resolve variables in a copy
  -> build HttpRequestMessage
  -> HttpRequestEngine.SendAsync
  -> HttpResponseResult
  -> WPF response viewer
Enter fullscreen mode Exit fullscreen mode

Taking a snapshot before sending gives the operation a stable set of settings. Resolving a deep copy preserves saved templates such as {{baseUrl}}/users instead of replacing them with one environment's URL.

The response is a separate result object:

public sealed record HttpResponseResult(
    int StatusCode,
    string ReasonPhrase,
    string Body,
    string? MediaType,
    long BodyBytes,
    bool IsTruncated,
    TimeSpan Elapsed,
    HttpResponseHeader[] Headers,
    string[] Cookies);
Enter fullscreen mode Exit fullscreen mode

It can represent a completed HTTP exchange whose status happens to be 401 or 500.

2. Reuse HttpClient and own the operation's deadline

The engine's default transport is a shared client:

private static readonly HttpClient SharedClient = new(
    new SocketsHttpHandler
    {
        AutomaticDecompression =
            DecompressionMethods.GZip |
            DecompressionMethods.Deflate |
            DecompressionMethods.Brotli,
        AllowAutoRedirect = true,
        UseCookies = false,
        PooledConnectionLifetime = TimeSpan.FromMinutes(5)
    })
{
    Timeout = Timeout.InfiniteTimeSpan
};
Enter fullscreen mode Exit fullscreen mode

This avoids constructing and disposing a connection pool for every click. The pooled connection lifetime also gives connections a finite lifetime. Five minutes is a project setting, not a universal networking recommendation. Microsoft's HttpClient lifetime guidance explains the connection-pooling and DNS tradeoffs.

Automatic cookie handling is disabled. The UI can inspect response Set-Cookie values, but the application does not maintain a browser-style cookie jar.

The engine also accepts an injected HttpClient. Tests use this to isolate DNS failure from proxy behavior without changing the application's default transport.

Each send has a linked cancellation source. These are excerpts from the engine; validation and preview reading are separate parts of the method:

using var deadline =
    CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

deadline.CancelAfter(timeout);

using var response = await client.SendAsync(
    request,
    HttpCompletionOption.ResponseHeadersRead,
    deadline.Token).ConfigureAwait(false);

await using var stream = await response.Content
    .ReadAsStreamAsync(deadline.Token)
    .ConfigureAwait(false);
Enter fullscreen mode Exit fullscreen mode

ResponseHeadersRead lets the engine read the body itself, but it creates a timeout boundary that is easy to overlook. Once that SendAsync completes, a client-level timeout does not independently supervise later body reads. The content operations need their own deadline handling. This is documented in HttpCompletionOption.

NorthAPI passes the same deadline token through stream reads. Its tests cover a server that delays headers and one that sends headers promptly but delays the body.

Caller cancellation and deadline expiration are distinguished:

catch (OperationCanceledException exception)
    when (!cancellationToken.IsCancellationRequested &&
          deadline.IsCancellationRequested)
{
    throw new TimeoutException(
        $"Request timed out after {timeout.TotalSeconds:0.##} seconds.",
        exception);
}
Enter fullscreen mode Exit fullscreen mode

A user clicking Cancel gets a cancellation result. An expired deadline gets a timeout result.

3. Test the dispatcher, not just the asynchronous method

The request editor awaits the engine from its WPF event handler. The engine's internal awaits use ConfigureAwait(false); the editor's await resumes through its captured WPF context and updates controls there.

During a request, the editor holds a CancellationTokenSource and changes Send to Cancel. Clicking it again cancels that source. Closing a request tab also cancels an active operation and releases the editor's store subscription.

The UI test checks more than eventual completion. It runs a DispatcherTimer while a slow local request is pending, confirms the timer keeps ticking, and verifies that cancellation or timeout restores Send and displays an understandable error.

This establishes responsiveness during those network waits. It does not establish that every large JSON document formats without a pause: parsing and rendering still have CPU and allocation costs on the UI side.

4. Keep HTTP failures inspectable

An API client needs the body of a 400 just as much as the body of a 200.

The engine reads the response without calling EnsureSuccessStatusCode(). Status, headers, and body are returned together. The viewer can show an error-colored status badge while keeping the response available for inspection.

Transport failures take a different path: invalid settings, DNS resolution, connection refusal, TLS validation, cancellation, and timeout receive separate error messages.

The deterministic integration suite exercises:

[Theory]
[InlineData(400)]
[InlineData(401)]
[InlineData(404)]
[InlineData(429)]
[InlineData(500)]
[InlineData(503)]
public async Task Non_success_responses_remain_inspectable(int status)
{
    await using var server = new QaServer();
    using var request = new HttpRequestMessage(
        HttpMethod.Get, server.Url + "/status/" + status);

    var result = await new HttpRequestEngine().SendAsync(
        request, TimeSpan.FromSeconds(5));

    Assert.Equal(status, result.StatusCode);

    using var json = JsonDocument.Parse(result.Body);
    Assert.Equal(status, json.RootElement.GetProperty("error").GetInt32());

    Assert.True(
        result.BodyBytes > 0 &&
        result.Elapsed > TimeSpan.Zero &&
        result.Headers.Length > 0);
}
Enter fullscreen mode Exit fullscreen mode

QaServer is the repository's loopback fixture. Companion WPF tests render these results so a successful engine assertion does not conceal a broken error-state UI.

5. Give response size and time precise meanings

The preview is capped at 8 MiB. The engine reads at most one additional byte to determine whether it is truncated, then retains the bounded preview.

  • Size counts retained body bytes after transport decompression. It is not the length of the formatted JSON string or the compressed transfer size.
  • Time measures the operation through reading the preview. For a truncated response, it is not the time required to download the complete original body.

The viewer displays truncation, including what Copy and Save will contain. It supports Pretty/Raw views, JSON token colors, line numbers, and header filtering. Invalid JSON falls back to text; an empty response gets an explicit empty-body display.

Syntax highlighting is limited to 256 KiB of displayed text. Larger previews remain readable without token coloring. A preview cap limits input size, but it does not make parsing, formatted text, or WPF document objects free.

NorthAPI raw JSON request editor alongside a successful response

The body editor supports raw formats, text form-data, URL-encoded forms, binary files, and GraphQL query/variables input.

6. Preserve templates and reject unresolved variables

Environment values can appear in URLs, parameters, headers, applicable auth fields, and request bodies.

The resolver builds a dictionary from enabled variables and resolves references recursively. It tracks visited names and rejects missing variables, cycles, and excessive nesting.

For example:

baseUrl = https://api.example.com
usersUrl = {{baseUrl}}/users
Enter fullscreen mode Exit fullscreen mode

A request can use {{usersUrl}}, while the saved definition retains the reference. These settings would be rejected:

a = {{b}}
b = {{a}}
Enter fullscreen mode Exit fullscreen mode

The engine should not discover a missing variable by attempting DNS resolution for an unresolved placeholder. Resolution and validation happen before sending.

Substitution is textual. It is not a JSON templating language that automatically escapes each inserted value for its surrounding context. Values inserted into a raw JSON body still need to produce the JSON the API expects.

7. Local-first storage still needs secret protection

One concrete QA finding was plaintext workspace persistence.

Protecting only a password property would miss several other locations: a custom API-key header, a token in an environment, credentials in a URL, or a sensitive body preserved in history.

The revised store protects the entire serialized workspace payload with Windows DPAPI:

var ciphertext = ProtectedData.Protect(
    plaintext,
    optionalEntropy: null,
    scope: DataProtectionScope.CurrentUser);
Enter fullscreen mode Exit fullscreen mode

The ciphertext is Base64-encoded into a JSON envelope. DPAPI supplies the protection; Base64 packages the encrypted bytes for storage.

The write path serializes a snapshot, protects it, writes a temporary sibling file, flushes it, and replaces the destination. An in-process lock serializes store updates. This is a local file store, not a multi-process database or a synchronization engine.

Migration and recovery need the same attention as normal saves:

  • Valid legacy plaintext files migrate to protected storage.
  • Old application recovery backups are protected too.
  • Corrupt or undecryptable state is preserved, and saving is disabled until recovery.
  • Tests check that synthetic secrets do not remain in plaintext storage artifacts.

CurrentUser ties protection to the Windows user context. It does not provide a boundary against malicious code running as that user, and a protected workspace file is not a portable backup for a different account. Microsoft's DPAPI documentation describes the platform and scope model.

Explicit exports are separate. Collection exports and generated code retain the values the user requested, including credentials when present. Quietly removing those values would change the request being exported. Those outputs need to be treated as sensitive.

NorthAPI authorization editor with a demo username and a masked demo password

Screenshot credentials are synthetic. The editor supports Basic, Bearer, API Key, and manually supplied OAuth 2.0 access tokens; token acquisition and refresh are not implemented.

8. Keep diagnostics useful without echoing credentials

Raw exception messages can carry values from invalid request settings. Persisting those messages in history creates another potential disclosure path.

NorthAPI maps failures to fixed categories. For example, these arms belong to the SafeDiagnostics.RequestError switch expression:

HttpRequestException
{
    HttpRequestError: HttpRequestError.SecureConnectionError
} => "The TLS certificate could not be validated or the secure connection failed.",

HttpRequestException
{
    HttpRequestError: HttpRequestError.NameResolutionError
} => "The server name could not be resolved. Check the URL and DNS settings.",
Enter fullscreen mode Exit fullscreen mode

The UI and persisted error summaries use that mapping instead of inserting raw exception text.

Security tests use synthetic canaries, capture Console and Trace during relevant store/export/code-generation operations, and assert that credentials are not emitted. Sensitive comparisons use boolean assertions so a failure does not print the expected and actual secret.

That is a defined scope, not a claim about every possible debugger, operating-system dump, or externally enabled tracing tool.

9. Execute generated examples and compare what the server receives

Sending and code generation start from RequestDefinition and the selected environment. Sharing that model reduces drift, but cannot prove two clients send identical requests.

The code-generation checks therefore:

  1. Send the original through NorthAPI.
  2. Generate C# and cURL examples.
  3. Compile and execute the C# example.
  4. Execute native cURL with the generated argument stream.
  5. Compare the server's observed method, target URL, application headers, and body.

GET, POST JSON, custom-header, Basic, and Bearer cases matched.

An additional Unicode case used this non-secret body:

{"text":"café"}
Enter fullscreen mode Exit fullscreen mode

The C# substep compiled, executed, and matched. The tested native Windows cURL 7.79.1 execution produced a different body, so the combined test failed.

The generator targets a POSIX shell. The recorded native-Windows test used a test-only POSIX literal quote decoder to produce arguments for the real curl.exe; it did not establish end-to-end copy/paste behavior in bash or sh. The Windows argv/code-page versus intended-shell cause was not fully isolated.

The report keeps both facts visible: a real native cURL execution failed its equivalence check, and full POSIX-shell validation remains pending. Removing Unicode from the fixture or rewriting the expected body would hide the finding.

10. Use deterministic fixtures and keep release claims separate

The local server supplies controlled status codes, malformed JSON, empty responses, delayed headers, and delayed bodies. A local TLS fixture presents an untrusted certificate; the client rejects it without disabling validation.

External HTTPS tests also exercise GET, POST, PUT, PATCH, and DELETE against httpbingo's documented method endpoints with synthetic payloads and an explicit QA User-Agent.

Earlier attempts returned 402. A later setup using documented method endpoints and an explicit User-Agent passed. Because both route and User-Agent changed, the exact cause of the earlier result was not isolated. The failed evidence was retained.

For persistence, UI Automation launches the actual app, sends and saves a request, closes it, and starts another process. The restarted app displays the saved collection, history, and environment. Temporary data directories keep fixtures away from the user's workspace.

The latest documented evidence is:

Check Recorded outcome
Discoverable release suite 61 executed: 60 passed, 1 failed.
Existing HTTP/workspace console checks 62 passed, counted separately.
Existing WPF workflow console checks 16 passed, counted separately.
Self-contained win-x64 publish Succeeded.
Published executable smoke test on development PC Passed startup, HTTP, save, shutdown, and restart checks.
Separate clean Windows PC without .NET Blocked: no such environment was available for the recorded run.
Native Windows cURL Unicode equivalence Failed; remains open.
End-to-end POSIX-shell execution Not established by the recorded tests.

Run the discoverable suite from the repository root:

dotnet test NorthAPI.slnx --configuration Release --logger "trx;LogFileName=release-qa.trx" --results-directory artifacts/qa -p:IntermediateOutputPath=obj/qa10/
Enter fullscreen mode Exit fullscreen mode

The known cURL regression makes that suite exit nonzero. These are recorded results, not a fresh test run performed while writing this article.

A self-contained build can be created with:

dotnet publish northApi.csproj --configuration Release --runtime win-x64 --self-contained true --output artifacts/win-x64
Enter fullscreen mode Exit fullscreen mode

The entire output directory is required. A successful publish and a development-PC smoke test do not prove that startup, permissions, networking, and shutdown work on a separate machine without .NET installed.

What this project has made clearer

The most useful boundaries have been concrete: a serializable request snapshot, a transport service with an operation-wide deadline, a response result that retains HTTP failures, and a store that protects all of its sensitive payload.

The most useful QA checks crossed those boundaries. They kept a dispatcher ticking during a timeout, restarted the actual process, decrypted and reloaded protected state, and compared generated clients with the request the server received.

NorthAPI's original project code is MIT-licensed. Imported artwork has separate provenance notes in the repository. The preview still has known release blockers, and the QA report is part of the project rather than a claim that every box is green.

Source and setup: deman404/NorthAPI.
Implementation details and execution evidence: RELEASE_QA.md.

If you work on desktop developer tools, what would you test next: authentication boundaries, streamed responses, persistence failures, or generated-client compatibility? A focused bug report with a synthetic reproduction would be particularly useful.

Top comments (3)

Collapse
 
alexshev profile image
Alex Shev •

The Unicode failure is a good case for round-trip tests on generated commands. Alongside non-ASCII text, shell metacharacters and quotes can expose cases where a command looks right but changes the transmitted payload.

Collapse
 
deman404 profile image
Ayman •

Great point! You're absolutely right that generating a command that looks correct doesn't necessarily guarantee payload integrity.
I'll add round-trip tests for generated cURL commands, covering Unicode, quotes, shell metacharacters, and multiline JSON across different shells.
The goal is to verify that the executed command sends exactly the same HTTP payload as the original NorthAPI request.
Thanks for the valuable feedback! This will definitely help improve NorthAPI's reliability.

Collapse
 
alexshev profile image
Alex Shev •

That test set will catch the failures that examples usually miss. I’d also record the shell, locale, and encoding in each fixture, then compare the captured request body byte-for-byte with the original. That makes a failure attributable to generation, quoting, or the shell boundary instead of treating “the cURL looked right” as sufficient evidence.