json_sentinel 0.4.0 copy "json_sentinel: ^0.4.0" to clipboard
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.

example/json_sentinel_example.dart

// ignore_for_file: avoid_print
import 'package:json_sentinel/json_sentinel.dart';

// Captures the last extras map received by the logger — used in the batch
// section to print item_previews. In a real app, forward extras directly
// inside your configure() callback.
Map<String, Object?>? _lastExtras;

void main() {
  // region Setup: configure() -----------------------------------------------
  //
  // Call configure() OR silence() exactly once at app startup — never both.
  // configure() registers a JsonLogFn that receives every validation failure.
  //
  // In production, forward to your error reporter:
  //
  //   JsonSentinel.configure(
  //     (message, {error, stackTrace, extras, escalate}) {
  //       if (escalate == true) {
  //         Sentry.captureMessage(message, hint: Hint.withMap(extras ?? {}));
  //       } else {
  //         Sentry.addBreadcrumb(Breadcrumb(message: message));
  //       }
  //     },
  //     verbose: true, // emits dart:developer traces on init, each validate/
  //                    // validateBatch call (pass or fail), and json_preview
  //                    // fallback — independent of silence()
  //   );
  //
  // Use resetLoggerForTesting() in test tearDown to allow re-configuration:
  //
  //   tearDown(JsonSentinel.resetLoggerForTesting);
  //   // resets _logger, _verbose, _redactKeys, and _redactionPlaceholder

  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
  );

  // logger getter — null until configure() or silence() is called.
  print('Logger configured: ${JsonSentinel.logger != null}');
  // → Logger configured: true

  // endregion

  // region validate(): single-item validation --------------------------------
  //
  // All parameters:
  //   json          — the Map<String, dynamic> to validate
  //   expectedTypes — schema: key → allowed types (include null to allow null)
  //   context       — model name shown in log output; defaults to 'UnknownModel'
  //   parentContext — optional parent path; produces '$parentContext > $context'
  //                   in log output and extras['context'] (see parentContext region)
  //   optional      — keys skipped when absent, type-checked when present
  //   strict        — flag keys in json not declared in expectedTypes (see region 6)
  //   escalate      — hint to logger for elevated capture; defaults to false
  //
  // All errors are collected before a single log entry fires — validate()
  // never returns early. result.isValid is the primary guard.

  // Standalone — no model class needed. Useful for scripts, middleware, tests,
  // and config validation where you just need a shape check before proceeding.
  final eventJson = <String, dynamic>{'event': 'page_view', 'screen': 'home', 'duration_ms': 320};
  final check = JsonSentinel.validate(
    json: eventJson,
    expectedTypes: {
      'event': [String],
      'screen': [String],
      'duration_ms': [int],
    },
    context: 'AnalyticsEvent',
  );
  if (check.isValid) {
    print('Event: ${eventJson['event']} — screen: ${eventJson['screen']} (${eventJson['duration_ms']}ms)');
  }
  // → Event: page_view — screen: home (320ms)

  // Happy path — all types correct, optional key absent:
  final listing = ProductListing.tryFromJson({'productId': 42, 'sku': 'WIDGET-XL', 'price': 19.99});
  if (listing != null) {
    print('Product ${listing.productId} — ${listing.sku} at \$${listing.price}');
    // → Product 42 — WIDGET-XL at $19.99
  }

  // Failure path — missing required key + type mismatch, one log entry covers both:
  final badResult = JsonSentinel.validate(
    json: <String, dynamic>{'sku': 'WIDGET-XL', 'price': 'not-a-number'},
    expectedTypes: {
      'productId': [int],
      'sku': [String],
      'price': [int, double], // union — int or double accepted
    },
    context: 'ProductListing',
    escalate: true,
  );
  print('badResult.isValid: ${badResult.isValid}');
  // → [ERROR] [ProductListing] JSON validation failed (2 errors):
  // →   • Missing required key 'productId'.
  // →   • Key 'price' has invalid type. Expected: int, double; Actual: String.
  // → badResult.isValid: false

  // optional — keys skipped when absent, but type-checked when present.
  // Absent key → passes silently. Present key with wrong type → logged as normal.
  final optAbsent = JsonSentinel.validate(
    json: <String, dynamic>{'id': 1},
    expectedTypes: {
      'id': [int],
      'nickname': [String],
    },
    optional: {'nickname'},
    context: 'Profile',
  );
  print('optional absent: ${optAbsent.isValid}');
  // → optional absent: true   (no log — absent optional key is fine)

  final optWrongType = JsonSentinel.validate(
    json: <String, dynamic>{'id': 1, 'nickname': 99},
    expectedTypes: {
      'id': [int],
      'nickname': [String],
    },
    optional: {'nickname'},
    context: 'Profile',
  );
  print('optional present+wrong type: ${optWrongType.isValid}');
  // → [WARN] [Profile] JSON validation failed (1 error):
  // →   • Key 'nickname' has invalid type. Expected: String; Actual: int.
  // → optional present+wrong type: false

  // expectedTypes type syntax quick reference:
  //   [int]          — exact single type
  //   [int, double]  — union (either accepted)
  //   [String, null] — nullable (String or null)
  //   null or []     — existence-only (any value accepted, just checks key is present)

  // endregion

  // region silence(): programmatic error access ------------------------------
  //
  // silence() is the alternative to configure() — installs a no-op logger.
  // Use it when you only need result.errors and do not want any log output.
  //
  // In a real app you would call silence() instead of configure() at startup.
  // Here, resetLoggerForTesting() is used to switch modes within a single
  // runnable file — it is a test-only API and should not appear in production.

  JsonSentinel.resetLoggerForTesting(); // test-only: resets _logger, _verbose, and redaction state
  JsonSentinel.silence();

  final silentResult = JsonSentinel.validate(
    json: <String, dynamic>{'id': 'not-an-int', 'name': 42},
    expectedTypes: {
      'id': [int],
      'name': [String],
    },
    context: 'SilentModel',
  );

  // No logger fires — read errors directly from result.errors:
  if (!silentResult.isValid) {
    print('${silentResult.errors.length} validation error(s):');
    for (final error in silentResult.errors) {
      print('  — $error');
    }
  }
  // → 2 validation error(s):
  // →   — Key 'id' has invalid type. Expected: int; Actual: String.
  // →   — Key 'name' has invalid type. Expected: String; Actual: int.

  // Restore configure() for the remaining sections.
  JsonSentinel.resetLoggerForTesting();
  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
  );

  // endregion

  // region Nested model validation -------------------------------------------
  //
  // Each model validates its own fields independently. Failures carry distinct
  // context labels ('PaginatedResponse', 'MetaModel') so log entries are always
  // attributable to the layer that produced them.

  final page = PaginatedResponse.tryFromJson(<String, dynamic>{
    'data': <dynamic>[],
    'links': <String, dynamic>{'first': 'https://api.example.com/products?page=1', 'last': null, 'prev': null, 'next': null},
    'meta': <String, dynamic>{
      'current_page': 1,
      'from': null, // null when the page is empty
      'last_page': 1,
      'links': <dynamic>[],
      'path': 'https://api.example.com/products',
      'per_page': 15,
      'to': null, // null when the page is empty
      'total': 0,
    },
  });

  if (page != null) {
    print('Page ${page.meta.currentPage} of ${page.meta.lastPage} — ${page.meta.total} total');
    // → Page 1 of 1 — 0 total
  }

  // endregion

  // region parentContext: chained paths across nested validate() calls --------
  //
  // When validate() is called inside a loop over nested models, passing
  // parentContext makes the log prefix include the full path to the failing item —
  // e.g. [UserPage.data[2] > MetaModel] instead of just [MetaModel].
  //
  // Without parentContext, it is impossible to know which index in the parent list
  // produced the failure. With it, the log entry is self-contained.
  //
  // extras['context'] reflects the same chained string, so your error reporter's
  // fingerprinting and breadcrumb labels are accurate automatically.

  final metaItems = <Map<String, dynamic>>[
    {'total': 0, 'per_page': 15}, // valid
    {'per_page': 15}, // missing 'total' — will log [UserPage.data[1] > MetaModel]
    {'total': 'bad', 'per_page': 15}, // wrong type — will log [UserPage.data[2] > MetaModel]
  ];

  for (var index = 0; index < metaItems.length; index++) {
    JsonSentinel.validate(
      json: metaItems[index],
      expectedTypes: {
        'total': [int],
        'per_page': [int],
      },
      context: 'MetaModel',
      parentContext: 'UserPage.data[$index]',
    );
  }
  // → [WARN] [UserPage.data[1] > MetaModel] JSON validation failed (1 error):
  // →   • Missing required key 'total'.
  // → [WARN] [UserPage.data[2] > MetaModel] JSON validation failed (1 error):
  // →   • Key 'total' has invalid type. Expected: int; Actual: String.

  // extras['context'] carries the full chained path — useful for error reporter grouping:
  //   extras['context'] == 'UserPage.data[1] > MetaModel'

  // endregion

  // region warnUnexpected: log API drift without failing ----------------------
  //
  // APIs often add new fields in minor versions. strict: true would fail the whole
  // validation on any unknown key. warnUnexpected: true logs the unknown keys as
  // warnings but keeps result.isValid = true — the app continues working while you
  // are notified of the contract drift.
  //
  // warnUnexpected and strict are mutually exclusive (assert in debug mode).

  // Simulate a v1 schema receiving a v2 response with new fields:
  final v2Response = <String, dynamic>{
    'id': 42,
    'status': 'active',
    'created_at': '2026-06-03', // new in v2 — unknown to v1 schema
    'metadata': {'source': 'web'}, // also new in v2
  };

  final warnResult = JsonSentinel.validate(
    json: v2Response,
    expectedTypes: {
      'id': [int],
      'status': [String],
    },
    warnUnexpected: true,
    context: 'UserRecord',
  );
  print('warnUnexpected isValid: ${warnResult.isValid}');
  // → [WARN] [UserRecord] JSON validation warning (2 unexpected keys):
  // →   • Unexpected key 'created_at'.
  // →   • Unexpected key 'metadata'.
  // → warnUnexpected isValid: true

  // endregion

  // region validators: per-field value checks after type validation ------------
  //
  // validators accepts a Map<String, bool Function(Object?)> of predicate lambdas
  // that run after type validation passes for that key. A predicate returning
  // false adds a bullet error identifying the key. Predicates are skipped for
  // absent optional keys and for keys that already failed type validation.
  //
  // Use this for lightweight domain constraints — allowed enum values, numeric
  // ranges, non-empty strings — without turning json_sentinel into a full
  // constraint library.

  final orderJson = <String, dynamic>{
    'status': 'unknown', // invalid — not in the allowed set
    'quantity': -5, // invalid — must be > 0
    'sku': 'WIDGET-XL', // valid
  };

  final validatorResult = JsonSentinel.validate(
    json: orderJson,
    expectedTypes: {
      'status': [String],
      'quantity': [int],
      'sku': [String],
    },
    validators: {
      'status': (Object? v) => const ['active', 'inactive', 'pending'].contains(v),
      'quantity': (Object? v) => (v as int) > 0,
    },
    context: 'OrderRecord',
  );
  print('validators isValid: ${validatorResult.isValid}');
  // → [WARN] [OrderRecord] JSON validation failed (2 errors):
  // →   • Key 'status' failed custom validation.
  // →   • Key 'quantity' failed custom validation.
  // → validators isValid: false

  // endregion

  // region validateList(): bare JSON arrays from jsonDecode --------------------
  //
  // Many API endpoints return a bare JSON array at the top level, not a keyed
  // envelope. validateList() accepts a List<dynamic> directly from jsonDecode
  // and handles non-Map items as failures with a descriptive error, rather than
  // crashing with a cast error.
  //
  // The returned BatchValidationResult is the same type as validateBatch(), so
  // the same isValid and failureIndices patterns apply.
  //
  // Two idiomatic patterns — add these as static methods on your model class:
  //
  //   tryFromListJson  — returns null when any item fails (strict)
  //   fromValidListJson — skips invalid items and returns the rest (lenient)

  // Simulating a bare JSON array response — item at index 1 is a non-Map value:
  final rawUserList = <dynamic>[
    <String, dynamic>{'id': 1, 'name': 'Alice', 'role': 'admin'},
    'not-a-map', // non-Map item — counted as failure at index 1
    <String, dynamic>{'id': 3, 'name': 'Carol', 'role': 'viewer'},
  ];

  // Pattern 1 — full-or-nothing (tryFromListJson):
  // Returns null when any item fails; all items must be valid.
  final strictList = UserRecord.tryFromListJson(rawUserList);
  print('tryFromListJson (strict): ${strictList == null ? "null — 1 item failed" : "non-null"}');
  // → [WARN] [UserRecord] JSON list validation failed (1 of 3 items failed):
  // →   Item 1 (1 error):
  // →     • Item 1 is not a Map<String, dynamic>.
  // → tryFromListJson (strict): null — 1 item failed

  // Pattern 2 — skip-bad-items (fromValidListJson):
  // Returns only the valid items using failureIndices to exclude failures.
  final lenientList = UserRecord.fromValidListJson(rawUserList);
  print('fromValidListJson (lenient): ${lenientList.map((UserRecord u) => u.name).toList()}');
  // → fromValidListJson (lenient): [Alice, Carol]

  // warnUnexpected and validators work identically inside validateList():
  JsonSentinel.validateList(
    raw: <dynamic>[
      <String, dynamic>{'id': 1, 'name': 'Alice', 'role': 'admin', 'new_field': 'v2'},
    ],
    expectedTypes: {
      'id': [int],
      'name': [String],
      'role': [String],
    },
    warnUnexpected: true,
    validators: {
      'role': (Object? v) => const ['admin', 'editor', 'viewer'].contains(v),
    },
    context: 'UserRecord',
  );
  // → [WARN] [UserRecord] JSON list validation warning (1 of 1 item had unexpected keys):
  // →   Item 0 (1 unexpected key):
  // →     • Unexpected key 'new_field'.

  // endregion

  // region validateBatch(): batch validation ---------------------------------
  //
  // validateBatch() validates a list of payloads against a shared schema and
  // emits exactly ONE consolidated log entry covering all failures. This prevents
  // one error reporter event per bad record when processing a list endpoint.
  //
  // The returned BatchValidationResult carries:
  //   isValid        — true only when every item passed
  //   results        — one JsonValidationResult per input item, in order
  //   failureCount   — number of failing items
  //   failureIndices — zero-based indices of failing items (ascending)
  //
  // On failure the logger extras map contains:
  //   'context'       — model name (String)
  //   'failure_count' — number of failing items (int)
  //   'total_count'   — total items in the batch (int)
  //   'item_previews' — truncated JSON per failing item in failureIndices order
  //                     (List<String>); absent when generatePreviews: false

  final records = <Map<String, dynamic>>[
    {'id': 1, 'name': 'Alice', 'role': 'admin'},
    {'id': 2, 'name': 'Bob'}, // missing 'role'
    {'id': 'three', 'name': 'Carol', 'role': 'viewer'}, // wrong type for 'id'
    {'id': 4, 'name': 'Dave', 'role': 'editor'},
  ];

  final batch = JsonSentinel.validateBatch(
    jsons: records,
    expectedTypes: {
      'id': [int],
      'name': [String],
      'role': [String],
    },
    context: 'UserRecord',
    escalate: true,
  );
  // → [ERROR] [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.

  // Per-item error detail from batch.results:
  print('${batch.failureCount} of ${records.length} invalid:');
  for (final i in batch.failureIndices) {
    print('  Item $i: ${batch.results[i].errors.join('; ')}');
  }
  // → 2 of 4 invalid:
  // →   Item 1: Missing required key 'role'.
  // →   Item 2: Key 'id' has invalid type. Expected: int; Actual: String.

  // item_previews — one JSON snapshot per failing item, in failureIndices order.
  // Forward them to Sentry in your configure() callback:
  //
  //   Sentry.captureMessage(message, hint: Hint.withMap({
  //     'previews': extras?['item_previews'], // List<String>
  //   }));
  final previews = _lastExtras?['item_previews'] as List<String>?;
  if (previews != null) {
    for (var k = 0; k < previews.length; k++) {
      print('  Preview[${batch.failureIndices[k]}]: ${previews[k]}');
    }
  }
  // →   Preview[1]: {"id":2,"name":"Bob"}
  // →   Preview[2]: {"id":"three","name":"Carol","role":"viewer"}

  // --- Converting batch results to models -----------------------------------

  // Pattern A — strict: abort entirely if any item is invalid.
  if (!batch.isValid) {
    print('Strict: ${batch.failureCount} invalid — aborting all');
    // → Strict: 2 invalid — aborting all
  }

  // Pattern B — lenient: skip invalid items, convert passing ones.
  // batch.results[i].isValid confirms all types — casts in fromValidJson are safe.
  final users = <UserRecord>[
    for (var i = 0; i < records.length; i++)
      if (batch.results[i].isValid) UserRecord.fromValidJson(records[i]),
  ];
  print('Lenient: converted ${users.length} of ${records.length} records:');
  for (final user in users) {
    print('  ${user.id} — ${user.name} (${user.role})');
  }
  // → Lenient: converted 2 of 4 records:
  // →   1 — Alice (admin)
  // →   4 — Dave (editor)

  // Pattern C — all failed: nothing to convert.
  if (batch.failureCount == records.length) {
    print('All records invalid — nothing to process');
  }

  // --- optional and strict apply per-item, same semantics as validate() -----

  // optional: key absent across all items → passes; present with wrong type → fails.
  final batchOpt = JsonSentinel.validateBatch(
    jsons: [
      {'id': 1, 'role': 'admin'}, // nickname absent — fine
      {'id': 2, 'role': 'editor', 'nickname': 99}, // nickname present + wrong type
    ],
    expectedTypes: {
      'id': [int],
      'role': [String],
      'nickname': [String],
    },
    optional: {'nickname'},
    context: 'BatchOptional',
  );
  print('batch optional: ${batchOpt.failureCount} of 2 failed');
  // → [WARN] [BatchOptional] JSON batch validation failed (1 of 2 items failed): ...
  // → batch optional: 1 of 2 failed

  // generatePreviews: false — skip jsonEncode for every failing item.
  // Use for large high-failure batches where preview generation would be costly.
  JsonSentinel.validateBatch(
    jsons: records,
    expectedTypes: {
      'id': [int],
      'name': [String],
      'role': [String],
    },
    context: 'UserRecord',
    generatePreviews: false,
  );
  // → Same log message as above, but extras has no 'item_previews' key.
  print('item_previews absent: ${!(_lastExtras?.containsKey('item_previews') ?? false)}');
  // → item_previews absent: true

  // strict: unexpected keys in any item are reported as errors.
  final batchStrict = JsonSentinel.validateBatch(
    jsons: [
      {'id': 1, 'role': 'admin'},
      {'id': 2, 'role': 'editor', 'extra': true}, // unexpected key
    ],
    expectedTypes: {
      'id': [int],
      'role': [String],
    },
    strict: true,
    context: 'BatchStrict',
  );
  print('batch strict: ${batchStrict.failureCount} of 2 failed');
  // → [WARN] [BatchStrict] JSON batch validation failed (1 of 2 items failed): ...
  // → batch strict: 1 of 2 failed

  // endregion

  // region Paginated responses: validate() + validateBatch() together ----------
  //
  // The canonical combined pattern: validate() checks the outer envelope once,
  // then validateBatch() validates every item inside data[], producing exactly
  // one log entry for all item failures — not one event per bad record.
  //
  // Envelope failure (wrong type for current_page) → escalate: true → full capture.
  // Item failures (bad records inside data) → escalate: false → single breadcrumb.

  final userPage = PaginatedUserResponse.tryFromJson(<String, dynamic>{
    'current_page': 1,
    'total': 4,
    'data': <dynamic>[
      {'id': 1, 'name': 'Alice', 'role': 'admin'},
      {'id': 2, 'name': 'Bob'}, // missing 'role'
      {'id': 'three', 'name': 'Carol', 'role': 'viewer'}, // wrong type for 'id'
      {'id': 4, 'name': 'Dave', 'role': 'editor'},
    ],
  });
  // → [WARN] [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.

  if (userPage != null) {
    print('Page ${userPage.currentPage} — ${userPage.users.length} of ${userPage.total} valid:');
    for (final user in userPage.users) {
      print('  ${user.name} (${user.role})');
    }
  }
  // → Page 1 — 2 of 4 valid:
  // →   Alice (admin)
  // →   Dave (editor)

  // endregion

  // region Strict mode -------------------------------------------------------
  //
  // When strict: true, keys present in json but absent from expectedTypes are
  // reported as errors. Catches API response drift and unexpected fields early.

  final strictResult = JsonSentinel.validate(
    json: <String, dynamic>{'id': 1, 'name': 'Alice', 'unexpected': 'surprise'},
    expectedTypes: {
      'id': [int],
      'name': [String],
    },
    strict: true,
    context: 'StrictModel',
  );
  print('Strict isValid: ${strictResult.isValid}');
  // → [WARN] [StrictModel] JSON validation failed (1 error):
  // →   • Unexpected key 'unexpected'.
  // → Strict isValid: false

  // endregion

  // region redactKeys: mask sensitive values in json_preview -----------------
  //
  // Pass redactKeys to configure() to mask named top-level fields before any
  // json_preview or item_previews snapshot is sent to your error reporter.
  // The redaction placeholder defaults to '[REDACTED]' and can be overridden.
  // The validated data itself is never modified — only the preview string is affected.
  //
  // In a real app call configure() once at startup with redactKeys.
  // Here, resetLoggerForTesting() lets us switch configurations mid-file.

  JsonSentinel.resetLoggerForTesting();
  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
    redactKeys: {'apiKey', 'password'},
  );

  JsonSentinel.validate(
    json: <String, dynamic>{
      'userId': 7,
      'email': 'alice@example.com',
      'apiKey': 'sk-live-abc123', // sensitive — will be masked in json_preview
      'password': 'hunter2', // sensitive — will be masked in json_preview
    },
    expectedTypes: {
      'missing_field': [int], // deliberately missing to trigger the log
    },
    context: 'LoginPayload',
  );
  // → [WARN] [LoginPayload] JSON validation failed (1 error): ...
  // → json_preview: {"userId":7,"email":"alice@example.com","apiKey":"[REDACTED]","password":"[REDACTED]"}
  final redactedPreview = _lastExtras?['json_preview'] as String?;
  print('preview contains [REDACTED]: ${redactedPreview?.contains('[REDACTED]') ?? false}');
  // → preview contains [REDACTED]: true
  print('preview hides password: ${!(redactedPreview?.contains('hunter2') ?? true)}');
  // → preview hides password: true

  // Custom placeholder — override the default '[REDACTED]' string.
  JsonSentinel.resetLoggerForTesting();
  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
    redactKeys: {'token'},
    redactionPlaceholder: '***',
  );
  JsonSentinel.validate(
    json: <String, dynamic>{'userId': 1, 'token': 'bearer-xyz'},
    expectedTypes: {
      'missing': [int],
    },
    context: 'TokenPayload',
  );
  final tokenPreview = _lastExtras?['json_preview'] as String?;
  print('custom placeholder present: ${tokenPreview?.contains('***') ?? false}');
  // → custom placeholder present: true

  // item_previews in validateBatch() are also redacted automatically —
  // no separate configuration needed.
  JsonSentinel.resetLoggerForTesting();
  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
    redactKeys: {'password'},
  );
  JsonSentinel.validateBatch(
    jsons: [
      {'userId': 1, 'password': 'topsecret'},
    ],
    expectedTypes: {
      'missing': [int],
    },
    context: 'BatchRedact',
  );
  final batchPreviews = _lastExtras?['item_previews'] as List<String>?;
  print('batch preview redacted: ${batchPreviews?.first.contains('[REDACTED]') ?? false}');
  // → batch preview redacted: true

  // Restore a plain logger for any code that follows.
  JsonSentinel.resetLoggerForTesting();
  JsonSentinel.configure(
    (message, {error, stackTrace, extras, escalate}) {
      _lastExtras = extras;
      print('[${escalate == true ? 'ERROR' : 'WARN'}] $message');
    },
  );

  // endregion
}

// ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------

// Used by the validateBatch(), validateList(), and paginated-responses sections.
// fromValidJson() performs no validation — call it only after the relevant
// validate/validateBatch/validateList call confirms the item passed.
class UserRecord {
  final int id;
  final String name;
  final String role;

  static const Map<String, List<Type?>?> _schema = <String, List<Type?>?>{
    'id': <Type?>[int],
    'name': <Type?>[String],
    'role': <Type?>[String],
  };

  UserRecord({required this.id, required this.name, required this.role});

  factory UserRecord.fromValidJson(Map<String, dynamic> json) => UserRecord(
        id: json['id'] as int,
        name: json['name'] as String,
        role: json['role'] as String,
      );

  // Full-or-nothing: returns null when any item in [raw] fails validation.
  // Use when the caller must abort entirely if the list is partially invalid.
  static List<UserRecord>? tryFromListJson(List<dynamic> raw) {
    final BatchValidationResult batch = JsonSentinel.validateList(
      raw: raw,
      expectedTypes: _schema,
      context: 'UserRecord',
    );
    if (!batch.isValid) return null;
    return <UserRecord>[
      for (final Object? item in raw)
        if (item is Map<String, dynamic>) UserRecord.fromValidJson(item),
    ];
  }

  // Skip-bad-items: returns only the valid items using failureIndices.
  // Use when the caller can continue with a partial list.
  static List<UserRecord> fromValidListJson(List<dynamic> raw) {
    final BatchValidationResult batch = JsonSentinel.validateList(
      raw: raw,
      expectedTypes: _schema,
      context: 'UserRecord',
    );
    return <UserRecord>[
      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>),
    ];
  }
}

