I/O, Streams & HTTP · Lesson 13

Validate HTTP media type and JSON shape

A successful response still needs representation and domain checks.

Validate the representation after HTTP success

This caller accepts a narrow response contract: successful status, an application/json media type, an object with a nonblank string Name, and a positive Count that fits Int32. These are separate gates. A successful HTTP exchange cannot establish the media type or domain invariants by itself, and valid JSON does not imply that every property has the expected kind. The offline ModelHandler supplies controlled responses so each gate can be inspected without server variability. Its messages still use real HttpResponseMessage, HttpContent and JsonDocument APIs rather than a replacement JSON parser.

Follow the three deliberately different responses

For /ok the handler returns the object Name=rune and Count=3 using JSON content. EnsureSuccessStatusCode accepts the status, media-type matching succeeds, and the property checks produce rune:3. For /bad the representation is still JSON but Name is blank and Count is negative, so the caller prints invalid model. For /text the handler sends text/plain and not json as its body. That route prints unsupported media type before parsing is attempted. The third route therefore demonstrates a representation rejection, not a caught JsonException.

Keep JsonElement values inside their document lifetime

The request and response remain alive while the content is read. A using declaration then owns the parsed JsonDocument until the current loop iteration ends. JsonElement values obtained from its root refer to that document's storage, so the sample consumes them before disposal and returns only printed scalar observations. Short-circuit Boolean evaluation prevents reading a missing property or calling a getter on the wrong JSON kind. The final GetProperty calls are justified by the earlier successful checks; moving them before validation would remove that protection and introduce another failure path.

Make rejection policy narrower than universal JSON support

The media-type policy intentionally accepts exactly application/json, compared without case sensitivity. It does not claim support for every vendor type ending in +json, every encoding declaration or every possible representation negotiation rule. Likewise, Name and Count are case-sensitive JSON property names here. The sample does not reject additional members or duplicate names, and ReadAsStringAsync buffers these small controlled bodies. Those choices belong in the stated scope. If your application needs a byte budget, a schema policy or a different naming contract, add and test those requirements explicitly.

Practice: separate malformed syntax from invalid content

Create a fourth route with application/json content whose text is syntactically malformed. Decide where JsonException should become a named rejected-import outcome and catch it at that boundary. Keep the existing /bad route unchanged so malformed syntax and invalid domain content remain independent cases. The key hint is that a JSON object containing an empty name is not malformed JSON. Predict distinct observations for a parser failure, an unsupported media type and a successfully parsed object that fails the application predicate.

Practice: challenge every required member independently

Add separate controlled cases for a missing Name, a null Name, Count as a string, zero Count and a numeric Count larger than Int32 can represent. Use one violation at a time so another failing condition cannot conceal a missing check. Keep an independently specified expected outcome beside each input. As a further extension, decide whether surrounding whitespace in an accepted name is preserved or normalized; the current predicate only rejects a name made entirely of whitespace and does not silently trim the value that is printed.

Review the complete acceptance predicate

A validated model boundary is a sequence of justified operations rather than one optimistic deserialization call. Check status, decide representation policy, parse the syntax, inspect kinds and presence, then establish domain values. Retain the owner of any borrowed JSON elements through that sequence. The three baseline outputs identify three different acceptance decisions, but they are selected examples rather than exhaustive validation evidence. Use focused counterexamples and a bounded-body reader when adapting this pattern to an untrusted real service, and keep transport integration checks separate from the offline contract suite.

  • A supported media type can still contain malformed or invalid data.
  • Validate kind, presence and value before using typed JSON getters.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;

using var client = new HttpClient(new ModelHandler());
foreach (string path in new[] { "ok", "bad", "text" }) {
    using var request = new HttpRequestMessage(HttpMethod.Get, "https://offline.invalid/" + path);
    using var response = await client.SendAsync(request, CancellationToken.None); response.EnsureSuccessStatusCode();
    if (!string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("unsupported media type"); continue; }
    using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(CancellationToken.None));
    JsonElement root = document.RootElement;
    bool valid = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("Name", out var name) && name.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(name.GetString()) && root.TryGetProperty("Count", out var count) && count.ValueKind == JsonValueKind.Number && count.TryGetInt32(out int value) && value > 0;
    Console.WriteLine(valid ? root.GetProperty("Name").GetString() + ":" + root.GetProperty("Count").GetInt32() : "invalid model");
}
sealed class ModelHandler : HttpMessageHandler {
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
        token.ThrowIfCancellationRequested(); string path = request.RequestUri!.AbsolutePath;
        string body = path == "/ok" ? """{"Name":"rune","Count":3}""" : path == "/bad" ? """{"Name":"","Count":-1}""" : "not json";
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, path == "/text" ? "text/plain" : "application/json") });
    }
}

Expected output

rune:3
invalid model
unsupported media type
Try this
Protocol success, representation compatibility and domain validity should have separate decision points.

Further reading