json_sentinel 0.4.0
json_sentinel: ^0.4.0 copied to clipboard
Lightweight runtime JSON validation for Dart. Configurable logger, redaction, and batch validation — routes failures to any error reporter. No code generation.
json_sentinel #
Lightweight runtime JSON validation for Dart — configurable logger, redaction, and batch validation — routes failures to any error reporter. No code generation required.
Validates a Map<String, dynamic> against an expected schema before you deserialise it,
catching malformed API responses early with a single, readable log entry per failure.
Features #
- Checks key existence and value types in one call
- Nullable fields, union types (
[int, double]), and optional fields all supported - Strict mode flags unexpected keys not declared in your schema
warnUnexpected— logs unknown keys as warnings without failing validation; ideal for APIs that add fields in minor versionsvalidators— per-key predicate functions for value-level checks (enums, ranges, non-empty strings) run after type validation passes- Single log entry per call listing every problem as a bullet — nothing hidden by an early return
validateList()— validates a bareList<dynamic>fromjsonDecodedirectly; non-Map items produce descriptive failures rather than cast errorsvalidateBatch()— validates a list of payloads against a shared schema and emits one consolidated log entry for all failures, preventing duplicate error reporter events when processing list endpointsjson_previewattached to single-item extras;item_previews(List<String>) attached to batch extras — one JSON preview per failing item (opt out withgeneratePreviews: false)parentContext— chainvalidate()calls across nested models to produce[UserPage.data[2] > MetaModel]log prefixes, pinpointing failures in paginated or deeply-nested responses without extra logging coderedactKeys— mask sensitive top-level field values (passwords, tokens, API keys) injson_previewanditem_previewsbefore they reach your error reporter; customredactionPlaceholdersupported- Configurable
escalateflag per call — control whether a failure is a breadcrumb or a full capture - Returns
JsonValidationResultwithisValid+ programmaticerrorslist;validateBatch()returnsBatchValidationResultwith per-itemresults,failureCount, andfailureIndices silence()for pure programmatic use with no log output- Optional
verbose: truefor debug-mode traces viadart:developer— fires on init, every passing and failing call, andjson_previewserialisation fallback - Zero runtime dependencies — pure Dart, works in Flutter, server, and CLI
Getting started #
dependencies:
json_sentinel: ^0.4.0
Usage #
1. Wire in your logger once at startup #
JsonSentinel.configure(
(message, {error, stackTrace, extras, escalate}) {
// escalate: true → full capture; false → breadcrumb
if (escalate == true) {
Sentry.captureMessage(message, hint: Hint.withMap(extras ?? {}));
} else {
Sentry.addBreadcrumb(Breadcrumb(message: message));
}
},
verbose: true, // optional: dart:developer traces in debug mode
);
If configure() has not been called, failures fall back to dart:developer (visible in
DevTools Logging; suppressed in release builds). Always call configure() or silence() in
production code. Use JsonSentinel.logger to check whether a logger is already registered.
No logging needed?
Use silence() when you only want programmatic access to result.errors:
JsonSentinel.silence(); // installs a no-op logger — no output of any kind
silence() and configure() are mutually exclusive — call one or the other, never both.
2. Validate #
No model class required — validate any Map<String, dynamic> directly:
final result = JsonSentinel.validate(
json: responseMap,
expectedTypes: {'id': [int], 'name': [String], 'active': [bool]},
context: 'UserInput',
);
if (result.isValid) {
// types confirmed — safe to cast and use
final id = responseMap['id'] as int;
}
Useful for scripts, middleware, configuration checks, and tests where you don't need a full model class — just a shape check before you proceed.
In a tryFromJson factory
For structured deserialisation, validate before casting:
static ProductListing? tryFromJson(Map<String, dynamic> json) {
final result = JsonSentinel.validate(
json: json,
expectedTypes: {
'productId': [int],
'sku': [String],
'price': [int, double], // union — API may return either
'description': [String, null], // nullable
},
optional: {'description'}, // absent is fine; type-checked if present
context: 'ProductListing',
escalate: true,
);
if (!result.isValid) return null;
return ProductListing(
productId: json['productId'] as int,
sku: json['sku'] as String,
price: (json['price'] as num).toDouble(),
description: json['description'] as String?,
);
}
3. Batch validation #
validateBatch() validates a list of payloads against a shared schema and emits exactly
one consolidated log entry — no matter how many items fail. Use it instead of calling
validate() in a loop when failures should produce a single error reporter event.
final batch = JsonSentinel.validateBatch(
jsons: payloads, // List<Map<String, dynamic>>
expectedTypes: {
'id': [int],
'name': [String],
'role': [String],
},
optional: {'role'}, // same optional/strict/escalate semantics as validate()
context: 'UserRecord',
escalate: true,
generatePreviews: true, // set false to skip jsonEncode on each failing item
);
BatchValidationResult fields:
| Field | Type | Description |
|---|---|---|
isValid |
bool |
true only when every item passed |
results |
List<JsonValidationResult> |
One result per input item, in order |
failureCount |
int |
Number of failing items |
failureIndices |
List<int> |
Zero-based indices of failing items |
Converting results to models
// Pattern A — strict: abort if any item is invalid
if (!batch.isValid) return null;
// Pattern B — lenient: skip invalid items, convert passing ones
final users = [
for (var i = 0; i < payloads.length; i++)
if (batch.results[i].isValid) UserRecord.fromValidJson(payloads[i]),
];
// Pattern C — all failed
if (batch.failureCount == payloads.length) {
// nothing valid to process
}
Batch extras for your error reporter
On failure the logger receives extras with 'context', 'failure_count' (int),
'total_count' (int), and 'item_previews' — a List<String> of truncated JSON
snapshots for each failing item, in failureIndices order. Pass
generatePreviews: false to omit item_previews and skip JSON serialisation for
all failing items (useful for large high-failure batches):
Sentry.captureMessage(message, hint: Hint.withMap({
'previews': extras?['item_previews'], // List<String>
}));
List validation (bare JSON arrays) #
When an endpoint returns a bare JSON array — not a keyed envelope — use validateList()
instead of manually casting and filtering before validateBatch():
final batch = JsonSentinel.validateList(
raw: jsonDecode(response) as List<dynamic>, // directly from jsonDecode
expectedTypes: {'id': [int], 'name': [String]},
context: 'UserRecord',
);
Non-Map items at index N produce a failure ("Item N is not a Map<String, dynamic>.")
counted in failureIndices rather than throwing a cast error. Map items are validated
identically to validateBatch().
tryFromListJson pattern
// Full-or-nothing: return null if any item fails.
static List<UserRecord>? tryFromListJson(List<dynamic> raw) {
final batch = JsonSentinel.validateList(
raw: raw,
expectedTypes: {'id': [int], 'name': [String]},
context: 'UserRecord',
);
if (!batch.isValid) return null;
return [
for (final item in raw)
if (item is Map<String, dynamic>) UserRecord.fromValidJson(item),
];
}
// Skip-bad-items: return only valid items using failureIndices.
static List<UserRecord> fromValidListJson(List<dynamic> raw) {
final batch = JsonSentinel.validateList(
raw: raw,
expectedTypes: {'id': [int], 'name': [String]},
context: 'UserRecord',
);
return [
for (int i = 0; i < raw.length; i++)
if (!batch.failureIndices.contains(i) && raw[i] is Map<String, dynamic>)
UserRecord.fromValidJson(raw[i] as Map<String, dynamic>),
];
}
validateList() accepts the same optional parameters as validateBatch(): optional,
strict, warnUnexpected, validators, escalate, and generatePreviews.
Paginated responses #
When a list endpoint wraps items in an envelope object, use validate() for the outer
shape and validateBatch() for the items inside. This produces at most two log entries —
one if the envelope itself is malformed (worth escalating), one covering all item failures
combined (a single breadcrumb, not one event per bad record).
static PaginatedUserResponse? tryFromJson(Map<String, dynamic> json) {
// Step 1: validate the outer envelope — structural errors here are worth escalating.
final envelope = JsonSentinel.validate(
json: json,
expectedTypes: {
'current_page': [int],
'total': [int],
'data': [List],
},
context: 'UserPage',
escalate: true,
);
if (!envelope.isValid) return null;
// Step 2: validate every item inside data — one breadcrumb for all failures combined.
// Filter non-Map elements rather than using a lazy cast that throws at runtime.
final items = [
for (final item in json['data'] as List)
if (item is Map<String, dynamic>) item,
];
final batch = JsonSentinel.validateBatch(
jsons: items,
expectedTypes: {
'id': [int],
'name': [String],
'role': [String],
},
context: 'UserRecord',
escalate: false,
);
// Step 3: return the page with valid items (lenient: skip bad records, keep good ones).
return PaginatedUserResponse(
currentPage: json['current_page'] as int,
total: json['total'] as int,
users: [
for (var i = 0; i < items.length; i++)
if (batch.results[i].isValid) UserRecord.fromValidJson(items[i]),
],
);
}
Nested JSON #
Validate the outer shape at each level; let child models validate their own fields.
Failures carry the child's own context string, so log entries are always pinpointed.
// Parent: validates top-level keys as List/Map only.
static PaginatedResponse? tryFromJson(Map<String, dynamic> json) {
final result = JsonSentinel.validate(
json: json,
expectedTypes: {
'data': [List],
'links': [Map],
'meta': [Map],
},
context: 'PaginatedResponse',
escalate: true,
);
if (!result.isValid) return null;
final meta = MetaModel.tryFromJson(
Map<String, dynamic>.from(json['meta'] as Map),
);
if (meta == null) return null;
// ...
}
// Child: validates its own schema, including nullable fields.
static MetaModel? tryFromJson(Map<String, dynamic> json) {
final result = JsonSentinel.validate(
json: json,
expectedTypes: {
'current_page': [int],
'from': [null, int], // null when page is empty
'last_page': [int],
'links': [List],
'path': [String],
'per_page': [int],
'to': [null, int], // null when page is empty
'total': [int],
},
context: 'MetaModel',
escalate: true,
);
if (!result.isValid) return null;
// ...
}
parentContext — chained paths across nested models #
When calling validate() in a loop over nested items, pass parentContext to include
the parent path in every log entry. Without it, errors from index 1, 2, and 19 all read
[MetaModel] — with it, the log pinpoints each one:
for (var i = 0; i < items.length; i++) {
JsonSentinel.validate(
json: items[i]['meta'] as Map<String, dynamic>,
expectedTypes: {'total': [int], 'per_page': [int]},
context: 'MetaModel',
parentContext: 'UserPage.data[$i]',
);
}
// [UserPage.data[1] > MetaModel] JSON validation failed (1 error):
// • Missing required key 'total'.
The combined path is also stored in extras['context'], so error reporter breadcrumbs and
fingerprints are accurate without any extra work.
validateBatch() does not accept parentContext — it builds its own indexed path
(Item 1, Item 2, …) internally.
Optional fields #
Keys listed in optional are skipped when absent but still type-checked when present:
JsonSentinel.validate(
json: json,
expectedTypes: {'id': [int], 'nickname': [String]},
optional: {'nickname'},
);
Strict mode #
Reject keys present in the JSON but not declared in your schema:
JsonSentinel.validate(
json: json,
expectedTypes: {'id': [int]},
strict: true,
);
warnUnexpected — API drift without failures #
strict: true fails validation on any unknown key. warnUnexpected: true logs them as
warnings instead — result.isValid remains true — so your app keeps working while you
are notified of API contract drift:
// API v2 added 'created_at' — your v1 schema doesn't know about it yet.
final result = JsonSentinel.validate(
json: responseMap,
expectedTypes: {'id': [int], 'status': [String]},
warnUnexpected: true,
context: 'UserRecord',
);
// [WARN] [UserRecord] JSON validation warning (1 unexpected key):
// • Unexpected key 'created_at'.
// result.isValid → true
warnUnexpected and strict are mutually exclusive (asserted in debug mode). Warning
logs always use escalate: false. Works identically in validateBatch() and
validateList(), where warnings are consolidated into a single batch warning log.
Per-field validators #
validators accepts a Map<String, bool Function(Object?)> of predicate functions run
after type validation passes for each key. Use it for lightweight domain constraints —
allowed enum values, numeric ranges, non-empty strings:
final result = JsonSentinel.validate(
json: orderJson,
expectedTypes: {
'status': [String],
'quantity': [int],
},
validators: {
'status': (v) => const ['active', 'inactive', 'pending'].contains(v),
'quantity': (v) => (v as int) > 0,
},
context: 'OrderRecord',
);
// [WARN] [OrderRecord] JSON validation failed (2 errors):
// • Key 'status' failed custom validation.
// • Key 'quantity' failed custom validation.
Predicates are skipped for absent optional keys and for keys that already failed type
validation. Works identically in validateBatch() and validateList().
Redaction #
Pass redactKeys to configure() to mask sensitive top-level field values in every
json_preview and item_previews snapshot sent to your error reporter. Values matching
a listed key are replaced with '[REDACTED]' (or your custom placeholder) before the
JSON is encoded — the validated data itself is never modified.
JsonSentinel.configure(
(message, {error, stackTrace, extras, escalate}) { ... },
redactKeys: {'password', 'token', 'apiKey', 'secret'},
redactionPlaceholder: '[REDACTED]', // optional, this is the default
);
Given {'userId': 42, 'password': 'hunter2', 'token': 'abc123'}, the json_preview
in extras becomes:
{"userId": 42, "password": "[REDACTED]", "token": "[REDACTED]"}
Redaction applies to both json_preview (single-item) and item_previews (batch) — no
separate configuration needed. Only top-level keys are redacted; nested object contents
are not inspected.
silence() accepts the same redactKeys and redactionPlaceholder parameters for
consistency.
Programmatic error access #
final result = JsonSentinel.validate(json: json, expectedTypes: schema);
if (!result.isValid) {
for (final error in result.errors) {
print(error);
}
}
Example log output #
Single-item failure:
[ProductListing] JSON validation failed (2 errors):
• Key 'productId' has invalid type. Expected: int; Actual: String.
• Missing required key 'sku'.
Single-item failure with parentContext:
[UserPage.data[2] > MetaModel] JSON validation failed (1 error):
• Missing required key 'total'.
Batch failure:
[UserRecord] JSON batch validation failed (2 of 4 items failed):
Item 1 (1 error):
• Missing required key 'role'.
Item 2 (1 error):
• Key 'id' has invalid type. Expected: int; Actual: String.
Testing #
configure() and silence() both assert in debug mode if called twice. Call
JsonSentinel.resetLoggerForTesting() in tearDown to allow re-configuration across test cases:
setUp(() {
JsonSentinel.configure((message, {error, stackTrace, extras, escalate}) {
logs.add(message);
});
});
tearDown(JsonSentinel.resetLoggerForTesting);
Schema reference #
| Type list value | Meaning |
|---|---|
[int] |
Required, must be int |
[String] |
Required, must be String |
[bool] |
Required, must be bool |
[double] |
Required, must be double |
[num] |
Required, int or double — prefer [int, double] when you want to signal intent explicitly |
[int, double] |
Required, int or double (explicit union) |
[Map] |
Required, any Map |
[List] |
Required, any List |
[String, null] |
Required, String or null |
[null, int] |
Required, null or int |
null or [] |
Required key, type not checked |
Add the key to optional to make presence itself optional.
Additional information #
- pub.dev package page
- API documentation
- File issues
- Changelog
- Contributing — open an issue before starting significant work
- Security policy
- Code of Conduct