Skip to content

Content Formatting

Aryeh Citron edited this page Mar 29, 2026 · 13 revisions

Diagram notes can contain a lot of raw HTTP data — headers, JSON bodies, tokens, cookies, HTML responses. Without processing, this often results in diagrams that are noisy, unreadable, or too large to render. The content formatting system lets you transform, redact, and reshape this content before it appears in diagrams.

There are four levels of control:

  1. DiagramFocus — Declarative per-request field highlighting. Specify which JSON fields matter and the library automatically emphasises them and de-emphasises everything else. No string manipulation required.
  2. ReportConfigurationOptions.RequestResponsePostProcessor — A single Func<string, string> applied to both requests and responses after formatting. The simplest approach for global redaction and cleanup.
  3. ReportConfigurationOptions.RequestResponseMidProcessor — A single Func<string, string> applied after library formatting (JSON pretty-print) but before DiagramFocus styling and header wrapping. Ideal for redacting values from clean, readable text without needing to account for PlantUML markup.
  4. DiagramsFetcherOptions — Fine-grained control with separate pre-, mid-, and post-processors for requests and responses independently. For advanced scenarios.

DiagramFocus (Field Highlighting)

When a JSON body has many fields but only a few are relevant to a particular test, the diagram can feel noisy. DiagramFocus lets you declaratively highlight the fields that matter — the library automatically emphasises them and de-emphasises (or hides) the rest. No string manipulation or regex required.

Basic Usage

The recommended approach is the fluent WithDiagramFocus() extension method on HttpClient. This makes it immediately visible which fields are being focused and that the focus applies to that specific HTTP call:

// Highlight "Flour" and "Eggs" in the request body for this call
var response = await client.WithDiagramFocus()
    .OnRequest<CreateCakeRequest>(x => x.Flour, x => x.Eggs)
    .PostAsJsonAsync("/api/cake",
        new CreateCakeRequest { Milk = "whole", Eggs = "free-range", Flour = "plain" });

The resulting diagram note will render Flour and Eggs in bold (the default emphasis), while Milk appears in light gray (the default de-emphasis). The structural braces { and } are left unstyled.

You can focus both request and response fields for the same call:

var response = await client.WithDiagramFocus()
    .OnRequest<CreateCakeRequest>(x => x.Flour)
    .OnResponse<CreateCakeResponse>(x => x.Id, x => x.Status)
    .PostAsJsonAsync("/api/cake", requestBody);

The .OnRequest() and .OnResponse() calls can appear in any order. The FocusedHttpClient builder exposes all standard HttpClient methods — GetAsync, PostAsync, PutAsync, PatchAsync, DeleteAsync, SendAsync, and their *AsJsonAsync/GetFromJsonAsync variants — so you never need to break the chain.

String overloads

If you don't have a strongly-typed DTO or prefer raw field names, string overloads are available:

var response = await client.WithDiagramFocus()
    .OnRequest("flour", "eggs")
    .OnResponse("id", "status")
    .PostAsJsonAsync("/api/cake", requestBody);

Static API (alternative)

The static DiagramFocus.Request<T>() / DiagramFocus.Response<T>() methods are still available and work identically. These are useful in BDD step definitions or other contexts where the HTTP call is made in a separate method:

DiagramFocus.Request<CreateCakeRequest>(x => x.Flour, x => x.Eggs);
var response = await client.PostAsJsonAsync("/api/cake", requestBody);

Prefer WithDiagramFocus() over the static API when the focus call and the HTTP call are in the same method. The fluent chain makes the association between focus and HTTP call explicit and harder to misread.

How It Works

  1. Before the HTTP callWithDiagramFocus().OnRequest<T>() / .OnResponse<T>() (or the static DiagramFocus API) store the property names in an AsyncLocal context scoped to the current test.
  2. During the HTTP callTestTrackingMessageHandler consumes the pending focus fields and attaches them to the RequestResponseLog entries alongside the captured body.
  3. At diagram generationPlantUmlCreator detects the focus fields on each log entry and passes them to JsonFocusFormatter, which annotates each line of the pretty-printed JSON with PlantUML markup.

Focus fields are consumed on use — each focus call applies only to the next HTTP request. If you make two HTTP calls and want focus on both, specify focus for each one:

await client.WithDiagramFocus()
    .OnRequest<CreateCakeRequest>(x => x.Flour)
    .PostAsJsonAsync("/api/cake", cakeRequest);

