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.MaxPayloadInputLengthcharacters (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[]=bluegives 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 inpage#section?a=1there 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