json_sentinel 0.2.0 copy "json_sentinel: ^0.2.0" to clipboard
json_sentinel: ^0.2.0 copied to clipboard

Lightweight runtime JSON key and type validation for Dart. No code generation required.

json_sentinel #

CI pub.dev

Lightweight runtime JSON key and type validation for Dart — 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
  • Single log entry per call listing every problem as a bullet — nothing hidden by an early return
  • validateBatch() — validates a list of payloads against a shared schema and emits one consolidated log entry for all failures, preventing duplicate Sentry/Crashlytics events when processing list endpoints
  • json_preview attached to single-item extras; item_previews (List<String>) attached to batch extras — one JSON preview per failing item (opt out with generatePreviews: false)
  • Configurable escalate flag per call — control whether a failure is a breadcrumb or a full capture
  • Returns JsonValidationResult with isValid + programmatic errors list; validateBatch() returns BatchValidationResult with per-item results, failureCount, and failureIndices
  • silence() for pure programmatic use with no log output
  • Optional verbose: true for debug-mode traces via dart:developer — fires on init, every passing and failing call, and json_preview serialisation fallback
  • Zero runtime dependencies — pure Dart, works in Flutter, server, and CLI

Getting started #

dependencies:
  json_sentinel: ^0.2.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 OrderResponse? tryFromJson(Map<String, dynamic> json) {
  final result = JsonSentinel.validate(
    json: json,
    expectedTypes: {
      'orderId':   [int],
      'depotCode': [String],
      'litres':    [int, double],   // union — API may return either
      'notes':     [String, null],  // nullable
    },
    optional: {'notes'},            // absent is fine; type-checked if present
    context: 'OrderResponse',
    escalate: true,
  );
  if (!result.isValid) return null;

  return OrderResponse(
    orderId:   json['orderId'] as int,
    depotCode: json['depotCode'] as String,
    litres:    (json['litres'] as num).toDouble(),
    notes:     json['notes'] 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 Sentry 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 Sentry

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>
}));

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 Sentry capture 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;
  // ...
}

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,
);

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:

[OrderResponse] JSON validation failed (2 errors):
  • Key 'orderId' has invalid type. Expected: int; Actual: String.
  • Missing required key 'depotCode'.

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 #

4
likes
0
points
198
downloads

Publisher

unverified uploader

Weekly Downloads

Lightweight runtime JSON key and type validation for Dart. No code generation required.

Repository (GitHub)
View/report issues

Topics

#json #validation #schema-validation

License

unknown (license)

More

Packages that depend on json_sentinel