await client.WithDiagramFocus()
    .OnRequest<UpdateCakeRequest>(x => x.Topping)
    .PutAsJsonAsync("/api/cake/1", updateRequest);

Emphasis and De-Emphasis Modes

The library applies two kinds of styling to lines in the JSON body:

  • Emphasis — Applied to lines belonging to focused fields
  • De-emphasis — Applied to lines belonging to non-focused fields

Both are configurable via [Flags] enums, meaning you can combine multiple modes:

FocusEmphasis (focused fields)

Value Effect PlantUML markup
FocusEmphasis.None No styling (none)
FocusEmphasis.Bold Bold text (default) <b>...</b>
FocusEmphasis.Colored Blue text <color:blue>...</color>
FocusEmphasis.Bold | FocusEmphasis.Colored Bold + blue <b><color:blue>...</color></b>

FocusDeEmphasis (non-focused fields)

Value Effect PlantUML markup
FocusDeEmphasis.None No styling (none)
FocusDeEmphasis.LightGray Gray text (default) <color:lightgray>...</color>
FocusDeEmphasis.SmallerText Smaller font <size:9>...</size>
FocusDeEmphasis.Hidden Collapsed to ... Non-focused fields replaced with ...
FocusDeEmphasis.LightGray | FocusDeEmphasis.SmallerText Gray + small <color:lightgray><size:9>...</size></color>

Configuring Globally

Set the emphasis and de-emphasis modes on ReportConfigurationOptions (or DiagramsFetcherOptions for advanced use). These apply to all diagrams in the test run:

new ReportConfigurationOptions
{
    SpecificationsTitle = "My API Specifications",
    FocusEmphasis = FocusEmphasis.Bold | FocusEmphasis.Colored,
    FocusDeEmphasis = FocusDeEmphasis.SmallerText
}

Or via DiagramsFetcherOptions:

new DiagramsFetcherOptions
{
    FocusEmphasis = FocusEmphasis.Bold,
    FocusDeEmphasis = FocusDeEmphasis.Hidden
}

Examples

Default (Bold + LightGray)

var response = await client.WithDiagramFocus()
    .OnRequest<CreateCakeRequest>(x => x.Flour, x => x.Eggs)
    .PostAsJsonAsync("/api/cake", requestBody);

Given this JSON body:

{
  "milk": "whole",
  "eggs": "free-range",
  "flour": "plain"
}

The diagram note renders as:

{
  <color:lightgray>"milk": "whole",</color>
  <b>"eggs": "free-range",</b>
  <b>"flour": "plain"</b>
}

milk is grayed out. eggs and flour are bold. The braces are unstyled.

Bold + Colored Emphasis

// Global config
new ReportConfigurationOptions
{
    FocusEmphasis = FocusEmphasis.Bold | FocusEmphasis.Colored,
    FocusDeEmphasis = FocusDeEmphasis.LightGray
}
{
  <color:lightgray>"milk": "whole",</color>
  <b><color:blue>"eggs": "free-range",</color></b>
  <b><color:blue>"flour": "plain"</color></b>
}

Hidden De-Emphasis

When FocusDeEmphasis.Hidden is set, non-focused fields are completely removed from the diagram and replaced with a single ... line. Consecutive hidden fields are collapsed into one ...:

new ReportConfigurationOptions
{
    FocusEmphasis = FocusEmphasis.Bold,
    FocusDeEmphasis = FocusDeEmphasis.Hidden
}
var response = await client.WithDiagramFocus()
    .OnResponse<OrderResponse>(x => x.Total)
    .PostAsJsonAsync("/api/orders", orderRequest);

Given:

{
  "orderId": "abc-123",
  "customerId": "cust-456",
  "items": [ ... ],
  "total": 42.99,
  "currency": "GBP",
  "status": "confirmed"
}

The diagram note renders as:

{
  ...
  <b>"total": 42.99</b>
  ...
}

The three non-focused fields before total collapse into a single ..., and the two after collapse into another. Trailing commas are automatically removed from the last visible focused field before a hidden section.

Nested Objects and Arrays

Focus matching operates on top-level property names only. If a focused field's value is an object or array, the entire nested structure is included and emphasised:

var response = await client.WithDiagramFocus()
    .OnResponse<OrderResponse>(x => x.Items)
    .GetAsync("/api/orders/abc-123");
{
  "orderId": "abc-123",
  "items": [
    { "name": "Cake", "qty": 1 },
    { "name": "Pie", "qty": 2 }
  ],
  "total": 42.99
}