// Used by the 'Paginated responses' region. Demonstrates validate() +
// validateBatch() combined — envelope validation followed by per-item batch
// validation, producing at most two log entries regardless of item count.
class PaginatedUserResponse {
  final int currentPage;
  final int total;
  final List<UserRecord> users;

  PaginatedUserResponse({
    required this.currentPage,
    required this.total,
    required this.users,
  });

  static PaginatedUserResponse? tryFromJson(Map<String, dynamic> json) {
    // Step 1: validate the outer envelope — structural errors 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.
    // Filter non-Map elements out rather than using a lazy cast that would throw
    // inside validateBatch() if the API returns unexpected element types.
    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 — skip invalid items, keep valid 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]),
      ],
    );
  }
}

// Used by the single-item validate() happy-path section. Demonstrates the
// tryFromJson pattern: validate first, cast only after isValid is confirmed.
class ProductListing {
  final int productId;
  final String sku;
  final double price;
  final String? description;

  ProductListing({
    required this.productId,
    required this.sku,
    required this.price,
    this.description,
  });

  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 when 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?,
    );
  }
}

// Used by the 'Nested model validation' region. Demonstrates nested model
// delegation — each level validates its own fields with a separate context label.
// Compare PaginatedUserResponse above, which uses validateBatch() on the items.
class PaginatedResponse {
  final List<dynamic> data;
  final Map<String, dynamic> links;
  final MetaModel meta;

