Skip to content

CkPayload

Job: turn raw wire text — a JSON body, a form post, a URL — into the standard dictionary the rest of the library reads.

"Wire text" is the text that arrives over HTTP: a webhook body, an API response, the query part of a URL. CkPayload is step one of the pipeline: it parses that text into a Dictionary<string, object?> — the bag — and CkDictionary then pulls typed values out of it, so you never write the deserializer ceremony yourself. Unreadable input answers an empty dictionary (an empty list from FromJsonMaps) — never null — so you can read the result straight away, without a null check.

Quick look

using SafeCoder.CommonKit;

var payload = CkPayload.FromJson("""{ "id": 7, "active": true }""");
int id = CkDictionary.Int32(dictionary: payload, key: "id");     // -> 7

var query = CkPayload.FromQuery("https://api.example.com/members?page=2");
int page = CkDictionary.Int32(dictionary: query, key: "page");   // -> 2

CkPayload.FromJson("not json");   // -> empty dictionary (unreadable — never null)
CkPayload.FromCsv("Smith, John,\"Boston, MA\",42");   // -> 4 fields, not 5
CkPayload.Explain("{ not json");   // -> the parser's reason

Methods

FromJson

A JSON body into the bag.

Dictionary<string, object?> FromJson(
    string? json = null,
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
json string? null The JSON text; its root must be a JSON object.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty dictionary.

Returns

When Default With throwIfInvalid: true
The text is a JSON object The payload as a dictionary: "id" reads 7 The payload as a dictionary: "id" reads 7
Rejected: missing, over the length cap, not valid JSON, or the root is not a JSON object Empty dictionary Throws CkPayloadException — carries Input and Reason

An array at the root is rejected too — that shape belongs to FromJsonMaps.

Parses JSON text whose root is a JSON object — { ... } — into the standard dictionary. Simple values (numbers, text, booleans) come out as plain .NET values; a nested object or array stays wrapped, ready for CkDictionary.Map, Maps or Items to open. The JSON is read strictly: no comments, no trailing commas.

string body = """{ "member": { "id": 7, "address": { "zip": "78701" } } }""";

var payload = CkPayload.FromJson(body);
var member = CkDictionary.Map(dictionary: payload, key: "member");
var address = CkDictionary.Map(dictionary: member, key: "address");

int id = CkDictionary.Int32(dictionary: member, key: "id");         // -> 7
string zip = CkDictionary.String(dictionary: address, key: "zip");  // -> "78701"

CkPayload.FromJson("[1, 2]");     // -> empty (an array root belongs to FromJsonMaps)
CkPayload.FromJson("not json");   // -> empty (unreadable)

FromJsonMaps

A JSON list into one bag per item.

Dictionary<string, object?>[] FromJsonMaps(
    string? json = null,
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
json string? null The JSON text; its root must be a JSON array of objects, or one object.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty or shortened list.

Returns

When Default With throwIfInvalid: true
The text is a JSON array of objects — or one object on its own One dictionary per item: "eventId" reads 1, then 2 One dictionary per item: "eventId" reads 1, then 2
Rejected: missing, over the length cap, not valid JSON, or the root is not list-shaped Empty list Throws CkPayloadException — carries Input and Reason

Without the flag an item that is not an object is left out quietly; with it, one such item makes the whole read throw.

For JSON whose root is an array of objects — a batch webhook body, a list endpoint's response. You get one dictionary per item. A single object where a list was allowed still works: it comes back as a one-item list. Items that are not objects are left out quietly (or reported, when you ask for errors — see the end of this page).

string body = """[ { "eventId": 1 }, { "eventId": 2 } ]""";

foreach (var item in CkPayload.FromJsonMaps(body))
{
    int id = CkDictionary.Int32(dictionary: item, key: "eventId");  // -> 1, then 2
}

CkPayload.FromJsonMaps("""{ "eventId": 7 }""");  // -> one item (a single object still lists)
CkPayload.FromJsonMaps("[1, 2, 3]");             // -> empty (numbers are not objects)

FromForm

A form body into the bag.

Dictionary<string, object?> FromForm(
    string? form = null,
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
form string? null The form-encoded body.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty dictionary.

Returns

When Default With throwIfInvalid: true
The text holds key=value pairs The pairs as a dictionary: "scope" reads "read write" The pairs as a dictionary: "scope" reads "read write"
Rejected: missing, over the length cap, or no usable pairs Empty dictionary Throws CkPayloadException — carries Input and Reason

A repeated key keeps only its last value, and a key with no = gets an empty one.

Parses an application/x-www-form-urlencoded body — the format HTML forms, OAuth2 token endpoints and many webhook providers send, like a=1&b=hello+world. Keys and values are percent-decoded (%C3%A9 becomes é) and a + is read as a space. Every value in the bag is text — the typed CkDictionary readers turn "2" into 2 from there.

var form = CkPayload.FromForm("grant_type=client_credentials&scope=read+write");

string grant = CkDictionary.String(dictionary: form, key: "grant_type");  // -> "client_credentials"
string scope = CkDictionary.String(dictionary: form, key: "scope");       // -> "read write"

var dup = CkPayload.FromForm("a=1&a=2");
CkDictionary.String(dictionary: dup, key: "a");      // -> "2" (a repeated key: the last value wins)

var flag = CkPayload.FromForm("name=Mary&flag");
CkDictionary.String(dictionary: flag, key: "flag");  // -> "" (a key with no "=" gets an empty value)

FromQuery

A URL's parameters into the bag.

Dictionary<string, object?> FromQuery(
    string? url = null,
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
url string? null The URL, or a bare query string.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty dictionary.

Returns

When Default With throwIfInvalid: true
The URL (or the bare query) holds key=value pairs The parameters as a dictionary: "sort" reads "name" The parameters as a dictionary: "sort" reads "name"
Rejected: missing, over the length cap, a URL with no ?, or no usable pairs Empty dictionary Throws CkPayloadException — carries Input and Reason

The fragment is cut before the ? is looked for, so page#section?a=1 holds no query.

Takes a full URL, or just the query string on its own, and turns the part after ? into the bag — decoded by the same rules as FromForm; the two share one syntax. The fragment (the #... part at the end of a URL) is cut off first, because by the URL grammar it is not part of the query.

var query = CkPayload.FromQuery("https://api.example.com/members?page=2&sort=name#top");

int page = CkDictionary.Int32(dictionary: query, key: "page");      // -> 2
string sort = CkDictionary.String(dictionary: query, key: "sort");  // -> "name"

CkPayload.FromQuery("page=2&sort=name");          // -> the same two pairs (a bare query works too)
CkPayload.FromQuery("https://example.com/path");  // -> empty (no "?" means no query)

CkPayload.FromQuery("https://example.com/page#section?page=2");
// -> empty — the "?" sits inside the fragment, so the fragment hides the query

FromCsv

One CSV line, by the real rules.

string[] FromCsv(
    string? line = null,
    char separator = ',',
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
line string? null One record of CSV text.
separator char ',' The field separator; ';' or '\t' for the other conventions.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty array, when the line cannot be read.

Returns

When Default With throwIfInvalid: true
One record Its fields, in order, quotes removed, nothing trimmed The fields
Rejected: missing, more than one record, an unclosed quote An empty array Throws CkPayloadException — carries Input, Expected and Reason

A quoted field may hold the separator and line breaks, and a doubled quote inside it is one quote — so "Smith, John,\"Boston, MA\",42" is four fields, not five. Splitting on the comma yourself is the classic import bug. Clean each field with the reader it belongs to.

CkPayload.FromCsv("Smith, John,\"Boston, MA\",42");   // -> ["Smith", " John", "Boston, MA", "42"]

FromCsvMaps

A CSV file as bags, one per row.

Dictionary<string, object?>[] FromCsvMaps(
    string? text = null,
    char separator = ',',
    bool throwIfInvalid = CkSettings.DefaultThrowIfInvalid
)
Parameter Type Default What it does
text string? null The CSV text, header row first.
separator char ',' The field separator.
throwIfInvalid bool CkSettings.DefaultThrowIfInvalid (false) Pass true to throw a CkPayloadException that says why, instead of answering an empty array, when the text cannot be read.

Returns

When Default With throwIfInvalid: true
A header and rows One bag per row, keyed by column, values as text; short rows hold null in their missing columns The bags
A header alone An empty array An empty array
Rejected: a blank or duplicate column name, a row longer than the header, an unclosed quote An empty array Throws CkPayloadException — carries Input, Expected and Reason — the reason names the line or column

The shape FromJsonMaps gives a JSON array, so an import goes through the same CkDictionary readers as a webhook does. Strict where it matters: a row longer than the header rejects the file, since that is what a broken quote looks like.

foreach (var row in CkPayload.FromCsvMaps(csv))
{
    decimal amount = CkMoney.Amount(CkDictionary.Value(row, "amount"));
}

Explain

Why not — in words, without an exception.

string Explain(
    string? input = null,
    string? what = nameof(FromJson),
    char separator = ','
)
Parameter Type Default What it does
input string? null The wire text.
what string? nameof(FromJson) The reader to judge by: FromJson, FromJsonMaps, FromForm, FromQuery, FromCsv or FromCsvMaps.
separator char ',' The field separator, for the CSV readers.

Returns

When You get
The value passes the reader ""
It does not The reason, the same words the exception would carry — an answer, not a failure; it never throws

The form-friendly twin of throwIfInvalid: the message to show beside the field, without a try and catch. Every class with a reader has one, on the reader's own rules, so the reason is exactly what the reader would say. what names the reader to judge by.

CkPayload.Explain("{ not json");                            // -> the parser's reason
CkPayload.Explain("\"a,b", nameof(CkPayload.FromCsv));       // -> "an unclosed quote"

Good to know

  • Unreadable in, empty out — never null. That is this class's version of the standard CommonKit signal: an empty dictionary (or empty list) means the text could not be read.
  • A repeated key: the last value wins. In JSON as in a form body — the answer System.Text.Json gives, so what this bag holds is what the application's own parser would hold, and a check made on the bag holds for the value the code then uses. A number too large for a double (1e400) arrives as its text, never as infinity.
  • JSON is strict. A comment or a trailing comma — things some editors tolerate — makes the whole text unreadable, so you notice instead of silently losing data.
  • Whitespace and a BOM are fine. Space around the text and a byte-order mark (an invisible marker character some tools put at the very start of a file) are trimmed off before parsing.
  • There is a size cap. Text over CkSettings.MaxPayloadInputLength characters (16,777,216 — about 16 MB) is rejected, so a runaway payload cannot eat your memory.
  • Form and query keys are literal. tags[]=red&tags[]=blue gives one key named "tags[]" holding "blue" — no hidden array magic, and a repeated key keeps only its last value.
  • A fragment can hide the query. The # is cut before the ? is looked for, so in page#section?a=1 there is no query at all. That is correct URL behaviour, but it surprises people.
  • Form and query values are all text. Numbers and booleans only become typed values when a CkDictionary reader pulls them out.

If you want errors instead

Pass throwIfInvalid: true and text that cannot be read raises a CkPayloadException (from SafeCoder.CommonKit.Exceptions) instead of answering an empty bag or list. The exception carries the Input and a short Reason, flattened into a one-line message. With it, FromJsonMaps also reports list items that are not objects, instead of quietly leaving them out.

CkPayload.FromJson(
    json: "not json",
    throwIfInvalid: true
);   // throws CkPayloadException — the message says why