All lines from "items" through the closing ] are emphasised; orderId and total are de-emphasised or hidden.

Case-Insensitive Matching

Property matching is case-insensitive. .OnRequest<T>(x => x.UserName) will match "userName", "UserName", or "username" in the JSON body.

Tips

  • Use WithDiagramFocus() for clarity — The fluent API makes it immediately visible that focus applies to this HTTP call. It reads naturally and avoids accidental misassociation between a focus call and the wrong HTTP request.
  • Use focus for readability, not security — Focus is a visual aid, not a redaction tool. Non-focused fields are still present in the diagram (unless you use Hidden mode). Use post-processors for sensitive data redaction.
  • One focus per HTTP request — Each focus applies to only the next HTTP call. The WithDiagramFocus() chain enforces this naturally since the terminal method (e.g. PostAsJsonAsync) is the HTTP call itself.
  • Combine with post-processors — Focus and post-processors work independently and stack. Focus is applied during diagram generation (PlantUML markup), while post-processors run on the final formatted text. You can use both: focus to highlight key fields, and a post-processor to redact tokens.
  • Works with any JSON body — Focus works on any request/response that contains a JSON body, including envelope responses where focused fields are nested inside wrapper objects or arrays. Non-JSON content (XML, form-encoded, etc.) is unaffected.
  • Expression safety — The lambda expressions are checked at compile time. If you rename a property, the focus call will produce a compile error, keeping your tests in sync with your DTOs.
  • Static API for BDD steps — In BDD frameworks where the HTTP call is in a separate step definition method, use the static DiagramFocus.Request<T>() / DiagramFocus.Response<T>() API instead.

Quick Start: Using ReportConfigurationOptions

The simplest way to process diagram content is via RequestResponsePostProcessor on ReportConfigurationOptions. This single function is applied to both request and response content after the library has formatted it (JSON pretty-printed, headers laid out, DiagramFocus markup applied):

new ReportConfigurationOptions
{
    SpecificationsTitle = "My API Specifications",
    RequestResponsePostProcessor = content => content
        .RedactBearerTokens()
        .RedactAccessTokens()
        .SplitLongWords()
};

This is what most integration guides show. Internally, the library maps this to both RequestPostFormattingProcessor and ResponsePostFormattingProcessor on DiagramsFetcherOptions.

Mid-Processor

If you need to transform content after JSON pretty-printing but before DiagramFocus styling and header wrapping, use RequestResponseMidProcessor. The mid-processor receives clean, human-readable text with no PlantUML markup — making simple regex-based redaction straightforward:

new ReportConfigurationOptions
{
    SpecificationsTitle = "My API Specifications",
    RequestResponseMidProcessor = content => content
        .RedactBearerTokens()
        .RedactAccessTokens(),
    RequestResponsePostProcessor = content => content
        .SplitLongWords()
};

Internally, the library maps RequestResponseMidProcessor to both RequestMidFormattingProcessor and ResponseMidFormattingProcessor on DiagramsFetcherOptions.

When to choose mid vs post: Use mid-processors when your regex targets raw values (tokens, secrets, PII) and you don't want to account for PlantUML markup like $color(gray), <b>, or <color:blue>. Use post-processors when you need to manipulate the final rendered text (e.g. splitting long words that may already contain markup).


DiagramsFetcherOptions (Advanced)

For fine-grained control over how request/response bodies and headers are formatted in diagram notes, use DiagramsFetcherOptions directly. This is useful when you need different processing for requests vs responses, or when you need to transform the raw body before the library formats it.

