Project · I/O & HTTP

Verify an offline bounded HTTP importer

Reuse one HttpClient, inject real in-memory HTTP responses, and check nine status, byte-budget and model boundaries.

All C# source files below are loaded together in the playground. Files written by a run live in its virtual directory.

Program.cs

await ImportChecks.RunAsync();

Import/ImportSummary.cs

public sealed record ImportSummary(int Count, decimal Total);

Import/ImportService.cs

using System.Net;
using System.Net.Http;
using System.Text.Json;
public sealed class ImportService {
    private readonly HttpClient client;
    public ImportService(HttpClient client) { this.client = client; }
    public async Task<ImportSummary> ReadAsync(string route, CancellationToken token) {
        using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("https://offline.invalid/" + route));
        using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
        response.EnsureSuccessStatusCode();
        if (response.StatusCode == HttpStatusCode.NoContent) throw new InvalidDataException("No document");
        if (!string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Media type");
        using Stream body = await response.Content.ReadAsStreamAsync(token);
        using var bytes = new MemoryStream(); byte[] buffer = new byte[16]; int read;
        while ((read = await body.ReadAsync(buffer.AsMemory(), token)) != 0) {
            if (bytes.Length + read > 256) throw new InvalidDataException("Byte limit");
            bytes.Write(buffer, 0, read);
        }
        using var document = JsonDocument.Parse(bytes.ToArray()); JsonElement root = document.RootElement;
        if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() is < 1 or > 4) throw new InvalidDataException("Array shape");
        var names = new HashSet<string>(StringComparer.Ordinal); decimal total = 0; int count = 0;
        foreach (JsonElement row in root.EnumerateArray()) {
            if (row.ValueKind != JsonValueKind.Object || !row.TryGetProperty("Name", out var name) || name.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(name.GetString()) || !row.TryGetProperty("Amount", out var amount) || amount.ValueKind != JsonValueKind.Number || !amount.TryGetDecimal(out decimal value) || value < 0 || !names.Add(name.GetString()!)) throw new InvalidDataException("Invalid row");
            total = checked(total + value); count++;
        }
        return new ImportSummary(count, total);
    }
}

Testing/ScenarioHandler.cs

using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
public sealed class ScenarioHandler : HttpMessageHandler {
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
        token.ThrowIfCancellationRequested();
        string route = request.RequestUri!.AbsolutePath.TrimStart('/');
        string body = route switch {
            "large" => new string(' ', 257),
            "duplicate" => """[{"Name":"A","Amount":1},{"Name":"A","Amount":2}]""",
            "negative" => """[{"Name":"A","Amount":-1}]""",
            "shape" => """{"Name":"A","Amount":1}""",
            _ => """[{"Name":"A","Amount":12.5},{"Name":"B","Amount":4.25}]"""
        };
        var content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(body), writable: false));
        content.Headers.ContentType = new MediaTypeHeaderValue(route == "media" ? "text/plain" : "application/json");
        HttpStatusCode status = route == "missing" ? HttpStatusCode.NotFound : route == "empty" ? HttpStatusCode.NoContent : HttpStatusCode.OK;
        if (route == "empty") { content.Dispose(); content = new StreamContent(new MemoryStream(Array.Empty<byte>())); }
        return Task.FromResult(new HttpResponseMessage(status) { Content = content });
    }
}

Testing/ImportChecks.cs

using System.Globalization;
using System.Net.Http;
public static class ImportChecks {
    public static async Task RunAsync() {
        using var handler = new ScenarioHandler(); using var client = new HttpClient(handler, disposeHandler: false);
        var service = new ImportService(client); int checks = 0;
        ImportSummary summary = await service.ReadAsync("valid", CancellationToken.None);
        if (summary != new ImportSummary(2, 16.75m)) throw new Exception("Valid summary failed"); checks++;
        async Task Reject<T>(string route, CancellationToken token) where T : Exception {
            try { await service.ReadAsync(route, token); } catch (T) { checks++; return; }
            throw new Exception("Expected " + typeof(T).Name + " for " + route);
        }
        await Reject<HttpRequestException>("missing", CancellationToken.None);
        await Reject<InvalidDataException>("empty", CancellationToken.None);
        await Reject<InvalidDataException>("media", CancellationToken.None);
        await Reject<InvalidDataException>("large", CancellationToken.None);
        await Reject<InvalidDataException>("duplicate", CancellationToken.None);
        await Reject<InvalidDataException>("negative", CancellationToken.None);
        await Reject<InvalidDataException>("shape", CancellationToken.None);
        using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
        await Reject<OperationCanceledException>("valid", cancellation.Token);
        Console.WriteLine("import checks:" + checks);
        Console.WriteLine("validated total:" + summary.Total.ToString(CultureInfo.InvariantCulture));
        Console.WriteLine("transport:injected handler");
    }
}

BoundedImport.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>