  PaginatedResponse({required this.data, required this.links, required this.meta});

  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;

    return PaginatedResponse(
      data: List<dynamic>.from(json['data'] as List),
      links: Map<String, dynamic>.from(json['links'] as Map),
      meta: meta,
    );
  }
}

class MetaModel {
  final int currentPage;
  final int? from; // null when the page contains no items
  final int lastPage;
  final int perPage;
  final int? to; // null when the page contains no items
  final int total;

  MetaModel({
    required this.currentPage,
    required this.lastPage,
    required this.perPage,
    required this.total,
    this.from,
    this.to,
  });

  static MetaModel? tryFromJson(Map<String, dynamic> json) {
    final result = JsonSentinel.validate(
      json: json,
      expectedTypes: {
        'current_page': [int],
        'from': [null, int], // nullable — no items on this page
        'last_page': [int],
        'links': [List],
        'path': [String],
        'per_page': [int],
        'to': [null, int], // nullable — no items on this page
        'total': [int],
      },
      context: 'MetaModel',
      escalate: true,
    );
    if (!result.isValid) return null;

    return MetaModel(
      currentPage: json['current_page'] as int,
      from: json['from'] as int?,
      lastPage: json['last_page'] as int,
      perPage: json['per_page'] as int,
      to: json['to'] as int?,
      total: json['total'] as int,
    );
  }
}
4
likes
160
points
198
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Lightweight runtime JSON validation for Dart. Configurable logger, redaction, and batch validation — routes failures to any error reporter. No code generation.

Repository (GitHub)
View/report issues
Contributing

Topics

#json #validation #schema-validation #logging #observability

License

MIT (license)

More

Packages that depend on json_sentinel