var options = new DiagramsFetcherOptions
{
    PlantUmlServerBaseUrl = "https://plantuml.com/plantuml",
    RequestPreFormattingProcessor = content => content,
    RequestMidFormattingProcessor = content => content,
    RequestPostFormattingProcessor = content => content,
    ResponsePreFormattingProcessor = content => content,
    ResponseMidFormattingProcessor = content => content,
    ResponsePostFormattingProcessor = content => content,
    ExcludedHeaders = ["Authorization", "X-Api-Key"]
};
Property Type Default Description
PlantUmlServerBaseUrl string "https://plantuml.com/plantuml" Base URL of the PlantUML server.
RequestPreFormattingProcessor Func<string, string>? null Transform raw request body before the library formats it into the PlantUML note.
RequestMidFormattingProcessor Func<string, string>? null Transform request content after JSON pretty-printing but before DiagramFocus styling and header wrapping. Receives clean text with no PlantUML markup.
RequestPostFormattingProcessor Func<string, string>? null Transform the formatted request note after the library has formatted it.
ResponsePreFormattingProcessor Func<string, string>? null Transform raw response body before the library formats it into the PlantUML note.
ResponseMidFormattingProcessor Func<string, string>? null Transform response content after JSON pretty-printing but before DiagramFocus styling and header wrapping. Receives clean text with no PlantUML markup.
ResponsePostFormattingProcessor Func<string, string>? null Transform the formatted response note after the library has formatted it.
ExcludedHeaders IEnumerable<string> [] HTTP headers to exclude from diagram notes.
SeparateSetup bool false When true, HTTP calls made before StartAction() are wrapped in a visual "Setup" partition in the diagram. See Diagram Customisation.
HighlightSetup bool true When true (and SeparateSetup is enabled), the setup partition is rendered with a background colour. When false, the partition has no background colour.
FocusEmphasis FocusEmphasis Bold How focused fields are styled in diagram notes. Flags enum — combine with |. See Content Formatting#DiagramFocus (Field Highlighting).
FocusDeEmphasis FocusDeEmphasis LightGray How non-focused fields are styled in diagram notes. Flags enum — combine with |. See Content Formatting#DiagramFocus (Field Highlighting).

Formatting Pipeline

The formatting pipeline for each request/response is:

Raw body → PreFormattingProcessor → Library formatting (JSON pretty-print) → MidFormattingProcessor → DiagramFocus styling → Header layout → PostFormattingProcessor → Diagram note
  • Pre-processor — Runs on the raw body text before the library formats it (JSON pretty-printing, header extraction, etc.). Use this to deserialise, decrypt, decompress, or restructure the raw content so the library's formatter can process it correctly.
  • Mid-processor — Runs after JSON pretty-printing (or form-encoded formatting) but before DiagramFocus styling and header wrapping. The content at this stage is clean, human-readable text with no PlantUML markup. Ideal for redacting tokens and secrets with simple regex patterns. See Content Formatting#Mid-Processor Use Cases.
  • DiagramFocus — Applied automatically during diagram generation. Annotates lines of the pretty-printed JSON with PlantUML emphasis/de-emphasis markup based on the focus fields set by the test. Runs before post-processors.
  • Post-processor — Runs on the fully formatted note text after the library has laid it out (including any DiagramFocus markup and headers with $color(gray) wrapping). Use this to redact sensitive values, shorten long strings, strip noise, or wrap long lines. This is the one you'll use most often.

The distinction matters because the library's built-in formatting does several things automatically:

  • Pretty-prints JSON bodies with indentation
  • Lays out headers in [Key=Value] format
  • Handles content-type detection

If the raw body isn't in a format the library recognises (e.g. it's compressed, encrypted, or non-JSON), the formatter can't do its job. That's where pre-processors come in.


Pre-Processor Use Cases

Pre-processors operate on the raw body string before the library formats it. They're essential when the raw content needs to be transformed into something the library's JSON pretty-printer can work with, or when the raw format isn't useful in its original form.

Note: Pre-processors are set via DiagramsFetcherOptions (separate RequestPreFormattingProcessor and ResponsePreFormattingProcessor), not via ReportConfigurationOptions. If you only need post-processing, use ReportConfigurationOptions.RequestResponsePostProcessor instead.

Pretty-Printing XML

If your SUT communicates with SOAP or XML-based services, the raw body is typically a single long line of XML. The library's formatter won't pretty-print it (it only handles JSON). Use a pre-processor to format it:

RequestPreFormattingProcessor = body =>
{
    try { return XDocument.Parse(body).ToString(); }
    catch { return body; } // Not XML, leave as-is
}

Before (raw):

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><GetCustomerResponse><Id>123</Id><Name>John Doe</Name></GetCustomerResponse></soap:Body></soap:Envelope>

After (pre-processed, then shown in diagram):

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetCustomerResponse>
      <Id>123</Id>
      <Name>John Doe</Name>
    </GetCustomerResponse>
  </soap:Body>
</soap:Envelope>

Decompressing Gzip/Deflate Bodies

Some APIs return compressed bodies. If the HttpClient pipeline hasn't decompressed them before tracking captures the content, the raw body will be binary gibberish:

ResponsePreFormattingProcessor = body =>
{
    try
    {
        var bytes = Convert.FromBase64String(body);
        using var input = new MemoryStream(bytes);
        using var gzip = new GZipStream(input, CompressionMode.Decompress);
        using var reader = new StreamReader(gzip);
        return reader.ReadToEnd();
    }
    catch { return body; }
}

Decrypting Encrypted Payloads

If a downstream service returns encrypted payloads (e.g. JWE tokens or encrypted JSON fields), the raw body is opaque. A pre-processor can decrypt it so the diagram shows the actual content:

RequestPreFormattingProcessor = body =>
{
    try
    {
        var decrypted = encryptionService.Decrypt(body);
        return decrypted;
    }
    catch { return body; }
}

Decoding Form-Encoded Bodies

POST requests with application/x-www-form-urlencoded content appear as a single line of key=value&key=value pairs. Transform them into a more readable layout:

RequestPreFormattingProcessor = body =>
{
    if (!body.Contains('=') || body.TrimStart().StartsWith('{'))
        return body; // Not form-encoded, or is JSON

    try
    {
        var pairs = body.Split('&')
            .Select(p => p.Split('=', 2))
            .Where(p => p.Length == 2)
            .Select(p => $"{Uri.UnescapeDataString(p[0])}: {Uri.UnescapeDataString(p[1])}");
        return string.Join("\n", pairs);
    }
    catch { return body; }
}

Before:

grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=my-app

After:

grant_type: authorization_code
code: abc123
redirect_uri: https://app.example.com/callback
client_id: my-app

Extracting Embedded JSON

Some APIs wrap JSON inside another format (e.g. an envelope or a Base64-encoded field). A pre-processor can extract the inner JSON so the library can pretty-print it:

ResponsePreFormattingProcessor = body =>
{
    try
    {
        var envelope = JsonSerializer.Deserialize<JsonElement>(body);
        if (envelope.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.String)
        {
            // "data" contains a JSON string — extract and return it so the library pretty-prints it
            var inner = data.GetString()!;
            return JsonSerializer.Serialize(
                JsonSerializer.Deserialize<JsonElement>(inner),
                new JsonSerializerOptions { WriteIndented = true });
        }
        return body;
    }
    catch { return body; }
}

Combining Pre- and Post-Processors

You can use both simultaneously. A common pattern is pre-processing to make the body parseable, then post-processing to redact sensitive values from the formatted output:

var options = new DiagramsFetcherOptions
{
    // First: decode form-encoded token requests so the library can format them
    RequestPreFormattingProcessor = body =>
    {
        if (!body.Contains('=') || body.TrimStart().StartsWith('{')) return body;
        try
        {
            var pairs = body.Split('&')
                .Select(p => p.Split('=', 2))
                .Where(p => p.Length == 2)
                .Select(p => $"{Uri.UnescapeDataString(p[0])}: {Uri.UnescapeDataString(p[1])}");
            return string.Join("\n", pairs);
        }
        catch { return body; }
    },

    // Then: redact tokens from the final formatted output
    RequestPostFormattingProcessor = content => content
        .RedactMiddle(new Regex(@"(?<=code: )\S+"))
        .RedactMiddle(new Regex(@"(?<=client_secret: )\S+")),

    ResponsePostFormattingProcessor = content => content
        .RedactMiddle(new Regex("(?<=\"access_token\": \")[^\"]+(?=\")"))
        .RedactMiddle(new Regex("(?<=\"refresh_token\": \")[^\"]+(?=\")"))
};

Tip: Always wrap pre-processors in try-catch blocks. The raw body may not be in the format you expect (e.g. a different content type, an error response, or empty). If the pre-processor throws, the diagram generation for that test will fail. Return the original body in the catch block to fall through gracefully.


Mid-Processor Use Cases

Mid-processors run after JSON pretty-printing (or form-encoded formatting) but before DiagramFocus styling and header wrapping. At this stage, the content is clean, human-readable text with no PlantUML markup — making simple regex patterns reliable.

Redacting Tokens Without Markup Interference

The most common use case. Post-processors receive content that may contain PlantUML markup ($color(gray) on wrapped lines, <b> from DiagramFocus), which breaks simple regex. A mid-processor avoids this entirely:

RequestResponseMidProcessor = content =>
    Regex.Replace(content, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer ***")

Or with DiagramsFetcherOptions for separate control:

new DiagramsFetcherOptions
{
    RequestMidFormattingProcessor = content =>
        Regex.Replace(content, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer ***"),
    ResponseMidFormattingProcessor = content =>
        Regex.Replace(content, "(?<=\"access_token\": \")[^\"]+(?=\")", "***")
}

Redacting PII From JSON Bodies

Because the mid-processor runs on pretty-printed JSON (one property per line, standard indentation), matching property values is straightforward:

private static readonly Regex EmailRegex = new("(?<=\"email\": \")[^\"]+(?=\")");
private static readonly Regex PhoneRegex = new("(?<=\"phone\": \")[^\"]+(?=\")");

RequestResponseMidProcessor = content => content
    .RegexReplace(EmailRegex, "***@***.***")
    .RegexReplace(PhoneRegex, "***-***-****")

Combining Mid- and Post-Processors

Use mid-processors for value-level redaction (tokens, PII) and post-processors for layout-level adjustments (splitting long words, wrapping lines). They compose naturally:

new ReportConfigurationOptions
{
    // Redact on clean text — simple regex, no markup concerns
    RequestResponseMidProcessor = content => content
        .RedactBearerTokens()
        .RedactAccessTokens()
        .RedactPii(),

    // Layout adjustments on final text — may include PlantUML markup
    RequestResponsePostProcessor = content => content
        .SplitLongWords()
}

Note: Mid-processors are set via ReportConfigurationOptions.RequestResponseMidProcessor (single function for both), or via DiagramsFetcherOptions (RequestMidFormattingProcessor and ResponseMidFormattingProcessor separately). If you only need simple redaction, ReportConfigurationOptions.RequestResponseMidProcessor is the easiest option.


Post-Processor Use Cases

Post-processors are the most commonly used. They operate on the fully formatted note text — the final string that will appear in the PlantUML diagram note, including headers and body content already laid out by the library.

Redacting Bearer Tokens

Bearer tokens are long, opaque strings that add significant noise to diagrams without providing useful information. A simple regex replacement shortens them:

RequestResponsePostProcessor = content =>
    Regex.Replace(content, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer ***")

Simpler alternative — use a mid-processor: Post-processors receive content that may already contain PlantUML markup ($color(gray) on wrapped header lines, <b> / <color:blue> from DiagramFocus). This can break simple regex patterns for bearer tokens. A mid-processor runs on clean text before any markup is applied, so a simple regex works without special handling:

RequestResponseMidProcessor = content =>
    Regex.Replace(content, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer ***")

If you must use a post-processor (e.g. because you also need to transform markup), account for the wrapped format:

private static readonly Regex BearerTokenRegex = new(
    @"(Bearer )[A-Za-z0-9\-._~+/_]+=*(?:\r?\n\$color\(gray\)[A-Za-z0-9\-._~+/_]+=*)*",
    RegexOptions.Compiled);

RequestResponsePostProcessor = content =>
    BearerTokenRegex.Replace(content, "$1***")

Alternatively, ensure your RedactBearerTokens() call runs before SplitLongWords() in the pipeline so the token is redacted before it gets split.

Before:

[Authorization=Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJ...]

After:

[Authorization=Bearer ***]

Redacting JSON Token Fields

Tokens also appear in JSON response bodies (access tokens, refresh tokens, ID tokens). Target them with regex that matches the JSON structure:

private static readonly Regex AccessTokenRegex = new("(?<=\"access_token\": \")[^\"]+(?=\")");
private static readonly Regex RefreshTokenRegex = new("(?<=\"refresh_token\": \")[^\"]+(?=\")");
private static readonly Regex IdTokenRegex = new("(?<=\"id_token\": \")[^\"]+(?=\")");

RequestResponsePostProcessor = content => content
    .RedactMiddle(AccessTokenRegex)
    .RedactMiddle(RefreshTokenRegex)
    .RedactMiddle(IdTokenRegex)

Where RedactMiddle keeps the start and end of the token visible for debugging while redacting the bulk:

private static string RedactMiddle(this string value, Regex regex) =>
    regex.Replace(value, m =>
        m.Value.Length > 50
            ? m.Value[..8] + "_REDACTED_" + m.Value[^18..]
            : m.Value);

Before:

"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Sfl..."

After:

"access_token": "eyJhbGc_REDACTED_KxwRJSMeKKF2QT4"

Redacting Cookies

Cookie headers often contain long session tokens that bloat diagrams:

private static readonly Regex SetCookieRegex = new(@"(?<=\[Set-Cookie=)[^\]]+(?=])");
private static readonly Regex CookieRegex = new(@"(?<=\[Cookie=)[^\]]+(?=])");

RequestResponsePostProcessor = content => content
    .RedactEnding(SetCookieRegex, 50)
    .RedactEnding(CookieRegex, 50)

Where RedactEnding keeps only the first N characters:

private static string RedactEnding(this string value, Regex regex, int length = 30) =>
    regex.Replace(value, m =>
        m.Value.Length > 200
            ? m.Value[..length] + "_RedactedEnding"
            : m.Value);

Splitting Long Words

Tokens, Base64-encoded values, and other long unbroken strings can make diagram notes extremely wide, sometimes exceeding PlantUML's rendering limits. Break them across lines:

private static string SplitWordsOverMaxLength(this string value, int maxLength = 200)
{
    var words = value.Split("\n")
        .SelectMany(line => line.Trim().Split(' '))
        .Where(w => !string.IsNullOrWhiteSpace(w));

    foreach (var word in words.Where(w => w.Length > maxLength))
    {
        var chunks = word.Chunk(maxLength).Select(c => new string(c));
        value = value.Replace(word, string.Join("\n", chunks));
    }

    return value;
}

Wrapping Long Embedded Values

Some payloads contain long string values (e.g. serialised JSON inside a JSON field, or EventGrid event payloads) that should be wrapped at a reasonable line length:

private static readonly Regex EventGridValueRegex =
    new("(?<=\"(?:request|response)\": \")[^\"]+");

private static string WrapLongValues(this string value, int maxLineLength = 90) =>
    EventGridValueRegex.Replace(value, match =>
        match.Value.Length <= maxLineLength
            ? match.Value
            : string.Join("\n", match.Value.Chunk(maxLineLength).Select(c => new string(c))));

Redacting Large HTML Responses

Some downstream services (identity providers, OAuth consent pages, etc.) return full HTML pages in their responses. These can be thousands of characters and make diagrams unrenderable. Replace them above a size threshold:

private const int HtmlMaxCharactersBeforeRedacting = 3_000;

private static string RedactLargeHtmlResponses(this string value)
{
    var startTag = "<html";
    var endTag = "</html";

    if (!value.Contains(startTag))
        return value;

    if (value.Length > HtmlMaxCharactersBeforeRedacting)
    {
        var before = value.Split(startTag)[0];
        var afterEnd = value.Split(startTag)[1].Split(endTag)[1];
        return before + startTag + ">...REDACTED..." + endTag + afterEnd.Trim();
    }

    return value;
}

Exposing JWT Claims

Rather than just redacting tokens, you can extract useful information from JWTs and annotate them in the diagram. For example, extracting the auth_level claim:

private static string ExposeAuthLevelsOfAccessTokens(this string value)
{
    var regex = new Regex("(\"access_token\": \")(.*)(\",)");
    return regex.Replace(value, match =>
    {
        try
        {
            var token = new JwtSecurityTokenHandler().ReadJwtToken(match.Groups[2].Value);
            var authLevel = token.Claims.FirstOrDefault(x => x.Type == "auth_level");
            return authLevel is null
                ? match.Value
                : match.Value + $" /* [auth_level={authLevel.Value}] */";
        }
        catch
        {
            return match.Value; // Not a JWT, leave as-is
        }
    });
}

Before:

"access_token": "eyJhbGciOiJSUzI1NiJ9.eyJhdXRoX2xldmVsIjoiMiIsInN1YiI6InVzZXIxIn0.sig...",

After:

"access_token": "eyJhbGc_REDACTED_KxwRJSMeKKF2QT4", /* [auth_level=2] */

This gives you the security-relevant metadata at a glance without the raw token noise.


Full Example: Building a Composable Redaction Pipeline

In practice, you'll combine multiple techniques into a fluent processing pipeline. Here's a complete real-world example for an API that deals with authentication tokens, cookies, and identity provider responses:

public static class DiagramContentProcessor
{
    // --- Token patterns ---
    private static readonly Regex AccessTokenRegex =
        new("(?<=\"access_token\": \")[^\"]+(?=\")");
    private static readonly Regex BearerTokenRegex =
        new(@"(?<=Bearer )([^\]]+)");
    private static readonly Regex RefreshTokenRegex =
        new("(?<=\"refresh_token\": \")[^\"]+(?=\")");
    private static readonly Regex IdTokenRegex =
        new("(?<=\"id_token\": \")[^\"]+(?=\")");

    // --- Cookie patterns ---
    private static readonly Regex SetCookieRegex =
        new(@"(?<=\[Set-Cookie=)[^\]]+(?=])");
    private static readonly Regex CookieRegex =
        new(@"(?<=\[Cookie=)[^\]]+(?=])");

    // --- Session / long value patterns ---
    private static readonly Regex SessionDataRegex =
        new("(?<=\"sessionData\": \")[^\"]+(?=\")");

    /// <summary>
    /// The post-processor function — wire this into ReportConfigurationOptions.
    /// </summary>
    public static Func<string, string> PostProcessor => content => content
        .ExposeAuthLevels()
        .RedactMiddle(AccessTokenRegex)
        .RedactMiddle(BearerTokenRegex)
        .RedactMiddle(RefreshTokenRegex)
        .RedactMiddle(IdTokenRegex)
        .RedactEnding(SetCookieRegex, 50)
        .RedactEnding(CookieRegex, 50)
        .RedactEnding(SessionDataRegex, 30)
        .RedactLargeHtml()
        .SplitLongWords();

    // --- Redaction helpers (extension methods) ---

    private static string RedactMiddle(this string value, Regex regex) =>
        regex.Replace(value, m =>
            m.Value.Length > 50
                ? m.Value[..8] + "_REDACTED_" + m.Value[^18..]
                : m.Value);

    private static string RedactEnding(this string value, Regex regex, int length) =>
        regex.Replace(value, m =>
            m.Value.Length > 200
                ? m.Value[..length] + "_RedactedEnding"
                : m.Value);

    private static string ExposeAuthLevels(this string value)
    {
        var regex = new Regex("(\"access_token\": \")(.*)(\",)");
        return regex.Replace(value, match =>
        {
            try
            {
                var token = new JwtSecurityTokenHandler().ReadJwtToken(match.Groups[2].Value);
                var claim = token.Claims.FirstOrDefault(c => c.Type == "auth_level");
                return claim is null ? match.Value : match.Value + $" /* [auth_level={claim.Value}] */";
            }
            catch { return match.Value; }
        });
    }

    private static string RedactLargeHtml(this string value)
    {
        if (!value.Contains("<html")) return value;
        if (value.Length <= 3_000) return value;
        var before = value.Split("<html")[0];
        var afterEnd = value.Split("<html")[1].Split("</html")[1];
        return before + "<html>...REDACTED...</html" + afterEnd.Trim();
    }

    private static string SplitLongWords(this string value, int maxLength = 200)
    {
        foreach (var word in value.Split('\n').SelectMany(l => l.Split(' ')).Where(w => w.Length > maxLength))
            value = value.Replace(word, string.Join("\n", word.Chunk(maxLength).Select(c => new string(c))));
        return value;
    }
}

Wire it up:

// In your test setup / report configuration
new ReportConfigurationOptions
{
    SpecificationsTitle = "My API Specifications",
    RequestResponsePostProcessor = DiagramContentProcessor.PostProcessor,
    ExcludedHeaders = ["X-Request-Id", "X-Correlation-Id"]
}

Order of Operations

The order you chain redaction steps matters. Recommended order:

  1. Extract useful metadata from tokens (e.g. ExposeAuthLevels) — do this before redacting, so you can still read the JWT
  2. Redact tokens and secrets (RedactMiddle / RedactEnding) — remove sensitive values
  3. Redact cookies and session data — reduce noise from large cookie headers
  4. Redact large HTML — prevent oversized diagrams from identity provider responses
  5. Split long words — break any remaining long strings so PlantUML can render them

If you redact tokens before extracting claims, the JWT will already be truncated and unreadable.


Tips

  • Test your processor by running your test suite and checking the generated PlantUML. If diagrams are still too wide or fail to render, you likely have unredacted long values.
  • Use RedactMiddle for tokens — keeping the start and end visible makes it possible to correlate which token is which across diagram notes.
  • Use RedactEnding for cookies and session data where only the name/start matters.
  • Set a size threshold for HTML redaction (e.g. 3,000 characters) so small HTML fragments still appear but full pages are collapsed.
  • Pre-processors vs mid-processors vs post-processors: Use pre-processors only when you need to transform the raw body before the library's JSON pretty-printer runs (e.g. decrypting, decompressing, parsing XML). Use mid-processors for clean regex-based redaction of values (tokens, PII) on pretty-printed text with no PlantUML markup. Use post-processors for everything else — they operate on the final formatted text which includes DiagramFocus markup and header wrapping.

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally