From 3a1c35da5982e752118fbc960324b988d6a45bcf Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 13:50:21 -0400 Subject: [PATCH 01/13] feat(schema-model): extract schema_model into standalone package Moves AckSchemaModel and related types into a new `schema_model` package, adds legacy aliases in `ack` for backwards compatibility, and introduces standard_schema/schema_model_parser alongside updated changelogs and CI config. --- .github/workflows/release.yml | 1 + PUBLISHING.md | 13 +- README.md | 1 + packages/ack/CHANGELOG.md | 13 + packages/ack/lib/ack.dart | 1 + packages/ack/lib/src/context.dart | 15 + packages/ack/lib/src/json_schema.dart | 6 +- .../ack_schema_model_builder.dart | 119 +++-- .../legacy_schema_model_aliases.dart | 59 +++ packages/ack/lib/src/schemas/schema.dart | 44 +- .../ack/lib/src/schemas/wrapper_schema.dart | 2 +- .../lib/src/validation/standard_issues.dart | 23 + packages/ack/pubspec.yaml | 1 + .../ack_schema_model_builder_test.dart | 57 ++- .../legacy_alias_compat_test.dart | 74 +++ .../ack/test/schemas/lazy_schema_test.dart | 2 +- .../nullable_constraint_json_schema_test.dart | 8 +- .../standard_schema_conformance_test.dart | 113 +++++ packages/ack_firebase_ai/CHANGELOG.md | 1 + .../manifest.json | 2 +- .../firebase_ai_native_schema/manifest.json | 2 +- ...firebase_ai_response_json_schema_test.dart | 2 +- ...irebase_ai_response_json_schema_cases.dart | 64 ++- packages/ack_json_schema_builder/CHANGELOG.md | 2 + .../lib/ack_json_schema_builder.dart | 6 +- .../test/to_json_schema_builder_test.dart | 32 +- packages/schema_model/.pubignore | 3 + packages/schema_model/CHANGELOG.md | 10 + packages/schema_model/LICENSE | 29 ++ packages/schema_model/README.md | 27 ++ packages/schema_model/analysis_options.yaml | 7 + packages/schema_model/lib/schema_model.dart | 6 + .../lib/src/schema_model.dart} | 317 ++++++------ .../lib/src/schema_model_parser.dart | 450 ++++++++++++++++++ .../lib/src/schema_model_warning.dart} | 6 +- .../schema_model/lib/src/standard_schema.dart | 76 +++ packages/schema_model/pubspec.yaml | 19 + .../test/schema_model_parser_test.dart | 216 +++++++++ .../test/schema_model_test.dart} | 73 ++- .../test/standard_schema_test.dart | 101 ++++ pubspec.yaml | 3 + scripts/update_release_changelog.dart | 1 + 42 files changed, 1651 insertions(+), 356 deletions(-) create mode 100644 packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart create mode 100644 packages/ack/lib/src/validation/standard_issues.dart create mode 100644 packages/ack/test/schema_model/legacy_alias_compat_test.dart create mode 100644 packages/ack/test/validation/standard_schema_conformance_test.dart create mode 100644 packages/schema_model/.pubignore create mode 100644 packages/schema_model/CHANGELOG.md create mode 100644 packages/schema_model/LICENSE create mode 100644 packages/schema_model/README.md create mode 100644 packages/schema_model/analysis_options.yaml create mode 100644 packages/schema_model/lib/schema_model.dart rename packages/{ack/lib/src/schema_model/ack_schema_model.dart => schema_model/lib/src/schema_model.dart} (77%) create mode 100644 packages/schema_model/lib/src/schema_model_parser.dart rename packages/{ack/lib/src/schema_model/ack_schema_model_warning.dart => schema_model/lib/src/schema_model_warning.dart} (87%) create mode 100644 packages/schema_model/lib/src/standard_schema.dart create mode 100644 packages/schema_model/pubspec.yaml create mode 100644 packages/schema_model/test/schema_model_parser_test.dart rename packages/{ack/test/schema_model/ack_schema_model_test.dart => schema_model/test/schema_model_test.dart} (65%) create mode 100644 packages/schema_model/test/standard_schema_test.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 326ba7d3..a6139494 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,3 +21,4 @@ jobs: ack_generator ack_json_schema_builder ack_firebase_ai + schema_model diff --git a/PUBLISHING.md b/PUBLISHING.md index 515d4ab5..07b72e20 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -19,7 +19,7 @@ Before creating a release: 1. Ensure all changes are committed and pushed to the `main` branch 2. Verify that all tests pass by running `melos test` (include `melos run validate-jsonschema` and `melos run test:gen` for full coverage) 3. Check that the documentation is up to date across the repo and docs site -4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`) +4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`, `schema_model`) 5. Ensure package CHANGELOG entries are finalized before tagging. If you want a link-only entry for a version, you can run `dart scripts/update_release_changelog.dart [tag]` after `melos version`. ### 2. Create a GitHub Release @@ -101,7 +101,7 @@ If you need to publish packages manually: ```bash # Dry-run each package (validation only) -for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai; do +for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai schema_model; do (cd packages/$pkg && dart pub publish --dry-run) || exit 1 done @@ -109,6 +109,15 @@ done melos run publish ``` +### First publish for `schema_model` + +`schema_model` is a new package. Pub.dev automated publishing can only be +enabled after the package exists, so the first version must be published +manually with `dart pub publish` from `packages/schema_model` before the +release tag. After that first upload, enable GitHub Actions publishing in the +package's pub.dev admin settings so future tagged releases can use the +workflow. + ## Troubleshooting ### Release Workflow Fails diff --git a/README.md b/README.md index 23006a05..bd667efb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). This repository is a monorepo containing: - **[ack](./packages/ack)**: Core validation library with fluent schema building API +- **[schema_model](./packages/schema_model)**: Vendor-neutral schema model and standard schema contracts for Dart - **[ack_generator](./packages/ack_generator)**: Code generator for `@AckType()` extension-type wrappers - **[ack_firebase_ai](./packages/ack_firebase_ai)**: Firebase AI (Gemini) schema converter for structured output generation - **[example](./example)**: Example projects demonstrating usage of all packages diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index de83083e..1c5f01a2 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -9,6 +9,9 @@ * Replace the interim JSON Schema model kind API with sealed `AckSchemaModel` variants and canonical `AckSchema.toSchemaModel()` adapter conversion. +* Move the schema description model to `package:schema_model` and rename + public model types from `Ack*` to neutral names such as `SchemaModel`, + `StringSchemaModel`, and `ObjectSchemaModel`. ### Added @@ -20,18 +23,28 @@ * `NumberSchemaExtensions` adds fluent numeric constraints to `Ack.number()`: `.min`, `.max`, `.greaterThan`, `.lessThan`, `.positive`, `.negative`, and `.multipleOf`. +* `AckSchema.standard` implements the `StandardSchema` validation contract with + flat `StandardIssue` failures and a Draft-7 JSON Schema converter. +* `SchemaContext.pathSegments` exposes raw path segments with list indices as + integers, and `SchemaError.toStandardIssues()` maps ACK errors to standard + issues. ### Changed * Project discriminated schemas through union-owned discriminator branches. * Preserve defaults, const values, extension keywords, transformed metadata, composition, and JSON Schema constraints through the schema model boundary. +* Deprecated `Ack*SchemaModel` typedef aliases remain exported from `ack` for + source compatibility; new in-repo code uses `package:schema_model` names. ### Migration * Re-run tests for code paths that parse or encode `double`/`num` values. If a boundary must accept `NaN` or infinities, model that value outside the JSON numeric schema path before validation. +* Replace `AckSchemaModel` type annotations with `SchemaModel` from + `package:schema_model` when updating code. The old names continue to compile + through deprecated aliases. ## 1.0.0-beta.11 diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 06b66251..3cf92dea 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -33,5 +33,6 @@ export 'src/schemas/schema.dart' hide AnyAckSchema, Refinement, SchemaOperation, WrapperSchema; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; +export 'src/validation/standard_issues.dart'; // Validation results export 'src/validation/schema_result.dart'; diff --git a/packages/ack/lib/src/context.dart b/packages/ack/lib/src/context.dart index 118e27b1..6b2cea8a 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -46,6 +46,21 @@ class SchemaContext { : '$parentPath/$escapedSegment'; } + /// Raw path segments from root to this context. + /// + /// Numeric segments are exposed as integer indices to match the standard + /// schema path shape. Empty path segments are branch pass-through markers and + /// do not add to the path. + List get pathSegments { + final parentSegments = parent?.pathSegments ?? const []; + if (parent == null || pathSegment == '') return parentSegments; + + final segment = pathSegment ?? name; + final index = int.tryParse(segment); + final pathValue = index != null && index >= 0 ? index : segment; + return [...parentSegments, pathValue]; + } + /// Creates a child context for nested validation. /// /// The child inherits the parent's [operation] unless overridden. diff --git a/packages/ack/lib/src/json_schema.dart b/packages/ack/lib/src/json_schema.dart index 245d88c1..f708baea 100644 --- a/packages/ack/lib/src/json_schema.dart +++ b/packages/ack/lib/src/json_schema.dart @@ -1,10 +1,10 @@ /// JSON Schema rendering for ACK's schema model. /// -/// [AckSchemaModel] is ACK's canonical export model. JSON Schema is one output +/// [SchemaModel] is ACK's canonical export model. JSON Schema is one output /// renderer for that model. library; export 'json_schema/json_schema_utils.dart'; -export 'schema_model/ack_schema_model.dart'; export 'schema_model/ack_schema_model_builder.dart'; -export 'schema_model/ack_schema_model_warning.dart'; +export 'schema_model/legacy_schema_model_aliases.dart'; +export 'package:schema_model/schema_model.dart'; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 1f635fda..e47a080b 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -7,22 +7,21 @@ import '../constraints/datetime_constraint.dart'; import '../context.dart'; import '../json_schema/json_schema_utils.dart'; import '../schemas/schema.dart'; -import 'ack_schema_model.dart'; -import 'ack_schema_model_warning.dart'; +import 'package:schema_model/schema_model.dart'; extension AckSchemaModelExtension< Boundary extends Object, Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + SchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); } final class _SchemaModelBuilder { - final _definitions = {}; + final _definitions = {}; final _targets = {}; - AckSchemaModel build(AckSchema schema) { + SchemaModel build(AckSchema schema) { final root = _build(schema); if (_definitions.isEmpty) return root; @@ -74,7 +73,7 @@ final class _SchemaModelBuilder { return merged; } - AckSchemaModel _build(AckSchema schema) { + SchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { final base = _build(schema.inner); // Defaults wrap their inner without transforming the boundary value, so @@ -100,7 +99,7 @@ final class _SchemaModelBuilder { } else { wrapped = wrapped.withWarnings([ ...wrapped.warnings, - AckSchemaModelWarning( + SchemaModelWarning( code: 'default_not_export_safe', message: 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', @@ -133,56 +132,56 @@ final class _SchemaModelBuilder { DiscriminatedObjectSchema() => _discriminated(schema), LazySchema() => _lazy(schema), _ => throw UnsupportedError( - 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', + 'Schema type ${schema.runtimeType} is not supported for SchemaModel conversion.', ), }; return schema is LazySchema ? model : _applyConstraints(model, schema); } - AckSchemaModel _string(StringSchema schema) { - return AckStringSchemaModel( + SchemaModel _string(StringSchema schema) { + return StringSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - AckSchemaModel _integer(IntegerSchema schema) { - return AckIntegerSchemaModel( + SchemaModel _integer(IntegerSchema schema) { + return IntegerSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - AckSchemaModel _number({String? description, required bool nullable}) { - return AckNumberSchemaModel(description: description, nullable: nullable); + SchemaModel _number({String? description, required bool nullable}) { + return NumberSchemaModel(description: description, nullable: nullable); } - AckSchemaModel _boolean(BooleanSchema schema) { - return AckBooleanSchemaModel( + SchemaModel _boolean(BooleanSchema schema) { + return BooleanSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - AckSchemaModel _enum(EnumSchema schema) { - return AckStringSchemaModel( + SchemaModel _enum(EnumSchema schema) { + return StringSchemaModel( description: schema.description, enumValues: [for (final value in schema.values) value.name], nullable: schema.isNullable, ); } - AckSchemaModel _array(ListSchema schema) { - return AckArraySchemaModel( + SchemaModel _array(ListSchema schema) { + return ArraySchemaModel( description: schema.description, nullable: schema.isNullable, items: _build(schema.itemSchema), ); } - AckSchemaModel _object(ObjectSchema schema) { - final properties = {}; + SchemaModel _object(ObjectSchema schema) { + final properties = {}; final required = []; final ordering = []; @@ -197,43 +196,43 @@ final class _SchemaModelBuilder { } } - return AckObjectSchemaModel( + return ObjectSchemaModel( description: schema.description, nullable: schema.isNullable, properties: properties.isEmpty ? null : properties, required: required.isEmpty ? null : required, propertyOrdering: ordering.isEmpty ? null : ordering, additionalProperties: schema.additionalProperties - ? const AckAdditionalPropertiesAllowed() - : const AckAdditionalPropertiesDisallowed(), + ? const AdditionalPropertiesAllowed() + : const AdditionalPropertiesDisallowed(), ); } - AckSchemaModel _anyOf(AnyOfSchema schema) { - return AckAnyOfSchemaModel( + SchemaModel _anyOf(AnyOfSchema schema) { + return AnyOfSchemaModel( schemas: schema.schemas.map(_build).toList(growable: false), nullable: schema.isNullable, description: schema.description, ); } - AckSchemaModel _instance(InstanceSchema schema) { + SchemaModel _instance(InstanceSchema schema) { // InstanceSchema accepts arbitrary Dart instances of a runtime type with no // direct JSON representation. Adapters that flow through a codec see the // boundary schema instead; this is the fallback for a bare instance. - return AckAnyOfSchemaModel( + return AnyOfSchemaModel( schemas: [ - AckStringSchemaModel(description: schema.description), - AckNumberSchemaModel(description: schema.description), - AckIntegerSchemaModel(description: schema.description), - AckBooleanSchemaModel(description: schema.description), - AckObjectSchemaModel(description: schema.description), - AckArraySchemaModel(description: schema.description), + StringSchemaModel(description: schema.description), + NumberSchemaModel(description: schema.description), + IntegerSchemaModel(description: schema.description), + BooleanSchemaModel(description: schema.description), + ObjectSchemaModel(description: schema.description), + ArraySchemaModel(description: schema.description), ], nullable: schema.isNullable, description: schema.description, warnings: const [ - AckSchemaModelWarning( + SchemaModelWarning( code: 'ack_instance_json_boundary', message: 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', @@ -242,23 +241,23 @@ final class _SchemaModelBuilder { ); } - AckSchemaModel _any(AnySchema schema) { + SchemaModel _any(AnySchema schema) { final description = schema.description; final primitiveBranches = [ - AckStringSchemaModel(description: description), - AckNumberSchemaModel(description: description), - AckIntegerSchemaModel(description: description), - AckBooleanSchemaModel(description: description), - AckObjectSchemaModel(description: description), - AckArraySchemaModel(description: description), + StringSchemaModel(description: description), + NumberSchemaModel(description: description), + IntegerSchemaModel(description: description), + BooleanSchemaModel(description: description), + ObjectSchemaModel(description: description), + ArraySchemaModel(description: description), ]; - return AckAnyOfSchemaModel( + return AnyOfSchemaModel( schemas: primitiveBranches, nullable: schema.isNullable, description: description, warnings: const [ - AckSchemaModelWarning( + SchemaModelWarning( code: 'ack_any_json_boundary', message: 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', @@ -267,9 +266,9 @@ final class _SchemaModelBuilder { ); } - AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { + SchemaModel _discriminated(DiscriminatedObjectSchema schema) { if (schema.schemas.isEmpty) { - return AckObjectSchemaModel( + return ObjectSchemaModel( properties: const {}, required: const [], nullable: schema.isNullable, @@ -277,10 +276,10 @@ final class _SchemaModelBuilder { ); } - final branches = []; + final branches = []; for (final entry in schema.schemas.entries) { final converted = _build(schema.effectiveBranch(entry.key)); - if (converted is! AckObjectSchemaModel) { + if (converted is! ObjectSchemaModel) { throw ArgumentError( 'Discriminated branch "${entry.key}" must export as an object schema model.', ); @@ -288,9 +287,9 @@ final class _SchemaModelBuilder { branches.add(converted); } - return AckAnyOfSchemaModel( + return AnyOfSchemaModel( schemas: branches, - discriminator: AckSchemaDiscriminatorModel( + discriminator: SchemaDiscriminatorModel( propertyName: schema.discriminatorKey, ), description: schema.description, @@ -298,7 +297,7 @@ final class _SchemaModelBuilder { ); } - AckSchemaModel _lazy(LazySchema schema) { + SchemaModel _lazy(LazySchema schema) { final name = schema.name; final target = schema.target; final priorTarget = _targets[name]; @@ -318,8 +317,8 @@ final class _SchemaModelBuilder { return _lazyRef(schema); } - AckSchemaModel _lazyRef(LazySchema schema) { - var model = AckRefSchemaModel( + SchemaModel _lazyRef(LazySchema schema) { + var model = RefSchemaModel( refName: schema.name, description: schema.description, nullable: schema.isNullable, @@ -332,7 +331,7 @@ final class _SchemaModelBuilder { return model.withWarnings([ ...model.warnings, - AckSchemaModelWarning( + SchemaModelWarning( code: 'lazy_runtime_checks_not_export_safe', message: 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', @@ -345,8 +344,8 @@ final class _SchemaModelBuilder { } } -AckSchemaModel _applyConstraints( - AckSchemaModel model, +SchemaModel _applyConstraints( + SchemaModel model, AckSchema schema, ) { var next = model; @@ -364,13 +363,13 @@ AckSchemaModel _applyConstraints( return next; } -AckSchemaModel _applyDateTimeConstraint( - AckSchemaModel model, +SchemaModel _applyDateTimeConstraint( + SchemaModel model, DateTimeConstraint constraint, ) { return model.withWarnings([ ...model.warnings, - AckSchemaModelWarning( + SchemaModelWarning( code: 'datetime_constraint_not_draft7', message: 'DateTime range constraints are not emitted because JSON Schema Draft-7 has no standard format range keywords.', diff --git a/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart b/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart new file mode 100644 index 00000000..00696e2a --- /dev/null +++ b/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart @@ -0,0 +1,59 @@ +import 'package:schema_model/schema_model.dart'; + +@Deprecated('Use SchemaModel from package:schema_model instead.') +typedef AckSchemaModel = SchemaModel; + +@Deprecated('Use StringSchemaModel from package:schema_model instead.') +typedef AckStringSchemaModel = StringSchemaModel; + +@Deprecated('Use IntegerSchemaModel from package:schema_model instead.') +typedef AckIntegerSchemaModel = IntegerSchemaModel; + +@Deprecated('Use NumberSchemaModel from package:schema_model instead.') +typedef AckNumberSchemaModel = NumberSchemaModel; + +@Deprecated('Use BooleanSchemaModel from package:schema_model instead.') +typedef AckBooleanSchemaModel = BooleanSchemaModel; + +@Deprecated('Use ArraySchemaModel from package:schema_model instead.') +typedef AckArraySchemaModel = ArraySchemaModel; + +@Deprecated('Use ObjectSchemaModel from package:schema_model instead.') +typedef AckObjectSchemaModel = ObjectSchemaModel; + +@Deprecated('Use NullSchemaModel from package:schema_model instead.') +typedef AckNullSchemaModel = NullSchemaModel; + +@Deprecated('Use AnyOfSchemaModel from package:schema_model instead.') +typedef AckAnyOfSchemaModel = AnyOfSchemaModel; + +@Deprecated('Use OneOfSchemaModel from package:schema_model instead.') +typedef AckOneOfSchemaModel = OneOfSchemaModel; + +@Deprecated('Use AllOfSchemaModel from package:schema_model instead.') +typedef AckAllOfSchemaModel = AllOfSchemaModel; + +@Deprecated('Use RefSchemaModel from package:schema_model instead.') +typedef AckRefSchemaModel = RefSchemaModel; + +@Deprecated('Use AdditionalPropertiesModel from package:schema_model instead.') +typedef AckAdditionalPropertiesModel = AdditionalPropertiesModel; + +@Deprecated( + 'Use AdditionalPropertiesAllowed from package:schema_model instead.', +) +typedef AckAdditionalPropertiesAllowed = AdditionalPropertiesAllowed; + +@Deprecated( + 'Use AdditionalPropertiesDisallowed from package:schema_model instead.', +) +typedef AckAdditionalPropertiesDisallowed = AdditionalPropertiesDisallowed; + +@Deprecated('Use AdditionalPropertiesSchema from package:schema_model instead.') +typedef AckAdditionalPropertiesSchema = AdditionalPropertiesSchema; + +@Deprecated('Use SchemaDiscriminatorModel from package:schema_model instead.') +typedef AckSchemaDiscriminatorModel = SchemaDiscriminatorModel; + +@Deprecated('Use SchemaModelWarning from package:schema_model instead.') +typedef AckSchemaModelWarning = SchemaModelWarning; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 313c4f83..b027a736 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,5 +1,13 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:schema_model/schema_model.dart' + show + JsonSchemaTarget, + StandardFailure, + StandardJsonSchemaConverter, + StandardSchema, + StandardSchemaProps, + StandardSuccess; import '../common_types.dart'; import '../constraints/constraint.dart'; @@ -11,6 +19,7 @@ import '../helpers.dart'; import '../schema_model/ack_schema_model_builder.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; +import '../validation/standard_issues.dart'; part 'any_of_schema.dart'; part 'any_schema.dart'; @@ -62,7 +71,8 @@ enum SchemaOperation { parse, encode } /// methods. Subclasses override the three methods; they should not override /// the public wrappers. @immutable -abstract class AckSchema { +abstract class AckSchema + implements StandardSchema { final bool isNullable; final bool isOptional; final String? description; @@ -340,6 +350,36 @@ abstract class AckSchema { return parseWithContext(value, context); } + @override + StandardSchemaProps get standard => StandardSchemaProps( + vendor: 'ack', + validate: (value, [options]) => switch (safeParse(value)) { + Ok(value: final value) => StandardSuccess(value), + Fail(error: final error) => StandardFailure( + error.toStandardIssues(), + ), + }, + jsonSchema: StandardJsonSchemaConverter( + input: (options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('ack supports draft-07 only'); + } + return toJsonSchema(); + }, + output: (options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('ack supports draft-07 only'); + } + if (this is CodecSchema) { + throw UnsupportedError( + 'codec Runtime side is not JSON-representable', + ); + } + return toJsonSchema(); + }, + ), + ); + /// Parses and validates a value, then maps the validated value to [TOut]. SchemaResult safeParseAs( Object? value, @@ -426,7 +466,7 @@ abstract class AckSchema { /// Converts this schema to a JSON Schema Draft-7 representation. /// - /// Delegates to the sealed [AckSchemaModel] boundary so all renderers share + /// Delegates to the sealed [SchemaModel] boundary so all renderers share /// the same Draft-7 output. Subclasses should not override this directly; /// instead they are dispatched in `ack_schema_model_builder.dart`. Map toJsonSchema() => toSchemaModel().toJsonSchema(); diff --git a/packages/ack/lib/src/schemas/wrapper_schema.dart b/packages/ack/lib/src/schemas/wrapper_schema.dart index 469ee8c7..26232323 100644 --- a/packages/ack/lib/src/schemas/wrapper_schema.dart +++ b/packages/ack/lib/src/schemas/wrapper_schema.dart @@ -6,7 +6,7 @@ part of 'schema.dart'; /// Wrappers add runtime behavior (e.g. codecs, defaults) while preserving an /// inner schema for boundary-shape traversal, schema-model export, and /// discriminated-branch rewriting. The canonical JSON export path is -/// `AckSchema → AckSchemaModel → JSON`; wrappers do not render JSON directly. +/// `AckSchema → SchemaModel → JSON`; wrappers do not render JSON directly. /// /// The fluent API (`nullable`, `describe`, `withConstraint`, …) is provided by /// [FluentSchema], which this mixin requires via its `on` clause; wrappers only diff --git a/packages/ack/lib/src/validation/standard_issues.dart b/packages/ack/lib/src/validation/standard_issues.dart new file mode 100644 index 00000000..7c1d5caa --- /dev/null +++ b/packages/ack/lib/src/validation/standard_issues.dart @@ -0,0 +1,23 @@ +import 'package:schema_model/schema_model.dart'; + +import 'schema_error.dart'; + +extension StandardIssueConversion on SchemaError { + List toStandardIssues() { + return switch (this) { + SchemaNestedError(errors: final errors) when errors.isNotEmpty => [ + for (final error in errors) ...error.toStandardIssues(), + ], + SchemaConstraintsError(constraints: final constraints) + when constraints.isNotEmpty => + [ + for (final constraint in constraints) + StandardIssue( + message: constraint.message, + path: context.pathSegments, + ), + ], + _ => [StandardIssue(message: message, path: context.pathSegments)], + }; + } +} diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index cb549cba..5361e6f1 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: meta: ^1.15.0 collection: ^1.18.0 + schema_model: ^1.0.0-beta.12-wip dev_dependencies: test: ^1.25.15 diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index 725aa2eb..9057b474 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -14,16 +14,13 @@ void main() { final model = schema.toSchemaModel(); - expect(model, isA()); - final object = model as AckObjectSchemaModel; + expect(model, isA()); + final object = model as ObjectSchemaModel; expect(object.description, 'User payload'); - expect(object.properties!['name'], isA()); - expect( - (object.properties!['name']! as AckStringSchemaModel).minLength, - 2, - ); + expect(object.properties!['name'], isA()); + expect((object.properties!['name']! as StringSchemaModel).minLength, 2); expect(object.properties!['name']!.defaultValue, 'Ada'); - expect((object.properties!['role']! as AckStringSchemaModel).enumValues, [ + expect((object.properties!['role']! as StringSchemaModel).enumValues, [ 'admin', 'member', ]); @@ -38,6 +35,16 @@ void main() { expect(model.toJsonSchema(), {'type': 'string', 'default': 'draft'}); }); + test('rendered schema model JSON imports back to the same JSON', () { + final model = Ack.object({ + 'name': Ack.string().minLength(2), + 'tags': Ack.list(Ack.string()).optional(), + }).toSchemaModel(); + final json = model.toJsonSchema(); + + expect(SchemaModel.fromJsonSchema(json).toJsonSchema(), json); + }); + test('omits defaults that cannot be encoded through wrapped schema', () { final transformed = Ack.string() .transform((value) => int.parse(value)) @@ -70,7 +77,7 @@ void main() { 'age': Ack.integer().min(10).withDefault(5), }); - final model = schema.toSchemaModel() as AckObjectSchemaModel; + final model = schema.toSchemaModel() as ObjectSchemaModel; final json = model.toJsonSchema(); final properties = json['properties'] as Map; @@ -91,7 +98,7 @@ void main() { expect( schema.toJsonSchema(), equals(schema.toSchemaModel().toJsonSchema()), - reason: '${schema.runtimeType} should render from AckSchemaModel', + reason: '${schema.runtimeType} should render from SchemaModel', ); } @@ -147,9 +154,9 @@ void main() { }, ); - final model = schema.toSchemaModel() as AckAnyOfSchemaModel; - final branch = model.schemas.single as AckObjectSchemaModel; - final discriminator = branch.properties!['type'] as AckStringSchemaModel; + final model = schema.toSchemaModel() as AnyOfSchemaModel; + final branch = model.schemas.single as ObjectSchemaModel; + final discriminator = branch.properties!['type'] as StringSchemaModel; expect(model.discriminator!.propertyName, 'type'); expect(discriminator.constValue, 'cat'); @@ -173,10 +180,9 @@ void main() { }, ); - final model = schema.toSchemaModel() as AckAnyOfSchemaModel; - final branch = model.schemas.single as AckObjectSchemaModel; - final discriminator = - branch.properties!['type'] as AckStringSchemaModel; + final model = schema.toSchemaModel() as AnyOfSchemaModel; + final branch = model.schemas.single as ObjectSchemaModel; + final discriminator = branch.properties!['type'] as StringSchemaModel; expect(model.discriminator!.propertyName, 'type'); expect(discriminator.constValue, 'cat'); @@ -193,8 +199,8 @@ void main() { }, ); - final model = schema.toSchemaModel() as AckAnyOfSchemaModel; - final branch = model.schemas.single as AckObjectSchemaModel; + final model = schema.toSchemaModel() as AnyOfSchemaModel; + final branch = model.schemas.single as ObjectSchemaModel; expect(branch.properties!.keys, ['type', 'name']); expect(branch.required, ['type', 'name']); @@ -233,10 +239,9 @@ void main() { }, ); - final model = schema.toSchemaModel() as AckAnyOfSchemaModel; - final branch = model.schemas.single as AckObjectSchemaModel; - final discriminator = - branch.properties!['type'] as AckStringSchemaModel; + final model = schema.toSchemaModel() as AnyOfSchemaModel; + final branch = model.schemas.single as ObjectSchemaModel; + final discriminator = branch.properties!['type'] as StringSchemaModel; expect(discriminator.constValue, 'cat'); expect(branch.extensions['x-transformed'], isTrue); @@ -265,8 +270,8 @@ void main() { test('records Ack.any JSON export limitation as a warning', () { final model = Ack.any().toSchemaModel(); - expect(model, isA()); - expect((model as AckAnyOfSchemaModel).schemas, isNotEmpty); + expect(model, isA()); + expect((model as AnyOfSchemaModel).schemas, isNotEmpty); expect(model.warnings, hasLength(1)); expect(model.warnings.single.message, contains('JSON-safe values')); }); @@ -300,7 +305,7 @@ void main() { ) .toSchemaModel(); - expect((model as AckStringSchemaModel).minLength, isNull); + expect((model as StringSchemaModel).minLength, isNull); expect(model.extensions, {'minLength': 1.5}); expect(model.toJsonSchema()['minLength'], 1.5); }); diff --git a/packages/ack/test/schema_model/legacy_alias_compat_test.dart b/packages/ack/test/schema_model/legacy_alias_compat_test.dart new file mode 100644 index 00000000..7942f5c5 --- /dev/null +++ b/packages/ack/test/schema_model/legacy_alias_compat_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package + +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +String _variantName(AckSchemaModel model) { + return switch (model) { + AckRefSchemaModel() => 'ref', + AckStringSchemaModel() => 'string', + AckIntegerSchemaModel() => 'integer', + AckNumberSchemaModel() => 'number', + AckBooleanSchemaModel() => 'boolean', + AckArraySchemaModel() => 'array', + AckObjectSchemaModel() => 'object', + AckNullSchemaModel() => 'null', + AckAnyOfSchemaModel() => 'anyOf', + AckOneOfSchemaModel() => 'oneOf', + AckAllOfSchemaModel() => 'allOf', + }; +} + +void main() { + group('legacy schema model aliases', () { + test('forward const constructors and type checks', () { + const schema = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, + additionalProperties: AckAdditionalPropertiesSchema( + AckStringSchemaModel(), + ), + ); + + expect(schema, isA()); + expect(schema, isA()); + expect(schema.additionalProperties, isA()); + expect(schema.additionalProperties, isA()); + }); + + test('preserve type identity', () { + expect(AckObjectSchemaModel, ObjectSchemaModel); + expect(AckStringSchemaModel, StringSchemaModel); + expect(AckSchemaModelWarning, SchemaModelWarning); + expect(AckAdditionalPropertiesAllowed, AdditionalPropertiesAllowed); + }); + + test('preserve sealed exhaustiveness', () { + expect(_variantName(const AckRefSchemaModel(refName: 'Node')), 'ref'); + expect(_variantName(const AckStringSchemaModel()), 'string'); + expect(_variantName(const AckIntegerSchemaModel()), 'integer'); + expect(_variantName(const AckNumberSchemaModel()), 'number'); + expect(_variantName(const AckBooleanSchemaModel()), 'boolean'); + expect(_variantName(const AckArraySchemaModel()), 'array'); + expect(_variantName(const AckObjectSchemaModel()), 'object'); + expect(_variantName(const AckNullSchemaModel()), 'null'); + expect( + _variantName( + const AckAnyOfSchemaModel(schemas: [AckStringSchemaModel()]), + ), + 'anyOf', + ); + expect( + _variantName( + const AckOneOfSchemaModel(schemas: [AckStringSchemaModel()]), + ), + 'oneOf', + ); + expect( + _variantName( + const AckAllOfSchemaModel(schemas: [AckStringSchemaModel()]), + ), + 'allOf', + ); + }); + }); +} diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 1f11b9eb..08265edb 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -205,7 +205,7 @@ void main() { ).refine((value) => true), }); - final model = categorySchema.toSchemaModel() as AckObjectSchemaModel; + final model = categorySchema.toSchemaModel() as ObjectSchemaModel; final child = model.properties!['child']!; expect(child.toJsonSchema(), {r'$ref': '#/definitions/Category'}); diff --git a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart index 2350427d..8f5dd8ab 100644 --- a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart +++ b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart @@ -8,9 +8,9 @@ import 'package:test/test.dart'; /// Used to verify that constraints are properly merged in JSON Schema output /// for AnyOfSchema, which uses `AckSchema` and has no natural /// JsonSchemaSpec constraints. -class _TestAckSchemaModelConstraint extends Constraint +class _TestSchemaModelConstraint extends Constraint with Validator, JsonSchemaSpec { - const _TestAckSchemaModelConstraint() + const _TestSchemaModelConstraint() : super( constraintKey: 'test_marker', description: 'Test constraint for JSON Schema merging verification', @@ -105,7 +105,7 @@ void main() { final schema = Ack.anyOf([ Ack.string(), Ack.integer(), - ]).withConstraint(const _TestAckSchemaModelConstraint()); + ]).withConstraint(const _TestSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); @@ -118,7 +118,7 @@ void main() { final schema = Ack.anyOf([ Ack.string(), Ack.integer(), - ]).nullable().withConstraint(const _TestAckSchemaModelConstraint()); + ]).nullable().withConstraint(const _TestSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart new file mode 100644 index 00000000..c70e58a8 --- /dev/null +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -0,0 +1,113 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('StandardSchema conformance', () { + test('AckSchema implements the standard schema contract', () { + expect(Ack.string(), isA>()); + }); + + test( + 'standard validate maps successful nullable nulls without type errors', + () { + final result = Ack.string().nullable().standard.validate(null); + + expect(result, isA>()); + expect((result as StandardSuccess).value, isNull); + }, + ); + + test( + 'standard validate maps nested failures to flat issues with paths', + () { + final schema = Ack.object({ + 'user': Ack.object({'tags': Ack.list(Ack.string().minLength(2))}), + }); + + final result = schema.standard.validate({ + 'user': { + 'tags': ['ok', 'x'], + }, + }); + + expect(result, isA>()); + final failure = result as StandardFailure; + expect(failure.issues, hasLength(1)); + expect(failure.issues.single.path, ['user', 'tags', 1]); + }, + ); + + test('SchemaContext.pathSegments mirrors JSON pointer path semantics', () { + final root = SchemaContext(name: 'root', schema: Ack.string(), value: {}); + final property = root.createChild( + name: 'tags', + schema: Ack.list(Ack.string()), + value: const ['x'], + pathSegment: 'tags', + ); + final index = property.createChild( + name: '1', + schema: Ack.string(), + value: 'x', + pathSegment: '1', + ); + final passThrough = index.createChild( + name: 'anyOf', + schema: Ack.string(), + value: 'x', + pathSegment: '', + ); + + expect(root.pathSegments, const []); + expect(property.pathSegments, ['tags']); + expect(index.pathSegments, ['tags', 1]); + expect(passThrough.pathSegments, ['tags', 1]); + }); + + test('SchemaError.toStandardIssues flattens direct and nested errors', () { + final direct = Ack.string().minLength(2).safeParse('x').getError(); + final nested = Ack.object({ + 'name': Ack.string().minLength(2), + 'age': Ack.integer(), + }).safeParse({'name': 'x', 'age': 'old'}).getError(); + + expect(direct.toStandardIssues(), hasLength(1)); + expect(direct.toStandardIssues().single.path, const []); + + final issues = nested.toStandardIssues(); + expect(issues.map((issue) => issue.path), [ + ['name'], + ['age'], + ]); + }); + + test('standard JSON Schema converter delegates to Draft-7 rendering', () { + final schema = Ack.string().minLength(2); + final converter = schema.standard.jsonSchema!; + const draft7 = StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, + ); + + expect(converter.input(draft7), schema.toJsonSchema()); + expect(converter.output(draft7), schema.toJsonSchema()); + expect( + () => converter.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.openapi30), + ), + throwsUnsupportedError, + ); + }); + + test('standard output JSON Schema throws for codecs', () { + final codec = Ack.string().transform((value) => int.parse(value)); + final converter = codec.standard.jsonSchema!; + + expect( + () => converter.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + throwsUnsupportedError, + ); + }); + }); +} diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index bdc35acc..7bf09819 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -18,6 +18,7 @@ - Default Firebase AI examples and live tests to `gemini-3.5-flash`. - Require `ack` `^1.0.0-beta.12-wip` for the sealed `AckSchemaModel` adapter boundary. +- Update schema-model fixture coverage to use the renamed `SchemaModel` API. - Keep `firebase_ai` and Flutter as explicit package dependencies so package tests and workspace orchestration use the Firebase AI SDK runtime. diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json index 7670dec3..6f309877 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.1", + "sourceVersion": "3.12.2", "sourceClass": "JSONSchema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json index 3c8f007a..e611fe70 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.1", + "sourceVersion": "3.12.2", "sourceClass": "Schema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart index f4b761c3..9a7eb017 100644 --- a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart +++ b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart @@ -176,7 +176,7 @@ Future _generateLiveFirebaseJson({ schemaCase.schema.toSchemaModel().toJsonSchema(), reason: 'Firebase live tests must exercise the canonical sealed ' - 'AckSchemaModel rendering path.', + 'SchemaModel rendering path.', ); final generationConfig = GenerationConfig( diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart index 2edc2bf1..0b8b2f60 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart @@ -55,7 +55,7 @@ final class SchemaModelResponseJsonSchemaCase required this.model, }); - final AckSchemaModel model; + final SchemaModel model; @override String get source => 'schema_model'; @@ -260,7 +260,7 @@ List firebaseAiResponseJsonSchemaCases() => [ 'pattern', 'extension', ], - model: AckStringSchemaModel( + model: StringSchemaModel( title: 'Status', description: 'Current status', format: 'custom-format', @@ -277,25 +277,25 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_integer_const_format', name: 'integer model const and format', features: ['integer', 'format', 'const'], - model: AckIntegerSchemaModel(format: 'int32', constValue: 7), + model: IntegerSchemaModel(format: 'int32', constValue: 7), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_number_const_format', name: 'number model const and format', features: ['number', 'format', 'const'], - model: AckNumberSchemaModel(format: 'double', constValue: 1.5), + model: NumberSchemaModel(format: 'double', constValue: 1.5), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_boolean_const', name: 'boolean model const', features: ['boolean', 'const'], - model: AckBooleanSchemaModel(constValue: true), + model: BooleanSchemaModel(constValue: true), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_nullable_default_extensions', name: 'nullable model default and extensions', features: ['string', 'nullable', 'anyOf', 'const', 'default', 'extension'], - model: AckStringSchemaModel( + model: StringSchemaModel( constValue: 'ready', nullable: true, defaultValue: 'ready', @@ -306,7 +306,7 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_array_without_item_schema', name: 'array model without item schema', features: ['array', 'minItems', 'maxItems'], - model: AckArraySchemaModel(minItems: 0, maxItems: 2), + model: ArraySchemaModel(minItems: 0, maxItems: 2), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_object_schema_additional_properties', @@ -319,36 +319,34 @@ List firebaseAiResponseJsonSchemaCases() => [ 'maxProperties', 'additionalPropertiesSchema', ], - model: AckObjectSchemaModel( - properties: {'id': AckStringSchemaModel()}, + model: ObjectSchemaModel( + properties: {'id': StringSchemaModel()}, required: ['id'], propertyOrdering: ['id'], minProperties: 1, maxProperties: 3, - additionalProperties: AckAdditionalPropertiesSchema( - AckStringSchemaModel(), - ), + additionalProperties: AdditionalPropertiesSchema(StringSchemaModel()), ), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_null', name: 'null model', features: ['null', 'title'], - model: AckNullSchemaModel(title: 'Nothing'), + model: NullSchemaModel(title: 'Nothing'), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_anyof_common_fields_explicit_null', name: 'anyOf model common fields and explicit null branch', features: ['anyOf', 'nullable', 'default', 'extension', 'null'], - model: AckAnyOfSchemaModel( + model: AnyOfSchemaModel( title: 'Flexible value', defaultValue: 'fallback', nullable: true, extensions: {'x-ack-test': true}, schemas: [ - AckStringSchemaModel(minLength: 1), - AckIntegerSchemaModel(minimum: 1), - AckNullSchemaModel(), + StringSchemaModel(minLength: 1), + IntegerSchemaModel(minimum: 1), + NullSchemaModel(), ], ), ), @@ -356,11 +354,11 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_oneof_nullable_composition', name: 'oneOf model nullable composition', features: ['oneOf', 'nullable', 'const', 'null'], - model: AckOneOfSchemaModel( + model: OneOfSchemaModel( nullable: true, schemas: [ - AckStringSchemaModel(constValue: 'ready'), - AckIntegerSchemaModel(minimum: 1), + StringSchemaModel(constValue: 'ready'), + IntegerSchemaModel(minimum: 1), ], ), ), @@ -368,38 +366,38 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_oneof_discriminator', name: 'oneOf model discriminator metadata', features: ['oneOf', 'const'], - model: AckOneOfSchemaModel( + model: OneOfSchemaModel( schemas: [ - AckObjectSchemaModel( + ObjectSchemaModel( properties: { - 'type': AckStringSchemaModel(constValue: 'email'), - 'address': AckStringSchemaModel(format: 'email'), + 'type': StringSchemaModel(constValue: 'email'), + 'address': StringSchemaModel(format: 'email'), }, required: ['type', 'address'], ), - AckObjectSchemaModel( + ObjectSchemaModel( properties: { - 'type': AckStringSchemaModel(constValue: 'sms'), - 'number': AckStringSchemaModel(), + 'type': StringSchemaModel(constValue: 'sms'), + 'number': StringSchemaModel(), }, required: ['type', 'number'], ), ], - discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), + discriminator: SchemaDiscriminatorModel(propertyName: 'type'), ), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_allof', name: 'allOf model', features: ['allOf'], - model: AckAllOfSchemaModel( + model: AllOfSchemaModel( schemas: [ - AckObjectSchemaModel( - properties: {'id': AckStringSchemaModel()}, + ObjectSchemaModel( + properties: {'id': StringSchemaModel()}, required: ['id'], ), - AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, required: ['name'], ), ], diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index 293a7dd9..040f2aa9 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -8,6 +8,8 @@ metadata, and composition. * Require `ack` `^1.0.0-beta.12-wip` for the sealed `AckSchemaModel` adapter boundary. +* Accept the renamed `SchemaModel` type from `package:schema_model` in + `convertAckSchemaModelToBuilder` while keeping the existing function name. ### Removed diff --git a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart index d4ad46e6..8da36b2d 100644 --- a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart +++ b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart @@ -33,9 +33,9 @@ extension JsonSchemaBuilderExtension on AckSchema { } } -/// Converts a [AckSchemaModel] model directly to json_schema_builder [Schema] format. +/// Converts a [SchemaModel] model directly to json_schema_builder [Schema] format. /// -/// This is useful for testing or when you have a pre-built AckSchemaModel model. -jsb.Schema convertAckSchemaModelToBuilder(AckSchemaModel schema) { +/// This is useful for testing or when you have a pre-built SchemaModel model. +jsb.Schema convertAckSchemaModelToBuilder(SchemaModel schema) { return jsb.Schema.fromMap(schema.toJsonSchema()); } diff --git a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart index 4f019276..5900eb2b 100644 --- a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart @@ -302,7 +302,7 @@ void main() { }); test('preserves model defaults, const values, and extensions', () { - const schema = AckBooleanSchemaModel( + const schema = BooleanSchemaModel( constValue: true, defaultValue: true, extensions: { @@ -565,9 +565,9 @@ void main() { }); test('additionalProperties schema converts recursively', () { - const schema = AckObjectSchemaModel( - additionalProperties: AckAdditionalPropertiesSchema( - AckStringSchemaModel(minLength: 1), + const schema = ObjectSchemaModel( + additionalProperties: AdditionalPropertiesSchema( + StringSchemaModel(minLength: 1), ), ); @@ -581,10 +581,10 @@ void main() { group('allOf composition', () { test('allOf converts to allOf in json_schema_builder', () { - const jsonSchema = AckAllOfSchemaModel( + const jsonSchema = AllOfSchemaModel( schemas: [ - AckObjectSchemaModel(properties: {'name': AckStringSchemaModel()}), - AckObjectSchemaModel(properties: {'age': AckIntegerSchemaModel()}), + ObjectSchemaModel(properties: {'name': StringSchemaModel()}), + ObjectSchemaModel(properties: {'age': IntegerSchemaModel()}), ], ); @@ -608,10 +608,10 @@ void main() { }); test('allOf preserves branch schemas', () { - const jsonSchema = AckAllOfSchemaModel( + const jsonSchema = AllOfSchemaModel( schemas: [ - AckStringSchemaModel(minLength: 5), - AckStringSchemaModel(maxLength: 10), + StringSchemaModel(minLength: 5), + StringSchemaModel(maxLength: 10), ], ); @@ -627,8 +627,8 @@ void main() { group('Object property count constraints', () { test('minProperties is preserved in conversion', () { - const jsonSchema = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const jsonSchema = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, minProperties: 2, ); @@ -638,8 +638,8 @@ void main() { }); test('maxProperties is preserved in conversion', () { - const jsonSchema = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const jsonSchema = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, maxProperties: 5, ); @@ -649,8 +649,8 @@ void main() { }); test('both minProperties and maxProperties together', () { - const jsonSchema = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const jsonSchema = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, minProperties: 1, maxProperties: 10, ); diff --git a/packages/schema_model/.pubignore b/packages/schema_model/.pubignore new file mode 100644 index 00000000..b86a5825 --- /dev/null +++ b/packages/schema_model/.pubignore @@ -0,0 +1,3 @@ +.context/ +coverage/ +*.iml diff --git a/packages/schema_model/CHANGELOG.md b/packages/schema_model/CHANGELOG.md new file mode 100644 index 00000000..3f2b8746 --- /dev/null +++ b/packages/schema_model/CHANGELOG.md @@ -0,0 +1,10 @@ +## Unreleased + +### Added + +- Add the vendor-neutral `SchemaModel` description tree, moved from ACK's + former `AckSchemaModel`. +- Add `SchemaModel.fromJsonSchema()` for best-effort Draft-7 import and + JSON-level round trips from rendered schema models. +- Add the `StandardSchema` validation contract, standard results/issues, and + JSON Schema converter option types. diff --git a/packages/schema_model/LICENSE b/packages/schema_model/LICENSE new file mode 100644 index 00000000..c936b029 --- /dev/null +++ b/packages/schema_model/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2025, Leo Farias +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/schema_model/README.md b/packages/schema_model/README.md new file mode 100644 index 00000000..1aceea6a --- /dev/null +++ b/packages/schema_model/README.md @@ -0,0 +1,27 @@ +# schema_model + +Vendor-neutral schema model and standard schema contracts for Dart. + +`schema_model` has two layers: + +- `SchemaModel`: a structured description tree that can render Draft-7 JSON + Schema and import JSON Schema with `SchemaModel.fromJsonSchema`. +- `StandardSchema`: a small validation contract for libraries that want to + expose `validate(value)` results and optional JSON Schema converters. + +```dart +import 'package:schema_model/schema_model.dart'; + +final model = SchemaModel.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 2}, + }, + 'required': ['name'], +}); + +final jsonSchema = model.toJsonSchema(); +``` + +Validator packages can implement `StandardSchema` and expose +their contract through `standard`. diff --git a/packages/schema_model/analysis_options.yaml b/packages/schema_model/analysis_options.yaml new file mode 100644 index 00000000..d04adaf9 --- /dev/null +++ b/packages/schema_model/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/packages/schema_model/lib/schema_model.dart b/packages/schema_model/lib/schema_model.dart new file mode 100644 index 00000000..e5ab92f7 --- /dev/null +++ b/packages/schema_model/lib/schema_model.dart @@ -0,0 +1,6 @@ +/// Vendor-neutral schema model and standard schema contracts for Dart. +library; + +export 'src/schema_model.dart'; +export 'src/schema_model_warning.dart'; +export 'src/standard_schema.dart'; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/schema_model/lib/src/schema_model.dart similarity index 77% rename from packages/ack/lib/src/schema_model/ack_schema_model.dart rename to packages/schema_model/lib/src/schema_model.dart index 25893fee..1b0975a9 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/schema_model/lib/src/schema_model.dart @@ -1,14 +1,16 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; -import 'ack_schema_model_warning.dart'; +import 'schema_model_warning.dart'; + +part 'schema_model_parser.dart'; const _unset = Object(); const _nullSchemaJson = {'type': 'null'}; @immutable -final class AckSchemaDiscriminatorModel { - const AckSchemaDiscriminatorModel({required this.propertyName}); +final class SchemaDiscriminatorModel { + const SchemaDiscriminatorModel({required this.propertyName}); final String propertyName; @@ -17,7 +19,7 @@ final class AckSchemaDiscriminatorModel { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! AckSchemaDiscriminatorModel) return false; + if (other is! SchemaDiscriminatorModel) return false; return propertyName == other.propertyName; } @@ -26,8 +28,8 @@ final class AckSchemaDiscriminatorModel { } @immutable -final class _AckSchemaModelCommon { - const _AckSchemaModelCommon({ +final class _SchemaModelCommon { + const _SchemaModelCommon({ this.title, this.description, this.nullable = false, @@ -40,18 +42,18 @@ final class _AckSchemaModelCommon { final String? description; final bool nullable; final Object? defaultValue; - final List warnings; + final List warnings; final Map extensions; - _AckSchemaModelCommon copyWith({ + _SchemaModelCommon copyWith({ Object? title = _unset, Object? description = _unset, bool? nullable, Object? defaultValue = _unset, - List? warnings, + List? warnings, Map? extensions, }) { - return _AckSchemaModelCommon( + return _SchemaModelCommon( title: identical(title, _unset) ? this.title : title as String?, description: identical(description, _unset) ? this.description @@ -93,40 +95,38 @@ final class _AckSchemaModelCommon { } @immutable -sealed class AckAdditionalPropertiesModel { - const AckAdditionalPropertiesModel(); +sealed class AdditionalPropertiesModel { + const AdditionalPropertiesModel(); Object? toJsonSchemaValue(); } -final class AckAdditionalPropertiesAllowed - extends AckAdditionalPropertiesModel { - const AckAdditionalPropertiesAllowed(); +final class AdditionalPropertiesAllowed extends AdditionalPropertiesModel { + const AdditionalPropertiesAllowed(); @override Object toJsonSchemaValue() => true; } -final class AckAdditionalPropertiesDisallowed - extends AckAdditionalPropertiesModel { - const AckAdditionalPropertiesDisallowed(); +final class AdditionalPropertiesDisallowed extends AdditionalPropertiesModel { + const AdditionalPropertiesDisallowed(); @override Object toJsonSchemaValue() => false; } -final class AckAdditionalPropertiesSchema extends AckAdditionalPropertiesModel { - const AckAdditionalPropertiesSchema(this.schema); +final class AdditionalPropertiesSchema extends AdditionalPropertiesModel { + const AdditionalPropertiesSchema(this.schema); - final AckSchemaModel schema; + final SchemaModel schema; @override Map toJsonSchemaValue() => schema.toJsonSchema(); } @immutable -sealed class AckSchemaModel { - const AckSchemaModel({ +sealed class SchemaModel { + const SchemaModel({ this.title, this.description, this.nullable = false, @@ -135,7 +135,11 @@ sealed class AckSchemaModel { this.extensions = const {}, }); - AckSchemaModel._(_AckSchemaModelCommon common) + factory SchemaModel.fromJsonSchema(Map json) { + return _JsonSchemaParser().parse(json); + } + + SchemaModel._(_SchemaModelCommon common) : title = common.title, description = common.description, nullable = common.nullable, @@ -147,10 +151,10 @@ sealed class AckSchemaModel { final String? description; final bool nullable; final Object? defaultValue; - final List warnings; + final List warnings; final Map extensions; - _AckSchemaModelCommon get _common => _AckSchemaModelCommon( + _SchemaModelCommon get _common => _SchemaModelCommon( title: title, description: description, nullable: nullable, @@ -166,24 +170,24 @@ sealed class AckSchemaModel { Object? get _metadataEquality => null; @protected - AckSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common); + SchemaModel _rebuildWithCommon(_SchemaModelCommon common); - AckSchemaModel withDescription(String? description) => + SchemaModel withDescription(String? description) => _rebuildWithCommon(_common.copyWith(description: description)); - AckSchemaModel withNullable(bool nullable) => + SchemaModel withNullable(bool nullable) => _rebuildWithCommon(_common.copyWith(nullable: nullable)); - AckSchemaModel withDefaultValue(Object? defaultValue) => + SchemaModel withDefaultValue(Object? defaultValue) => _rebuildWithCommon(_common.copyWith(defaultValue: defaultValue)); - AckSchemaModel withWarnings(List warnings) => + SchemaModel withWarnings(List warnings) => _rebuildWithCommon(_common.copyWith(warnings: warnings)); - AckSchemaModel withExtensions(Map extensions) => + SchemaModel withExtensions(Map extensions) => _rebuildWithCommon(_common.copyWith(extensions: extensions)); - AckSchemaModel withJsonSchemaKeywords(Map keywords) { + SchemaModel withJsonSchemaKeywords(Map keywords) { final commonHandled = {}; var common = _common; @@ -205,47 +209,47 @@ sealed class AckSchemaModel { } return switch (_rebuildWithCommon(common)) { - AckStringSchemaModel schema => schema._withJsonSchemaKeywords( + StringSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckIntegerSchemaModel schema => schema._withJsonSchemaKeywords( + IntegerSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckNumberSchemaModel schema => schema._withJsonSchemaKeywords( + NumberSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckBooleanSchemaModel schema => schema._withJsonSchemaKeywords( + BooleanSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckArraySchemaModel schema => schema._withJsonSchemaKeywords( + ArraySchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckObjectSchemaModel schema => schema._withJsonSchemaKeywords( + ObjectSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckAnyOfSchemaModel schema => schema._withJsonSchemaKeywords( + AnyOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckOneOfSchemaModel schema => schema._withJsonSchemaKeywords( + OneOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckAllOfSchemaModel schema => schema._withJsonSchemaKeywords( + AllOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckNullSchemaModel schema => schema._withJsonSchemaKeywords( + NullSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AckRefSchemaModel schema => schema._withJsonSchemaKeywords( + RefSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), @@ -273,10 +277,10 @@ sealed class AckSchemaModel { Map finishCompositionJson( String keyword, - List schemas, + List schemas, ) { final branches = schemas.map((schema) => schema.toJsonSchema()).toList(); - if (nullable && !schemas.any((schema) => schema is AckNullSchemaModel)) { + if (nullable && !schemas.any((schema) => schema is NullSchemaModel)) { // Match Zod v4's Draft-7 nullable-union shape: keep the composition as // one branch, then add null as the other branch. Flattening would validate // the same values but loses the distinction between nullability and the @@ -294,7 +298,7 @@ sealed class AckSchemaModel { return {..._common.toJson(), keyword: branches}; } - AckSchemaModel _withUnhandledKeywords( + SchemaModel _withUnhandledKeywords( Map keywords, Set handled, ) { @@ -308,7 +312,7 @@ sealed class AckSchemaModel { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! AckSchemaModel) return false; + if (other is! SchemaModel) return false; const deepEq = DeepCollectionEquality(); return runtimeType == other.runtimeType && deepEq.equals(toJsonSchema(), other.toJsonSchema()) && @@ -328,8 +332,8 @@ sealed class AckSchemaModel { } } -final class AckRefSchemaModel extends AckSchemaModel { - const AckRefSchemaModel({ +final class RefSchemaModel extends SchemaModel { + const RefSchemaModel({ required this.refName, super.title, super.description, @@ -339,8 +343,7 @@ final class AckRefSchemaModel extends AckSchemaModel { super.extensions, }); - AckRefSchemaModel._(_AckSchemaModelCommon common, {required this.refName}) - : super._(common); + RefSchemaModel._(super.common, {required this.refName}) : super._(); final String refName; @@ -359,10 +362,10 @@ final class AckRefSchemaModel extends AckSchemaModel { } @override - AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckRefSchemaModel._(common, refName: refName); + RefSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + RefSchemaModel._(common, refName: refName); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -370,8 +373,8 @@ final class AckRefSchemaModel extends AckSchemaModel { } } -final class AckStringSchemaModel extends AckSchemaModel { - const AckStringSchemaModel({ +final class StringSchemaModel extends SchemaModel { + const StringSchemaModel({ this.format, this.enumValues, this.constValue, @@ -388,8 +391,8 @@ final class AckStringSchemaModel extends AckSchemaModel { super.extensions, }); - AckStringSchemaModel._( - _AckSchemaModelCommon common, { + StringSchemaModel._( + super.common, { this.format, this.enumValues, this.constValue, @@ -398,7 +401,7 @@ final class AckStringSchemaModel extends AckSchemaModel { this.pattern, this.formatMinimum, this.formatMaximum, - }) : super._(common); + }) : super._(); @override final String? format; @@ -435,8 +438,8 @@ final class AckStringSchemaModel extends AckSchemaModel { }); @override - AckStringSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckStringSchemaModel._( + StringSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + StringSchemaModel._( common, format: format, enumValues: enumValues, @@ -448,7 +451,7 @@ final class AckStringSchemaModel extends AckSchemaModel { formatMaximum: formatMaximum, ); - AckStringSchemaModel _copyWith({ + StringSchemaModel _copyWith({ String? format, List? enumValues, Object? constValue = _unset, @@ -458,7 +461,7 @@ final class AckStringSchemaModel extends AckSchemaModel { String? formatMinimum, String? formatMaximum, }) { - return AckStringSchemaModel._( + return StringSchemaModel._( _common, format: format ?? this.format, enumValues: enumValues ?? this.enumValues, @@ -473,7 +476,7 @@ final class AckStringSchemaModel extends AckSchemaModel { ); } - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -484,9 +487,9 @@ final class AckStringSchemaModel extends AckSchemaModel { handled.add('format'); next = next._copyWith(format: value); } - if (keywords['enum'] case final List values) { + if (keywords['enum'] case final List values) { handled.add('enum'); - next = next._copyWith(enumValues: List.from(values)); + next = next._copyWith(enumValues: values); } if (keywords['const'] case final String value) { handled.add('const'); @@ -517,8 +520,8 @@ final class AckStringSchemaModel extends AckSchemaModel { } } -final class AckIntegerSchemaModel extends AckSchemaModel { - const AckIntegerSchemaModel({ +final class IntegerSchemaModel extends SchemaModel { + const IntegerSchemaModel({ this.format, this.constValue, this.minimum, @@ -534,8 +537,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { super.extensions, }); - AckIntegerSchemaModel._( - _AckSchemaModelCommon common, { + IntegerSchemaModel._( + super.common, { this.format, this.constValue, this.minimum, @@ -543,7 +546,7 @@ final class AckIntegerSchemaModel extends AckSchemaModel { this.exclusiveMinimum, this.exclusiveMaximum, this.multipleOf, - }) : super._(common); + }) : super._(); @override final String? format; @@ -567,8 +570,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { }); @override - AckIntegerSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckIntegerSchemaModel._( + IntegerSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + IntegerSchemaModel._( common, format: format, constValue: constValue, @@ -579,7 +582,7 @@ final class AckIntegerSchemaModel extends AckSchemaModel { multipleOf: multipleOf, ); - AckIntegerSchemaModel _copyWith({ + IntegerSchemaModel _copyWith({ String? format, Object? constValue = _unset, num? minimum, @@ -588,7 +591,7 @@ final class AckIntegerSchemaModel extends AckSchemaModel { num? exclusiveMaximum, num? multipleOf, }) { - return AckIntegerSchemaModel._( + return IntegerSchemaModel._( _common, format: format ?? this.format, constValue: identical(constValue, _unset) @@ -602,7 +605,7 @@ final class AckIntegerSchemaModel extends AckSchemaModel { ); } - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -642,8 +645,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { } } -final class AckNumberSchemaModel extends AckSchemaModel { - const AckNumberSchemaModel({ +final class NumberSchemaModel extends SchemaModel { + const NumberSchemaModel({ this.format, this.constValue, this.minimum, @@ -659,8 +662,8 @@ final class AckNumberSchemaModel extends AckSchemaModel { super.extensions, }); - AckNumberSchemaModel._( - _AckSchemaModelCommon common, { + NumberSchemaModel._( + super.common, { this.format, this.constValue, this.minimum, @@ -668,7 +671,7 @@ final class AckNumberSchemaModel extends AckSchemaModel { this.exclusiveMinimum, this.exclusiveMaximum, this.multipleOf, - }) : super._(common); + }) : super._(); @override final String? format; @@ -692,8 +695,8 @@ final class AckNumberSchemaModel extends AckSchemaModel { }); @override - AckNumberSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckNumberSchemaModel._( + NumberSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + NumberSchemaModel._( common, format: format, constValue: constValue, @@ -704,7 +707,7 @@ final class AckNumberSchemaModel extends AckSchemaModel { multipleOf: multipleOf, ); - AckNumberSchemaModel _copyWith({ + NumberSchemaModel _copyWith({ String? format, Object? constValue = _unset, num? minimum, @@ -713,7 +716,7 @@ final class AckNumberSchemaModel extends AckSchemaModel { num? exclusiveMaximum, num? multipleOf, }) { - return AckNumberSchemaModel._( + return NumberSchemaModel._( _common, format: format ?? this.format, constValue: identical(constValue, _unset) @@ -727,7 +730,7 @@ final class AckNumberSchemaModel extends AckSchemaModel { ); } - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -767,8 +770,8 @@ final class AckNumberSchemaModel extends AckSchemaModel { } } -final class AckBooleanSchemaModel extends AckSchemaModel { - const AckBooleanSchemaModel({ +final class BooleanSchemaModel extends SchemaModel { + const BooleanSchemaModel({ this.constValue, super.title, super.description, @@ -778,8 +781,7 @@ final class AckBooleanSchemaModel extends AckSchemaModel { super.extensions, }); - AckBooleanSchemaModel._(_AckSchemaModelCommon common, {this.constValue}) - : super._(common); + BooleanSchemaModel._(super.common, {this.constValue}) : super._(); final bool? constValue; @@ -790,10 +792,10 @@ final class AckBooleanSchemaModel extends AckSchemaModel { }); @override - AckBooleanSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckBooleanSchemaModel._(common, constValue: constValue); + BooleanSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + BooleanSchemaModel._(common, constValue: constValue); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -801,14 +803,14 @@ final class AckBooleanSchemaModel extends AckSchemaModel { var next = this; if (keywords['const'] case final bool value) { handled.add('const'); - next = AckBooleanSchemaModel._(_common, constValue: value); + next = BooleanSchemaModel._(_common, constValue: value); } return next._withUnhandledKeywords(keywords, handled); } } -final class AckArraySchemaModel extends AckSchemaModel { - const AckArraySchemaModel({ +final class ArraySchemaModel extends SchemaModel { + const ArraySchemaModel({ this.items, this.minItems, this.maxItems, @@ -821,15 +823,15 @@ final class AckArraySchemaModel extends AckSchemaModel { super.extensions, }); - AckArraySchemaModel._( - _AckSchemaModelCommon common, { + ArraySchemaModel._( + super.common, { this.items, this.minItems, this.maxItems, this.uniqueItems, - }) : super._(common); + }) : super._(); - final AckSchemaModel? items; + final SchemaModel? items; final int? minItems; final int? maxItems; final bool? uniqueItems; @@ -844,8 +846,8 @@ final class AckArraySchemaModel extends AckSchemaModel { }); @override - AckArraySchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckArraySchemaModel._( + ArraySchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + ArraySchemaModel._( common, items: items, minItems: minItems, @@ -853,13 +855,13 @@ final class AckArraySchemaModel extends AckSchemaModel { uniqueItems: uniqueItems, ); - AckArraySchemaModel _copyWith({ - AckSchemaModel? items, + ArraySchemaModel _copyWith({ + SchemaModel? items, int? minItems, int? maxItems, bool? uniqueItems, }) { - return AckArraySchemaModel._( + return ArraySchemaModel._( _common, items: items ?? this.items, minItems: minItems ?? this.minItems, @@ -868,7 +870,7 @@ final class AckArraySchemaModel extends AckSchemaModel { ); } - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -892,8 +894,8 @@ final class AckArraySchemaModel extends AckSchemaModel { } } -final class AckObjectSchemaModel extends AckSchemaModel { - const AckObjectSchemaModel({ +final class ObjectSchemaModel extends SchemaModel { + const ObjectSchemaModel({ this.properties, this.required, this.propertyOrdering, @@ -908,22 +910,22 @@ final class AckObjectSchemaModel extends AckSchemaModel { super.extensions, }); - AckObjectSchemaModel._( - _AckSchemaModelCommon common, { + ObjectSchemaModel._( + super.common, { this.properties, this.required, this.propertyOrdering, this.minProperties, this.maxProperties, this.additionalProperties, - }) : super._(common); + }) : super._(); - final Map? properties; + final Map? properties; final List? required; final List? propertyOrdering; final int? minProperties; final int? maxProperties; - final AckAdditionalPropertiesModel? additionalProperties; + final AdditionalPropertiesModel? additionalProperties; @override Object? get _metadataEquality => { @@ -947,8 +949,8 @@ final class AckObjectSchemaModel extends AckSchemaModel { } @override - AckObjectSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckObjectSchemaModel._( + ObjectSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + ObjectSchemaModel._( common, properties: properties, required: required, @@ -958,15 +960,15 @@ final class AckObjectSchemaModel extends AckSchemaModel { additionalProperties: additionalProperties, ); - AckObjectSchemaModel _copyWith({ - Map? properties, + ObjectSchemaModel _copyWith({ + Map? properties, List? required, List? propertyOrdering, int? minProperties, int? maxProperties, - AckAdditionalPropertiesModel? additionalProperties, + AdditionalPropertiesModel? additionalProperties, }) { - return AckObjectSchemaModel._( + return ObjectSchemaModel._( _common, properties: properties ?? this.properties, required: required ?? this.required, @@ -977,7 +979,7 @@ final class AckObjectSchemaModel extends AckSchemaModel { ); } - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -996,8 +998,8 @@ final class AckObjectSchemaModel extends AckSchemaModel { handled.add('additionalProperties'); next = next._copyWith( additionalProperties: value - ? const AckAdditionalPropertiesAllowed() - : const AckAdditionalPropertiesDisallowed(), + ? const AdditionalPropertiesAllowed() + : const AdditionalPropertiesDisallowed(), ); } @@ -1005,8 +1007,8 @@ final class AckObjectSchemaModel extends AckSchemaModel { } } -final class AckNullSchemaModel extends AckSchemaModel { - const AckNullSchemaModel({ +final class NullSchemaModel extends SchemaModel { + const NullSchemaModel({ super.title, super.description, super.defaultValue, @@ -1014,16 +1016,16 @@ final class AckNullSchemaModel extends AckSchemaModel { super.extensions, }) : super(nullable: false); - AckNullSchemaModel._(_AckSchemaModelCommon common) : super._(common); + NullSchemaModel._(super.common) : super._(); @override Map toJsonSchema() => {'type': 'null', ..._common.toJson()}; @override - AckNullSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckNullSchemaModel._(common.copyWith(nullable: false)); + NullSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + NullSchemaModel._(common.copyWith(nullable: false)); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1031,8 +1033,8 @@ final class AckNullSchemaModel extends AckSchemaModel { } } -final class AckAnyOfSchemaModel extends AckSchemaModel { - const AckAnyOfSchemaModel({ +final class AnyOfSchemaModel extends SchemaModel { + const AnyOfSchemaModel({ required this.schemas, this.discriminator, super.title, @@ -1043,14 +1045,11 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { super.extensions, }); - AckAnyOfSchemaModel._( - _AckSchemaModelCommon common, { - required this.schemas, - this.discriminator, - }) : super._(common); + AnyOfSchemaModel._(super.common, {required this.schemas, this.discriminator}) + : super._(); - final List schemas; - final AckSchemaDiscriminatorModel? discriminator; + final List schemas; + final SchemaDiscriminatorModel? discriminator; @override Object? get _metadataEquality => { @@ -1062,14 +1061,14 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { finishCompositionJson('anyOf', schemas); @override - AckAnyOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckAnyOfSchemaModel._( + AnyOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + AnyOfSchemaModel._( common, schemas: schemas, discriminator: discriminator, ); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1077,8 +1076,8 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { } } -final class AckOneOfSchemaModel extends AckSchemaModel { - const AckOneOfSchemaModel({ +final class OneOfSchemaModel extends SchemaModel { + const OneOfSchemaModel({ required this.schemas, this.discriminator, super.title, @@ -1089,14 +1088,11 @@ final class AckOneOfSchemaModel extends AckSchemaModel { super.extensions, }); - AckOneOfSchemaModel._( - _AckSchemaModelCommon common, { - required this.schemas, - this.discriminator, - }) : super._(common); + OneOfSchemaModel._(super.common, {required this.schemas, this.discriminator}) + : super._(); - final List schemas; - final AckSchemaDiscriminatorModel? discriminator; + final List schemas; + final SchemaDiscriminatorModel? discriminator; @override Object? get _metadataEquality => { @@ -1108,14 +1104,14 @@ final class AckOneOfSchemaModel extends AckSchemaModel { finishCompositionJson('oneOf', schemas); @override - AckOneOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckOneOfSchemaModel._( + OneOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + OneOfSchemaModel._( common, schemas: schemas, discriminator: discriminator, ); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1123,8 +1119,8 @@ final class AckOneOfSchemaModel extends AckSchemaModel { } } -final class AckAllOfSchemaModel extends AckSchemaModel { - const AckAllOfSchemaModel({ +final class AllOfSchemaModel extends SchemaModel { + const AllOfSchemaModel({ required this.schemas, super.title, super.description, @@ -1134,20 +1130,19 @@ final class AckAllOfSchemaModel extends AckSchemaModel { super.extensions, }); - AckAllOfSchemaModel._(_AckSchemaModelCommon common, {required this.schemas}) - : super._(common); + AllOfSchemaModel._(super.common, {required this.schemas}) : super._(); - final List schemas; + final List schemas; @override Map toJsonSchema() => finishCompositionJson('allOf', schemas); @override - AckAllOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckAllOfSchemaModel._(common, schemas: schemas); + AllOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => + AllOfSchemaModel._(common, schemas: schemas); - AckSchemaModel _withJsonSchemaKeywords( + SchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { diff --git a/packages/schema_model/lib/src/schema_model_parser.dart b/packages/schema_model/lib/src/schema_model_parser.dart new file mode 100644 index 00000000..c3cd4e38 --- /dev/null +++ b/packages/schema_model/lib/src/schema_model_parser.dart @@ -0,0 +1,450 @@ +part of 'schema_model.dart'; + +final class _JsonSchemaParser { + SchemaModel parse(Map json) { + return _parse(json, path: ''); + } + + SchemaModel _parse( + Map json, { + required String path, + bool nullable = false, + }) { + final nullableUnion = _readNullableUnion(json); + if (nullableUnion != null) { + final parsed = _parse( + nullableUnion.schema, + path: _joinPath(path, 'anyOf/${nullableUnion.schemaIndex}'), + nullable: true, + ); + return _applyKeywords(parsed, json, const {'anyOf'}); + } + + final wrappedRefName = _readWrappedDefinitionsRef(json); + if (wrappedRefName != null) { + return _applyKeywords( + RefSchemaModel(refName: wrappedRefName, nullable: nullable), + json, + const {'allOf'}, + ); + } + + if (json[r'$ref'] case final String ref) { + final refName = _definitionsRefName(ref); + if (refName != null) { + return _applyKeywords( + RefSchemaModel(refName: refName, nullable: nullable), + json, + const {r'$ref'}, + ); + } + + return _fallback( + json, + path: path, + nullable: nullable, + warning: _warning( + code: 'unsupported_ref', + message: + 'Only local #/definitions/ JSON Schema references can be imported.', + path: path, + context: {'ref': ref}, + ), + ); + } + + if (json['type'] case final List types) { + return _parseTypeList(json, types, path: path, nullable: nullable); + } + + if (json['anyOf'] case final List schemas) { + return _parseComposition( + 'anyOf', + schemas, + json, + path: path, + nullable: nullable, + ); + } + if (json['oneOf'] case final List schemas) { + return _parseComposition( + 'oneOf', + schemas, + json, + path: path, + nullable: nullable, + ); + } + if (json['allOf'] case final List schemas) { + return _parseComposition( + 'allOf', + schemas, + json, + path: path, + nullable: nullable, + ); + } + + if (json['type'] case final String type) { + return switch (type) { + 'string' => _applyKeywords( + StringSchemaModel(nullable: nullable), + json, + const {'type'}, + ), + 'integer' => _applyKeywords( + IntegerSchemaModel(nullable: nullable), + json, + const {'type'}, + ), + 'number' => _applyKeywords( + NumberSchemaModel(nullable: nullable), + json, + const {'type'}, + ), + 'boolean' => _applyKeywords( + BooleanSchemaModel(nullable: nullable), + json, + const {'type'}, + ), + 'array' => _parseArray(json, path: path, nullable: nullable), + 'object' => _parseObject(json, path: path, nullable: nullable), + 'null' => _applyKeywords(const NullSchemaModel(), json, const {'type'}), + _ => _fallback( + json, + path: path, + nullable: nullable, + warning: _warning( + code: 'unsupported_type', + message: 'JSON Schema type "$type" is not supported.', + path: path, + context: {'type': type}, + ), + ), + }; + } + + return _fallback( + json, + path: path, + nullable: nullable, + warning: _warning( + code: 'unsupported_schema_shape', + message: 'JSON Schema shape could not be mapped to a SchemaModel.', + path: path, + ), + ); + } + + SchemaModel _parseArray( + Map json, { + required String path, + required bool nullable, + }) { + final warnings = []; + SchemaModel? items; + + if (json.containsKey('items')) { + final itemMap = _asStringMap(json['items']); + if (itemMap != null) { + items = _parse(itemMap, path: _joinPath(path, 'items')); + } else { + warnings.add( + _warning( + code: 'unsupported_items_schema', + message: + 'Boolean or non-object JSON Schema items are not imported.', + path: _joinPath(path, 'items'), + ), + ); + } + } + + return _applyKeywords( + ArraySchemaModel(items: items, nullable: nullable, warnings: warnings), + json, + const {'type', 'items'}, + ); + } + + SchemaModel _parseObject( + Map json, { + required String path, + required bool nullable, + }) { + final warnings = []; + Map? properties; + List? required; + List? propertyOrdering; + AdditionalPropertiesModel? additionalProperties; + + final propertiesJson = _asStringMap(json['properties']); + if (propertiesJson != null) { + final parsed = {}; + for (final entry in propertiesJson.entries) { + final propertyJson = _asStringMap(entry.value); + parsed[entry.key] = propertyJson == null + ? _fallback( + const {}, + path: _joinPath(path, 'properties/${entry.key}'), + warning: _warning( + code: 'unsupported_property_schema', + message: 'Object property schemas must be JSON objects.', + path: _joinPath(path, 'properties/${entry.key}'), + ), + ) + : _parse( + propertyJson, + path: _joinPath(path, 'properties/${entry.key}'), + ); + } + properties = parsed.isEmpty ? null : parsed; + propertyOrdering = parsed.keys.toList(growable: false); + } + + if (json['required'] case final List values) { + required = [ + for (final value in values) + if (value is String) value, + ]; + if (required.isEmpty) required = null; + } + + if (json.containsKey('additionalProperties')) { + switch (json['additionalProperties']) { + case true: + additionalProperties = const AdditionalPropertiesAllowed(); + case false: + additionalProperties = const AdditionalPropertiesDisallowed(); + case final Object? value when _asStringMap(value) != null: + additionalProperties = AdditionalPropertiesSchema( + _parse( + _asStringMap(value)!, + path: _joinPath(path, 'additionalProperties'), + ), + ); + default: + warnings.add( + _warning( + code: 'unsupported_additional_properties', + message: + 'additionalProperties must be a boolean or JSON Schema object.', + path: _joinPath(path, 'additionalProperties'), + ), + ); + } + } + + return _applyKeywords( + ObjectSchemaModel( + properties: properties, + required: required, + propertyOrdering: propertyOrdering, + additionalProperties: additionalProperties, + nullable: nullable, + warnings: warnings, + ), + json, + const {'type', 'properties', 'required', 'additionalProperties'}, + ); + } + + SchemaModel _parseComposition( + String keyword, + List schemas, + Map json, { + required String path, + required bool nullable, + }) { + final parsedSchemas = []; + final warnings = []; + + for (var i = 0; i < schemas.length; i += 1) { + final schema = _asStringMap(schemas[i]); + if (schema == null) { + warnings.add( + _warning( + code: 'unsupported_composition_branch', + message: 'Composition branches must be JSON Schema objects.', + path: _joinPath(path, '$keyword/$i'), + ), + ); + continue; + } + parsedSchemas.add(_parse(schema, path: _joinPath(path, '$keyword/$i'))); + } + + final model = switch (keyword) { + 'anyOf' => AnyOfSchemaModel( + schemas: parsedSchemas, + nullable: nullable, + warnings: warnings, + ), + 'oneOf' => OneOfSchemaModel( + schemas: parsedSchemas, + nullable: nullable, + warnings: warnings, + ), + 'allOf' => AllOfSchemaModel( + schemas: parsedSchemas, + nullable: nullable, + warnings: warnings, + ), + _ => throw StateError('Unsupported composition keyword $keyword'), + }; + + return _applyKeywords(model, json, {keyword}); + } + + SchemaModel _parseTypeList( + Map json, + List types, { + required String path, + required bool nullable, + }) { + final hasNull = types.contains('null'); + final schemas = []; + + for (final type in types) { + if (type is! String || type == 'null') continue; + schemas.add(_parse({...json, 'type': type}, path: path)); + } + + final warning = _warning( + code: 'unsupported_type_array', + message: + 'JSON Schema type arrays are imported as a best-effort composition.', + path: path, + context: {'type': types}, + ); + + if (schemas.isEmpty) { + return _fallback( + json, + path: path, + nullable: nullable || hasNull, + ).withWarnings([warning]); + } + + return AnyOfSchemaModel( + schemas: schemas, + nullable: nullable || hasNull, + warnings: [warning], + ); + } + + SchemaModel _fallback( + Map json, { + required String path, + bool nullable = false, + SchemaModelWarning? warning, + }) { + final fallback = AnyOfSchemaModel( + schemas: const [ + StringSchemaModel(), + NumberSchemaModel(), + IntegerSchemaModel(), + BooleanSchemaModel(), + ObjectSchemaModel(), + ArraySchemaModel(), + ], + nullable: nullable, + warnings: [if (warning != null) warning], + ); + return json.isEmpty ? fallback : fallback.withJsonSchemaKeywords(json); + } + + SchemaModel _applyKeywords( + SchemaModel model, + Map json, + Set handled, + ) { + final keywords = Map.fromEntries( + json.entries.where((entry) => !handled.contains(entry.key)), + ); + return keywords.isEmpty ? model : model.withJsonSchemaKeywords(keywords); + } + + _NullableUnion? _readNullableUnion(Map json) { + if (json['anyOf'] case final List schemas) { + if (schemas.length != 2) return null; + + final first = _asStringMap(schemas[0]); + final second = _asStringMap(schemas[1]); + if (first == null || second == null) return null; + + if (_isNullSchema(first)) { + return _NullableUnion(schema: second, schemaIndex: 1); + } + if (_isNullSchema(second)) { + return _NullableUnion(schema: first, schemaIndex: 0); + } + } + return null; + } + + String? _readWrappedDefinitionsRef(Map json) { + if (json['allOf'] case final List schemas) { + if (schemas.length != 1) return null; + final refSchema = _asStringMap(schemas.single); + if (refSchema == null || refSchema.length != 1) return null; + if (refSchema[r'$ref'] case final String ref) { + return _definitionsRefName(ref); + } + } + return null; + } + + bool _isNullSchema(Map json) { + return json.length == 1 && json['type'] == 'null'; + } + + String? _definitionsRefName(String ref) { + const prefix = '#/definitions/'; + if (!ref.startsWith(prefix)) return null; + return _unescapeJsonPointerToken(ref.substring(prefix.length)); + } + + Map? _asStringMap(Object? value) { + if (value is Map) return value; + if (value is! Map) return null; + + final result = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String) return null; + result[key] = entry.value; + } + return result; + } + + SchemaModelWarning _warning({ + required String code, + required String message, + required String path, + Map context = const {}, + }) { + return SchemaModelWarning( + code: code, + message: message, + path: path.isEmpty ? null : path, + context: context, + ); + } + + String _joinPath(String parent, String child) { + if (parent.isEmpty) return child; + return '$parent/$child'; + } + + String _unescapeJsonPointerToken(String value) { + return value.replaceAll('~1', '/').replaceAll('~0', '~'); + } +} + +final class _NullableUnion { + const _NullableUnion({required this.schema, required this.schemaIndex}); + + final Map schema; + final int schemaIndex; +} diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart b/packages/schema_model/lib/src/schema_model_warning.dart similarity index 87% rename from packages/ack/lib/src/schema_model/ack_schema_model_warning.dart rename to packages/schema_model/lib/src/schema_model_warning.dart index f44d03ec..ed573e24 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart +++ b/packages/schema_model/lib/src/schema_model_warning.dart @@ -2,8 +2,8 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; @immutable -final class AckSchemaModelWarning { - const AckSchemaModelWarning({ +final class SchemaModelWarning { + const SchemaModelWarning({ required this.code, required this.message, this.path, @@ -25,7 +25,7 @@ final class AckSchemaModelWarning { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! AckSchemaModelWarning) return false; + if (other is! SchemaModelWarning) return false; return code == other.code && message == other.message && path == other.path && diff --git a/packages/schema_model/lib/src/standard_schema.dart b/packages/schema_model/lib/src/standard_schema.dart new file mode 100644 index 00000000..1a560651 --- /dev/null +++ b/packages/schema_model/lib/src/standard_schema.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +abstract interface class StandardSchema { + /// Standard schema properties. + StandardSchemaProps get standard; +} + +final class StandardSchemaProps { + const StandardSchemaProps({ + required this.vendor, + required this.validate, + this.version = 1, + this.jsonSchema, + }); + + final String vendor; + final int version; + final FutureOr> Function( + Object? value, [ + StandardValidateOptions? options, + ]) + validate; + final StandardJsonSchemaConverter? jsonSchema; +} + +final class StandardValidateOptions { + const StandardValidateOptions({this.libraryOptions}); + + final Map? libraryOptions; +} + +sealed class StandardResult { + const StandardResult(); +} + +final class StandardSuccess extends StandardResult { + const StandardSuccess(this.value); + + final Output value; +} + +final class StandardFailure extends StandardResult { + const StandardFailure(this.issues); + + final List issues; +} + +final class StandardIssue { + const StandardIssue({required this.message, this.path = const []}); + + final String message; + final List path; +} + +final class StandardJsonSchemaConverter { + const StandardJsonSchemaConverter({ + required this.input, + required this.output, + }); + + final Map Function(StandardJsonSchemaOptions options) input; + final Map Function(StandardJsonSchemaOptions options) output; +} + +final class StandardJsonSchemaOptions { + const StandardJsonSchemaOptions({required this.target, this.libraryOptions}); + + final String target; + final Map? libraryOptions; +} + +abstract final class JsonSchemaTarget { + static const draft202012 = 'draft-2020-12'; + static const draft07 = 'draft-07'; + static const openapi30 = 'openapi-3.0'; +} diff --git a/packages/schema_model/pubspec.yaml b/packages/schema_model/pubspec.yaml new file mode 100644 index 00000000..2f5eb337 --- /dev/null +++ b/packages/schema_model/pubspec.yaml @@ -0,0 +1,19 @@ +name: schema_model +description: Vendor-neutral schema model and standard schema contracts for Dart +version: 1.0.0-beta.12-wip +repository: https://github.com/btwld/ack +issue_tracker: https://github.com/btwld/ack/issues +homepage: https://docs.page/btwld/ack + +resolution: workspace + +environment: + sdk: '>=3.8.0 <4.0.0' + +dependencies: + collection: ^1.18.0 + meta: ^1.15.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.15 diff --git a/packages/schema_model/test/schema_model_parser_test.dart b/packages/schema_model/test/schema_model_parser_test.dart new file mode 100644 index 00000000..d6709a14 --- /dev/null +++ b/packages/schema_model/test/schema_model_parser_test.dart @@ -0,0 +1,216 @@ +import 'package:schema_model/schema_model.dart'; +import 'package:test/test.dart'; + +void main() { + group('SchemaModel.fromJsonSchema', () { + test('parses typed scalar keywords', () { + final string = SchemaModel.fromJsonSchema({ + 'type': 'string', + 'format': 'email', + 'minLength': 3, + 'maxLength': 64, + 'pattern': r'.+@.+', + }); + final integer = SchemaModel.fromJsonSchema({ + 'type': 'integer', + 'minimum': 1, + 'maximum': 10, + 'multipleOf': 2, + }); + final number = SchemaModel.fromJsonSchema({ + 'type': 'number', + 'exclusiveMinimum': 0, + 'exclusiveMaximum': 1, + }); + final boolean = SchemaModel.fromJsonSchema({ + 'type': 'boolean', + 'const': true, + }); + + expect(string, isA()); + expect(integer, isA()); + expect(number, isA()); + expect(boolean, isA()); + expect(string.toJsonSchema(), { + 'type': 'string', + 'format': 'email', + 'minLength': 3, + 'maxLength': 64, + 'pattern': r'.+@.+', + }); + expect(integer.toJsonSchema(), { + 'type': 'integer', + 'minimum': 1, + 'maximum': 10, + 'multipleOf': 2, + }); + expect(number.toJsonSchema(), { + 'type': 'number', + 'exclusiveMinimum': 0, + 'exclusiveMaximum': 1, + }); + expect(boolean.toJsonSchema(), {'type': 'boolean', 'const': true}); + }); + + test( + 'parses arrays, objects, required fields, and additional properties', + () { + final model = SchemaModel.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'tags': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + }, + 'required': ['name'], + 'minProperties': 1, + 'additionalProperties': {'type': 'string'}, + }); + + final object = model as ObjectSchemaModel; + expect(object.propertyOrdering, ['name', 'tags']); + expect(object.properties!['name'], isA()); + expect(object.properties!['tags'], isA()); + expect(object.required, ['name']); + expect(object.additionalProperties, isA()); + expect(model.toJsonSchema(), { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'tags': { + 'type': 'array', + 'items': {'type': 'integer'}, + }, + }, + 'required': ['name'], + 'minProperties': 1, + 'additionalProperties': {'type': 'string'}, + }); + }, + ); + + test('canonicalizes nullable anyOf wrappers back to nullable models', () { + final json = { + 'description': 'nickname', + 'default': 'leo', + 'definitions': { + 'User': {'type': 'object'}, + }, + 'anyOf': [ + {'type': 'string', 'minLength': 1}, + {'type': 'null'}, + ], + }; + + final model = SchemaModel.fromJsonSchema(json); + + expect(model, isA()); + expect(model.nullable, isTrue); + expect(model.description, 'nickname'); + expect(model.defaultValue, 'leo'); + expect(model.extensions['definitions'], { + 'User': {'type': 'object'}, + }); + expect(model.toJsonSchema(), json); + }); + + test('parses refs in bare and metadata-wrapped forms', () { + final bare = SchemaModel.fromJsonSchema({ + r'$ref': '#/definitions/Foo~0Bar~1Baz', + }); + final wrapped = SchemaModel.fromJsonSchema({ + 'title': 'Node', + 'allOf': [ + {r'$ref': '#/definitions/Node'}, + ], + }); + + expect((bare as RefSchemaModel).refName, 'Foo~Bar/Baz'); + expect(bare.toJsonSchema(), {r'$ref': '#/definitions/Foo~0Bar~1Baz'}); + expect((wrapped as RefSchemaModel).refName, 'Node'); + expect(wrapped.title, 'Node'); + expect(wrapped.toJsonSchema(), { + 'title': 'Node', + 'allOf': [ + {r'$ref': '#/definitions/Node'}, + ], + }); + }); + + test('warns instead of throwing for unsupported shapes', () { + final unknown = SchemaModel.fromJsonSchema({'x-custom': true}); + final unsupportedRef = SchemaModel.fromJsonSchema({ + r'$ref': 'https://example.com/schema.json', + }); + final typeList = SchemaModel.fromJsonSchema({ + 'type': ['string', 'null'], + 'minLength': 1, + }); + final booleanItems = SchemaModel.fromJsonSchema({ + 'type': 'array', + 'items': true, + }); + + expect(unknown.warnings.single.code, 'unsupported_schema_shape'); + expect(unknown.extensions['x-custom'], isTrue); + expect(unsupportedRef.warnings.single.code, 'unsupported_ref'); + expect(typeList.warnings.single.code, 'unsupported_type_array'); + expect(typeList.nullable, isTrue); + expect(booleanItems.warnings.single.code, 'unsupported_items_schema'); + expect(booleanItems.toJsonSchema(), {'type': 'array'}); + }); + + test('round-trips rendered JSON for model variants', () { + final corpus = [ + const StringSchemaModel( + format: 'email', + minLength: 3, + nullable: true, + defaultValue: 'a@b.com', + ), + const IntegerSchemaModel(minimum: 1, maximum: 10), + const NumberSchemaModel(multipleOf: 0.5), + const BooleanSchemaModel(constValue: false), + const ArraySchemaModel(items: StringSchemaModel(minLength: 1)), + const ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, + required: ['name'], + additionalProperties: AdditionalPropertiesSchema( + IntegerSchemaModel(), + ), + ), + const NullSchemaModel(title: 'Nothing'), + const AnyOfSchemaModel( + schemas: [StringSchemaModel(), IntegerSchemaModel()], + ), + const OneOfSchemaModel( + schemas: [ + StringSchemaModel(constValue: 'x'), + IntegerSchemaModel(), + ], + nullable: true, + ), + const AllOfSchemaModel( + schemas: [ + StringSchemaModel(minLength: 2), + StringSchemaModel(maxLength: 8), + ], + ), + const RefSchemaModel(refName: 'Node'), + ]; + + for (final model in corpus) { + final json = model.toJsonSchema(); + final parsed = SchemaModel.fromJsonSchema(json); + + expect( + parsed.toJsonSchema(), + json, + reason: model.runtimeType.toString(), + ); + } + }); + }); +} diff --git a/packages/ack/test/schema_model/ack_schema_model_test.dart b/packages/schema_model/test/schema_model_test.dart similarity index 65% rename from packages/ack/test/schema_model/ack_schema_model_test.dart rename to packages/schema_model/test/schema_model_test.dart index f0d0ecb0..ca40d115 100644 --- a/packages/ack/test/schema_model/ack_schema_model_test.dart +++ b/packages/schema_model/test/schema_model_test.dart @@ -1,10 +1,10 @@ -import 'package:ack/ack.dart'; +import 'package:schema_model/schema_model.dart'; import 'package:test/test.dart'; void main() { - group('AckSchemaModel sealed variants', () { + group('SchemaModel sealed variants', () { test('renders string constraints and const literals', () { - const model = AckStringSchemaModel( + const model = StringSchemaModel( constValue: 'cat', minLength: 3, maxLength: 12, @@ -21,18 +21,15 @@ void main() { }); test('keeps number and integer as distinct variants', () { - const integer = AckIntegerSchemaModel(minimum: 1); - const number = AckNumberSchemaModel(minimum: 1.5); + const integer = IntegerSchemaModel(minimum: 1); + const number = NumberSchemaModel(minimum: 1.5); expect(integer.toJsonSchema(), {'type': 'integer', 'minimum': 1}); expect(number.toJsonSchema(), {'type': 'number', 'minimum': 1.5}); }); test('wraps nullable primitive schemas without nullable keyword', () { - const model = AckStringSchemaModel( - description: 'nickname', - nullable: true, - ); + const model = StringSchemaModel(description: 'nickname', nullable: true); // Description is hoisted to the top level so generic JSON Schema // consumers can discover it without descending into anyOf branches. @@ -46,9 +43,9 @@ void main() { }); test('composition nullable adds one null branch', () { - const model = AckAnyOfSchemaModel( + const model = AnyOfSchemaModel( nullable: true, - schemas: [AckStringSchemaModel(), AckNullSchemaModel()], + schemas: [StringSchemaModel(), NullSchemaModel()], ); expect(model.toJsonSchema(), { @@ -60,10 +57,10 @@ void main() { }); test('renders object additional properties as one typed policy', () { - const model = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const model = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, required: ['name'], - additionalProperties: AckAdditionalPropertiesDisallowed(), + additionalProperties: AdditionalPropertiesDisallowed(), ); expect(model.toJsonSchema(), { @@ -79,8 +76,8 @@ void main() { test( 'renders explicit object required fields even with property default', () { - const model = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel(defaultValue: 'guest')}, + const model = ObjectSchemaModel( + properties: {'name': StringSchemaModel(defaultValue: 'guest')}, required: ['name'], ); @@ -95,10 +92,10 @@ void main() { ); test('renders allOf directly for adapter tests', () { - const model = AckAllOfSchemaModel( + const model = AllOfSchemaModel( schemas: [ - AckStringSchemaModel(minLength: 2), - AckStringSchemaModel(maxLength: 8), + StringSchemaModel(minLength: 2), + StringSchemaModel(maxLength: 8), ], ); @@ -111,18 +108,18 @@ void main() { }); test('uses structural warnings in equality and hashCode', () { - const left = AckStringSchemaModel( + const left = StringSchemaModel( warnings: [ - AckSchemaModelWarning( + SchemaModelWarning( code: 'test_warning', message: 'A test warning.', context: {'path': 'left'}, ), ], ); - const right = AckStringSchemaModel( + const right = StringSchemaModel( warnings: [ - AckSchemaModelWarning( + SchemaModelWarning( code: 'test_warning', message: 'A test warning.', context: {'path': 'left'}, @@ -135,17 +132,17 @@ void main() { }); test('uses non-rendered metadata in equality and hashCode', () { - const left = AckAnyOfSchemaModel( - schemas: [AckStringSchemaModel()], - discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), + const left = AnyOfSchemaModel( + schemas: [StringSchemaModel()], + discriminator: SchemaDiscriminatorModel(propertyName: 'type'), ); - const same = AckAnyOfSchemaModel( - schemas: [AckStringSchemaModel()], - discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), + const same = AnyOfSchemaModel( + schemas: [StringSchemaModel()], + discriminator: SchemaDiscriminatorModel(propertyName: 'type'), ); - const different = AckAnyOfSchemaModel( - schemas: [AckStringSchemaModel()], - discriminator: AckSchemaDiscriminatorModel(propertyName: 'kind'), + const different = AnyOfSchemaModel( + schemas: [StringSchemaModel()], + discriminator: SchemaDiscriminatorModel(propertyName: 'kind'), ); expect(left.toJsonSchema(), same.toJsonSchema()); @@ -154,22 +151,22 @@ void main() { expect(left.hashCode, same.hashCode); expect(left, isNot(different)); - const ordered = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const ordered = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, propertyOrdering: ['name'], ); - const unordered = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, + const unordered = ObjectSchemaModel( + properties: {'name': StringSchemaModel()}, ); expect(ordered.toJsonSchema(), unordered.toJsonSchema()); expect(ordered, isNot(unordered)); - const dateBounded = AckStringSchemaModel( + const dateBounded = StringSchemaModel( format: 'date', formatMinimum: '2026-01-01', ); - const dateUnbounded = AckStringSchemaModel(format: 'date'); + const dateUnbounded = StringSchemaModel(format: 'date'); expect(dateBounded.toJsonSchema(), dateUnbounded.toJsonSchema()); expect(dateBounded, isNot(dateUnbounded)); diff --git a/packages/schema_model/test/standard_schema_test.dart b/packages/schema_model/test/standard_schema_test.dart new file mode 100644 index 00000000..34bd3952 --- /dev/null +++ b/packages/schema_model/test/standard_schema_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:schema_model/schema_model.dart'; +import 'package:test/test.dart'; + +Future> _resolve( + FutureOr> result, +) async { + return result; +} + +final class _FakeSchema implements StandardSchema { + const _FakeSchema({this.async = false, this.includeJsonSchema = true}); + + final bool async; + final bool includeJsonSchema; + + @override + StandardSchemaProps get standard => StandardSchemaProps( + vendor: 'fake', + validate: (value, [options]) { + if (value == 'ok') { + final result = const StandardSuccess(1); + return async ? Future>.value(result) : result; + } + return const StandardFailure([ + StandardIssue(message: 'Not ok', path: ['items', 1]), + ]); + }, + jsonSchema: includeJsonSchema + ? StandardJsonSchemaConverter( + input: (options) => { + r'$schema': options.target, + 'type': 'string', + if (options.libraryOptions case final options?) + 'x-options': options, + }, + output: (options) => {'type': 'integer'}, + ) + : null, + ); +} + +void main() { + group('StandardSchema', () { + test('carries vendor, version, and success or failure results', () async { + const schema = _FakeSchema(); + + expect(schema.standard.vendor, 'fake'); + expect(schema.standard.version, 1); + + final success = await _resolve(schema.standard.validate('ok')); + final failure = await _resolve(schema.standard.validate('bad')); + + expect(success, isA>()); + expect((success as StandardSuccess).value, 1); + expect(failure, isA>()); + expect((failure as StandardFailure).issues.single.message, 'Not ok'); + expect(failure.issues.single.path, ['items', 1]); + }); + + test('allows async validation and validate options', () async { + const schema = _FakeSchema(async: true); + + final result = await _resolve( + schema.standard.validate( + 'ok', + const StandardValidateOptions(libraryOptions: {'mode': 'strict'}), + ), + ); + + expect(result, isA>()); + }); + + test('models optional JSON Schema converters', () { + const withConverter = _FakeSchema(); + const withoutConverter = _FakeSchema(includeJsonSchema: false); + + expect(withoutConverter.standard.jsonSchema, isNull); + expect( + withConverter.standard.jsonSchema!.input( + const StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, + libraryOptions: {'dialect': 'draft7'}, + ), + ), + { + r'$schema': JsonSchemaTarget.draft07, + 'type': 'string', + 'x-options': {'dialect': 'draft7'}, + }, + ); + expect( + withConverter.standard.jsonSchema!.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'integer'}, + ); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index b25e295c..5723796b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ workspace: - packages/ack_generator - packages/ack_firebase_ai - packages/ack_json_schema_builder + - packages/schema_model - example dependencies: @@ -55,6 +56,7 @@ melos: - ack_generator - ack_firebase_ai - ack_json_schema_builder + - schema_model - ack_example - ack_annotations @@ -66,6 +68,7 @@ melos: - ack - ack_generator - ack_json_schema_builder + - schema_model - ack_example dependsOn: - "ack" # This will ensure 'ack' is prioritized diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index 19157807..29965f89 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -36,6 +36,7 @@ void main(List args) { 'packages/ack_generator/CHANGELOG.md', 'packages/ack_firebase_ai/CHANGELOG.md', 'packages/ack_json_schema_builder/CHANGELOG.md', + 'packages/schema_model/CHANGELOG.md', ]; for (final path in changelogPaths) { From a3dc1a8e7caa9d6b58fb43cac8a24210f0c8244e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 15:05:12 -0400 Subject: [PATCH 02/13] feat(schema-model): move SchemaModel into ack as AckSchemaModel Relocate schema model types, parser, and warnings from the schema_model package into ack under the AckSchemaModel naming. Remove legacy aliases and compat tests. Narrow schema_model to the StandardSchema validation contract and update dependent packages and tests accordingly. --- README.md | 2 +- packages/ack/CHANGELOG.md | 15 +- packages/ack/lib/src/json_schema.dart | 6 +- .../src/schema_model/ack_schema_model.dart} | 315 +++++++++--------- .../ack_schema_model_builder.dart | 119 +++---- .../ack_schema_model_parser.dart} | 94 +++--- .../ack_schema_model_warning.dart} | 6 +- .../legacy_schema_model_aliases.dart | 59 ---- packages/ack/lib/src/schemas/schema.dart | 35 +- .../ack/lib/src/schemas/wrapper_schema.dart | 2 +- .../ack_schema_model_builder_test.dart | 57 ++-- .../ack_schema_model_parser_test.dart} | 95 +++--- .../schema_model/ack_schema_model_test.dart} | 73 ++-- .../legacy_alias_compat_test.dart | 74 ---- .../ack/test/schemas/core_schema_test.dart | 1 - .../ack/test/schemas/lazy_schema_test.dart | 2 +- .../nullable_constraint_json_schema_test.dart | 8 +- .../standard_schema_conformance_test.dart | 1 + packages/ack_firebase_ai/CHANGELOG.md | 1 - .../manifest.json | 2 +- .../firebase_ai_native_schema/manifest.json | 2 +- ...firebase_ai_response_json_schema_test.dart | 2 +- ...irebase_ai_response_json_schema_cases.dart | 64 ++-- packages/ack_json_schema_builder/CHANGELOG.md | 2 - .../lib/ack_json_schema_builder.dart | 6 +- .../test/to_json_schema_builder_test.dart | 32 +- packages/schema_model/CHANGELOG.md | 11 +- packages/schema_model/README.md | 63 +++- packages/schema_model/lib/schema_model.dart | 4 +- .../schema_model/lib/src/standard_schema.dart | 93 +++++- packages/schema_model/pubspec.yaml | 6 +- 31 files changed, 608 insertions(+), 644 deletions(-) rename packages/{schema_model/lib/src/schema_model.dart => ack/lib/src/schema_model/ack_schema_model.dart} (77%) rename packages/{schema_model/lib/src/schema_model_parser.dart => ack/lib/src/schema_model/ack_schema_model_parser.dart} (83%) rename packages/{schema_model/lib/src/schema_model_warning.dart => ack/lib/src/schema_model/ack_schema_model_warning.dart} (87%) delete mode 100644 packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart rename packages/{schema_model/test/schema_model_parser_test.dart => ack/test/schema_model/ack_schema_model_parser_test.dart} (64%) rename packages/{schema_model/test/schema_model_test.dart => ack/test/schema_model/ack_schema_model_test.dart} (65%) delete mode 100644 packages/ack/test/schema_model/legacy_alias_compat_test.dart diff --git a/README.md b/README.md index bd667efb..9b1d1cd0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). This repository is a monorepo containing: - **[ack](./packages/ack)**: Core validation library with fluent schema building API -- **[schema_model](./packages/schema_model)**: Vendor-neutral schema model and standard schema contracts for Dart +- **[schema_model](./packages/schema_model)**: Standard Schema contracts for Dart validators and converters - **[ack_generator](./packages/ack_generator)**: Code generator for `@AckType()` extension-type wrappers - **[ack_firebase_ai](./packages/ack_firebase_ai)**: Firebase AI (Gemini) schema converter for structured output generation - **[example](./example)**: Example projects demonstrating usage of all packages diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 1c5f01a2..dd8cf313 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 1.0.0-beta.12-wip ### Breaking Changes @@ -9,9 +9,6 @@ * Replace the interim JSON Schema model kind API with sealed `AckSchemaModel` variants and canonical `AckSchema.toSchemaModel()` adapter conversion. -* Move the schema description model to `package:schema_model` and rename - public model types from `Ack*` to neutral names such as `SchemaModel`, - `StringSchemaModel`, and `ObjectSchemaModel`. ### Added @@ -28,23 +25,21 @@ * `SchemaContext.pathSegments` exposes raw path segments with list indices as integers, and `SchemaError.toStandardIssues()` maps ACK errors to standard issues. +* `AckSchemaModel.fromJsonSchema(...)` imports supported Draft-7 JSON Schema + maps into ACK's existing schema model as a best-effort Ack feature. ### Changed * Project discriminated schemas through union-owned discriminator branches. * Preserve defaults, const values, extension keywords, transformed metadata, - composition, and JSON Schema constraints through the schema model boundary. -* Deprecated `Ack*SchemaModel` typedef aliases remain exported from `ack` for - source compatibility; new in-repo code uses `package:schema_model` names. + composition, and JSON Schema constraints through the `AckSchemaModel` + boundary. ### Migration * Re-run tests for code paths that parse or encode `double`/`num` values. If a boundary must accept `NaN` or infinities, model that value outside the JSON numeric schema path before validation. -* Replace `AckSchemaModel` type annotations with `SchemaModel` from - `package:schema_model` when updating code. The old names continue to compile - through deprecated aliases. ## 1.0.0-beta.11 diff --git a/packages/ack/lib/src/json_schema.dart b/packages/ack/lib/src/json_schema.dart index f708baea..245d88c1 100644 --- a/packages/ack/lib/src/json_schema.dart +++ b/packages/ack/lib/src/json_schema.dart @@ -1,10 +1,10 @@ /// JSON Schema rendering for ACK's schema model. /// -/// [SchemaModel] is ACK's canonical export model. JSON Schema is one output +/// [AckSchemaModel] is ACK's canonical export model. JSON Schema is one output /// renderer for that model. library; export 'json_schema/json_schema_utils.dart'; +export 'schema_model/ack_schema_model.dart'; export 'schema_model/ack_schema_model_builder.dart'; -export 'schema_model/legacy_schema_model_aliases.dart'; -export 'package:schema_model/schema_model.dart'; +export 'schema_model/ack_schema_model_warning.dart'; diff --git a/packages/schema_model/lib/src/schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart similarity index 77% rename from packages/schema_model/lib/src/schema_model.dart rename to packages/ack/lib/src/schema_model/ack_schema_model.dart index 1b0975a9..8aa9f587 100644 --- a/packages/schema_model/lib/src/schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -1,16 +1,16 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; -import 'schema_model_warning.dart'; +import 'ack_schema_model_warning.dart'; -part 'schema_model_parser.dart'; +part 'ack_schema_model_parser.dart'; const _unset = Object(); const _nullSchemaJson = {'type': 'null'}; @immutable -final class SchemaDiscriminatorModel { - const SchemaDiscriminatorModel({required this.propertyName}); +final class AckSchemaDiscriminatorModel { + const AckSchemaDiscriminatorModel({required this.propertyName}); final String propertyName; @@ -19,7 +19,7 @@ final class SchemaDiscriminatorModel { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! SchemaDiscriminatorModel) return false; + if (other is! AckSchemaDiscriminatorModel) return false; return propertyName == other.propertyName; } @@ -28,8 +28,8 @@ final class SchemaDiscriminatorModel { } @immutable -final class _SchemaModelCommon { - const _SchemaModelCommon({ +final class _AckSchemaModelCommon { + const _AckSchemaModelCommon({ this.title, this.description, this.nullable = false, @@ -42,18 +42,18 @@ final class _SchemaModelCommon { final String? description; final bool nullable; final Object? defaultValue; - final List warnings; + final List warnings; final Map extensions; - _SchemaModelCommon copyWith({ + _AckSchemaModelCommon copyWith({ Object? title = _unset, Object? description = _unset, bool? nullable, Object? defaultValue = _unset, - List? warnings, + List? warnings, Map? extensions, }) { - return _SchemaModelCommon( + return _AckSchemaModelCommon( title: identical(title, _unset) ? this.title : title as String?, description: identical(description, _unset) ? this.description @@ -95,38 +95,40 @@ final class _SchemaModelCommon { } @immutable -sealed class AdditionalPropertiesModel { - const AdditionalPropertiesModel(); +sealed class AckAdditionalPropertiesModel { + const AckAdditionalPropertiesModel(); Object? toJsonSchemaValue(); } -final class AdditionalPropertiesAllowed extends AdditionalPropertiesModel { - const AdditionalPropertiesAllowed(); +final class AckAdditionalPropertiesAllowed + extends AckAdditionalPropertiesModel { + const AckAdditionalPropertiesAllowed(); @override Object toJsonSchemaValue() => true; } -final class AdditionalPropertiesDisallowed extends AdditionalPropertiesModel { - const AdditionalPropertiesDisallowed(); +final class AckAdditionalPropertiesDisallowed + extends AckAdditionalPropertiesModel { + const AckAdditionalPropertiesDisallowed(); @override Object toJsonSchemaValue() => false; } -final class AdditionalPropertiesSchema extends AdditionalPropertiesModel { - const AdditionalPropertiesSchema(this.schema); +final class AckAdditionalPropertiesSchema extends AckAdditionalPropertiesModel { + const AckAdditionalPropertiesSchema(this.schema); - final SchemaModel schema; + final AckSchemaModel schema; @override Map toJsonSchemaValue() => schema.toJsonSchema(); } @immutable -sealed class SchemaModel { - const SchemaModel({ +sealed class AckSchemaModel { + const AckSchemaModel({ this.title, this.description, this.nullable = false, @@ -135,11 +137,11 @@ sealed class SchemaModel { this.extensions = const {}, }); - factory SchemaModel.fromJsonSchema(Map json) { + factory AckSchemaModel.fromJsonSchema(Map json) { return _JsonSchemaParser().parse(json); } - SchemaModel._(_SchemaModelCommon common) + AckSchemaModel._(_AckSchemaModelCommon common) : title = common.title, description = common.description, nullable = common.nullable, @@ -151,10 +153,10 @@ sealed class SchemaModel { final String? description; final bool nullable; final Object? defaultValue; - final List warnings; + final List warnings; final Map extensions; - _SchemaModelCommon get _common => _SchemaModelCommon( + _AckSchemaModelCommon get _common => _AckSchemaModelCommon( title: title, description: description, nullable: nullable, @@ -170,24 +172,24 @@ sealed class SchemaModel { Object? get _metadataEquality => null; @protected - SchemaModel _rebuildWithCommon(_SchemaModelCommon common); + AckSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common); - SchemaModel withDescription(String? description) => + AckSchemaModel withDescription(String? description) => _rebuildWithCommon(_common.copyWith(description: description)); - SchemaModel withNullable(bool nullable) => + AckSchemaModel withNullable(bool nullable) => _rebuildWithCommon(_common.copyWith(nullable: nullable)); - SchemaModel withDefaultValue(Object? defaultValue) => + AckSchemaModel withDefaultValue(Object? defaultValue) => _rebuildWithCommon(_common.copyWith(defaultValue: defaultValue)); - SchemaModel withWarnings(List warnings) => + AckSchemaModel withWarnings(List warnings) => _rebuildWithCommon(_common.copyWith(warnings: warnings)); - SchemaModel withExtensions(Map extensions) => + AckSchemaModel withExtensions(Map extensions) => _rebuildWithCommon(_common.copyWith(extensions: extensions)); - SchemaModel withJsonSchemaKeywords(Map keywords) { + AckSchemaModel withJsonSchemaKeywords(Map keywords) { final commonHandled = {}; var common = _common; @@ -209,47 +211,47 @@ sealed class SchemaModel { } return switch (_rebuildWithCommon(common)) { - StringSchemaModel schema => schema._withJsonSchemaKeywords( + AckStringSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - IntegerSchemaModel schema => schema._withJsonSchemaKeywords( + AckIntegerSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - NumberSchemaModel schema => schema._withJsonSchemaKeywords( + AckNumberSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - BooleanSchemaModel schema => schema._withJsonSchemaKeywords( + AckBooleanSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - ArraySchemaModel schema => schema._withJsonSchemaKeywords( + AckArraySchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - ObjectSchemaModel schema => schema._withJsonSchemaKeywords( + AckObjectSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AnyOfSchemaModel schema => schema._withJsonSchemaKeywords( + AckAnyOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - OneOfSchemaModel schema => schema._withJsonSchemaKeywords( + AckOneOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - AllOfSchemaModel schema => schema._withJsonSchemaKeywords( + AckAllOfSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - NullSchemaModel schema => schema._withJsonSchemaKeywords( + AckNullSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), - RefSchemaModel schema => schema._withJsonSchemaKeywords( + AckRefSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, ), @@ -277,10 +279,10 @@ sealed class SchemaModel { Map finishCompositionJson( String keyword, - List schemas, + List schemas, ) { final branches = schemas.map((schema) => schema.toJsonSchema()).toList(); - if (nullable && !schemas.any((schema) => schema is NullSchemaModel)) { + if (nullable && !schemas.any((schema) => schema is AckNullSchemaModel)) { // Match Zod v4's Draft-7 nullable-union shape: keep the composition as // one branch, then add null as the other branch. Flattening would validate // the same values but loses the distinction between nullability and the @@ -298,7 +300,7 @@ sealed class SchemaModel { return {..._common.toJson(), keyword: branches}; } - SchemaModel _withUnhandledKeywords( + AckSchemaModel _withUnhandledKeywords( Map keywords, Set handled, ) { @@ -312,7 +314,7 @@ sealed class SchemaModel { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! SchemaModel) return false; + if (other is! AckSchemaModel) return false; const deepEq = DeepCollectionEquality(); return runtimeType == other.runtimeType && deepEq.equals(toJsonSchema(), other.toJsonSchema()) && @@ -332,8 +334,8 @@ sealed class SchemaModel { } } -final class RefSchemaModel extends SchemaModel { - const RefSchemaModel({ +final class AckRefSchemaModel extends AckSchemaModel { + const AckRefSchemaModel({ required this.refName, super.title, super.description, @@ -343,7 +345,8 @@ final class RefSchemaModel extends SchemaModel { super.extensions, }); - RefSchemaModel._(super.common, {required this.refName}) : super._(); + AckRefSchemaModel._(_AckSchemaModelCommon common, {required this.refName}) + : super._(common); final String refName; @@ -362,10 +365,10 @@ final class RefSchemaModel extends SchemaModel { } @override - RefSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - RefSchemaModel._(common, refName: refName); + AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckRefSchemaModel._(common, refName: refName); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -373,8 +376,8 @@ final class RefSchemaModel extends SchemaModel { } } -final class StringSchemaModel extends SchemaModel { - const StringSchemaModel({ +final class AckStringSchemaModel extends AckSchemaModel { + const AckStringSchemaModel({ this.format, this.enumValues, this.constValue, @@ -391,8 +394,8 @@ final class StringSchemaModel extends SchemaModel { super.extensions, }); - StringSchemaModel._( - super.common, { + AckStringSchemaModel._( + _AckSchemaModelCommon common, { this.format, this.enumValues, this.constValue, @@ -401,7 +404,7 @@ final class StringSchemaModel extends SchemaModel { this.pattern, this.formatMinimum, this.formatMaximum, - }) : super._(); + }) : super._(common); @override final String? format; @@ -438,8 +441,8 @@ final class StringSchemaModel extends SchemaModel { }); @override - StringSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - StringSchemaModel._( + AckStringSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckStringSchemaModel._( common, format: format, enumValues: enumValues, @@ -451,7 +454,7 @@ final class StringSchemaModel extends SchemaModel { formatMaximum: formatMaximum, ); - StringSchemaModel _copyWith({ + AckStringSchemaModel _copyWith({ String? format, List? enumValues, Object? constValue = _unset, @@ -461,7 +464,7 @@ final class StringSchemaModel extends SchemaModel { String? formatMinimum, String? formatMaximum, }) { - return StringSchemaModel._( + return AckStringSchemaModel._( _common, format: format ?? this.format, enumValues: enumValues ?? this.enumValues, @@ -476,7 +479,7 @@ final class StringSchemaModel extends SchemaModel { ); } - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -487,9 +490,9 @@ final class StringSchemaModel extends SchemaModel { handled.add('format'); next = next._copyWith(format: value); } - if (keywords['enum'] case final List values) { + if (keywords['enum'] case final List values) { handled.add('enum'); - next = next._copyWith(enumValues: values); + next = next._copyWith(enumValues: List.from(values)); } if (keywords['const'] case final String value) { handled.add('const'); @@ -520,8 +523,8 @@ final class StringSchemaModel extends SchemaModel { } } -final class IntegerSchemaModel extends SchemaModel { - const IntegerSchemaModel({ +final class AckIntegerSchemaModel extends AckSchemaModel { + const AckIntegerSchemaModel({ this.format, this.constValue, this.minimum, @@ -537,8 +540,8 @@ final class IntegerSchemaModel extends SchemaModel { super.extensions, }); - IntegerSchemaModel._( - super.common, { + AckIntegerSchemaModel._( + _AckSchemaModelCommon common, { this.format, this.constValue, this.minimum, @@ -546,7 +549,7 @@ final class IntegerSchemaModel extends SchemaModel { this.exclusiveMinimum, this.exclusiveMaximum, this.multipleOf, - }) : super._(); + }) : super._(common); @override final String? format; @@ -570,8 +573,8 @@ final class IntegerSchemaModel extends SchemaModel { }); @override - IntegerSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - IntegerSchemaModel._( + AckIntegerSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckIntegerSchemaModel._( common, format: format, constValue: constValue, @@ -582,7 +585,7 @@ final class IntegerSchemaModel extends SchemaModel { multipleOf: multipleOf, ); - IntegerSchemaModel _copyWith({ + AckIntegerSchemaModel _copyWith({ String? format, Object? constValue = _unset, num? minimum, @@ -591,7 +594,7 @@ final class IntegerSchemaModel extends SchemaModel { num? exclusiveMaximum, num? multipleOf, }) { - return IntegerSchemaModel._( + return AckIntegerSchemaModel._( _common, format: format ?? this.format, constValue: identical(constValue, _unset) @@ -605,7 +608,7 @@ final class IntegerSchemaModel extends SchemaModel { ); } - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -645,8 +648,8 @@ final class IntegerSchemaModel extends SchemaModel { } } -final class NumberSchemaModel extends SchemaModel { - const NumberSchemaModel({ +final class AckNumberSchemaModel extends AckSchemaModel { + const AckNumberSchemaModel({ this.format, this.constValue, this.minimum, @@ -662,8 +665,8 @@ final class NumberSchemaModel extends SchemaModel { super.extensions, }); - NumberSchemaModel._( - super.common, { + AckNumberSchemaModel._( + _AckSchemaModelCommon common, { this.format, this.constValue, this.minimum, @@ -671,7 +674,7 @@ final class NumberSchemaModel extends SchemaModel { this.exclusiveMinimum, this.exclusiveMaximum, this.multipleOf, - }) : super._(); + }) : super._(common); @override final String? format; @@ -695,8 +698,8 @@ final class NumberSchemaModel extends SchemaModel { }); @override - NumberSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - NumberSchemaModel._( + AckNumberSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckNumberSchemaModel._( common, format: format, constValue: constValue, @@ -707,7 +710,7 @@ final class NumberSchemaModel extends SchemaModel { multipleOf: multipleOf, ); - NumberSchemaModel _copyWith({ + AckNumberSchemaModel _copyWith({ String? format, Object? constValue = _unset, num? minimum, @@ -716,7 +719,7 @@ final class NumberSchemaModel extends SchemaModel { num? exclusiveMaximum, num? multipleOf, }) { - return NumberSchemaModel._( + return AckNumberSchemaModel._( _common, format: format ?? this.format, constValue: identical(constValue, _unset) @@ -730,7 +733,7 @@ final class NumberSchemaModel extends SchemaModel { ); } - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -770,8 +773,8 @@ final class NumberSchemaModel extends SchemaModel { } } -final class BooleanSchemaModel extends SchemaModel { - const BooleanSchemaModel({ +final class AckBooleanSchemaModel extends AckSchemaModel { + const AckBooleanSchemaModel({ this.constValue, super.title, super.description, @@ -781,7 +784,8 @@ final class BooleanSchemaModel extends SchemaModel { super.extensions, }); - BooleanSchemaModel._(super.common, {this.constValue}) : super._(); + AckBooleanSchemaModel._(_AckSchemaModelCommon common, {this.constValue}) + : super._(common); final bool? constValue; @@ -792,10 +796,10 @@ final class BooleanSchemaModel extends SchemaModel { }); @override - BooleanSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - BooleanSchemaModel._(common, constValue: constValue); + AckBooleanSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckBooleanSchemaModel._(common, constValue: constValue); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -803,14 +807,14 @@ final class BooleanSchemaModel extends SchemaModel { var next = this; if (keywords['const'] case final bool value) { handled.add('const'); - next = BooleanSchemaModel._(_common, constValue: value); + next = AckBooleanSchemaModel._(_common, constValue: value); } return next._withUnhandledKeywords(keywords, handled); } } -final class ArraySchemaModel extends SchemaModel { - const ArraySchemaModel({ +final class AckArraySchemaModel extends AckSchemaModel { + const AckArraySchemaModel({ this.items, this.minItems, this.maxItems, @@ -823,15 +827,15 @@ final class ArraySchemaModel extends SchemaModel { super.extensions, }); - ArraySchemaModel._( - super.common, { + AckArraySchemaModel._( + _AckSchemaModelCommon common, { this.items, this.minItems, this.maxItems, this.uniqueItems, - }) : super._(); + }) : super._(common); - final SchemaModel? items; + final AckSchemaModel? items; final int? minItems; final int? maxItems; final bool? uniqueItems; @@ -846,8 +850,8 @@ final class ArraySchemaModel extends SchemaModel { }); @override - ArraySchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - ArraySchemaModel._( + AckArraySchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckArraySchemaModel._( common, items: items, minItems: minItems, @@ -855,13 +859,13 @@ final class ArraySchemaModel extends SchemaModel { uniqueItems: uniqueItems, ); - ArraySchemaModel _copyWith({ - SchemaModel? items, + AckArraySchemaModel _copyWith({ + AckSchemaModel? items, int? minItems, int? maxItems, bool? uniqueItems, }) { - return ArraySchemaModel._( + return AckArraySchemaModel._( _common, items: items ?? this.items, minItems: minItems ?? this.minItems, @@ -870,7 +874,7 @@ final class ArraySchemaModel extends SchemaModel { ); } - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -894,8 +898,8 @@ final class ArraySchemaModel extends SchemaModel { } } -final class ObjectSchemaModel extends SchemaModel { - const ObjectSchemaModel({ +final class AckObjectSchemaModel extends AckSchemaModel { + const AckObjectSchemaModel({ this.properties, this.required, this.propertyOrdering, @@ -910,22 +914,22 @@ final class ObjectSchemaModel extends SchemaModel { super.extensions, }); - ObjectSchemaModel._( - super.common, { + AckObjectSchemaModel._( + _AckSchemaModelCommon common, { this.properties, this.required, this.propertyOrdering, this.minProperties, this.maxProperties, this.additionalProperties, - }) : super._(); + }) : super._(common); - final Map? properties; + final Map? properties; final List? required; final List? propertyOrdering; final int? minProperties; final int? maxProperties; - final AdditionalPropertiesModel? additionalProperties; + final AckAdditionalPropertiesModel? additionalProperties; @override Object? get _metadataEquality => { @@ -949,8 +953,8 @@ final class ObjectSchemaModel extends SchemaModel { } @override - ObjectSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - ObjectSchemaModel._( + AckObjectSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckObjectSchemaModel._( common, properties: properties, required: required, @@ -960,15 +964,15 @@ final class ObjectSchemaModel extends SchemaModel { additionalProperties: additionalProperties, ); - ObjectSchemaModel _copyWith({ - Map? properties, + AckObjectSchemaModel _copyWith({ + Map? properties, List? required, List? propertyOrdering, int? minProperties, int? maxProperties, - AdditionalPropertiesModel? additionalProperties, + AckAdditionalPropertiesModel? additionalProperties, }) { - return ObjectSchemaModel._( + return AckObjectSchemaModel._( _common, properties: properties ?? this.properties, required: required ?? this.required, @@ -979,7 +983,7 @@ final class ObjectSchemaModel extends SchemaModel { ); } - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -998,8 +1002,8 @@ final class ObjectSchemaModel extends SchemaModel { handled.add('additionalProperties'); next = next._copyWith( additionalProperties: value - ? const AdditionalPropertiesAllowed() - : const AdditionalPropertiesDisallowed(), + ? const AckAdditionalPropertiesAllowed() + : const AckAdditionalPropertiesDisallowed(), ); } @@ -1007,8 +1011,8 @@ final class ObjectSchemaModel extends SchemaModel { } } -final class NullSchemaModel extends SchemaModel { - const NullSchemaModel({ +final class AckNullSchemaModel extends AckSchemaModel { + const AckNullSchemaModel({ super.title, super.description, super.defaultValue, @@ -1016,16 +1020,16 @@ final class NullSchemaModel extends SchemaModel { super.extensions, }) : super(nullable: false); - NullSchemaModel._(super.common) : super._(); + AckNullSchemaModel._(_AckSchemaModelCommon common) : super._(common); @override Map toJsonSchema() => {'type': 'null', ..._common.toJson()}; @override - NullSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - NullSchemaModel._(common.copyWith(nullable: false)); + AckNullSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckNullSchemaModel._(common.copyWith(nullable: false)); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1033,8 +1037,8 @@ final class NullSchemaModel extends SchemaModel { } } -final class AnyOfSchemaModel extends SchemaModel { - const AnyOfSchemaModel({ +final class AckAnyOfSchemaModel extends AckSchemaModel { + const AckAnyOfSchemaModel({ required this.schemas, this.discriminator, super.title, @@ -1045,11 +1049,14 @@ final class AnyOfSchemaModel extends SchemaModel { super.extensions, }); - AnyOfSchemaModel._(super.common, {required this.schemas, this.discriminator}) - : super._(); + AckAnyOfSchemaModel._( + _AckSchemaModelCommon common, { + required this.schemas, + this.discriminator, + }) : super._(common); - final List schemas; - final SchemaDiscriminatorModel? discriminator; + final List schemas; + final AckSchemaDiscriminatorModel? discriminator; @override Object? get _metadataEquality => { @@ -1061,14 +1068,14 @@ final class AnyOfSchemaModel extends SchemaModel { finishCompositionJson('anyOf', schemas); @override - AnyOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - AnyOfSchemaModel._( + AckAnyOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckAnyOfSchemaModel._( common, schemas: schemas, discriminator: discriminator, ); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1076,8 +1083,8 @@ final class AnyOfSchemaModel extends SchemaModel { } } -final class OneOfSchemaModel extends SchemaModel { - const OneOfSchemaModel({ +final class AckOneOfSchemaModel extends AckSchemaModel { + const AckOneOfSchemaModel({ required this.schemas, this.discriminator, super.title, @@ -1088,11 +1095,14 @@ final class OneOfSchemaModel extends SchemaModel { super.extensions, }); - OneOfSchemaModel._(super.common, {required this.schemas, this.discriminator}) - : super._(); + AckOneOfSchemaModel._( + _AckSchemaModelCommon common, { + required this.schemas, + this.discriminator, + }) : super._(common); - final List schemas; - final SchemaDiscriminatorModel? discriminator; + final List schemas; + final AckSchemaDiscriminatorModel? discriminator; @override Object? get _metadataEquality => { @@ -1104,14 +1114,14 @@ final class OneOfSchemaModel extends SchemaModel { finishCompositionJson('oneOf', schemas); @override - OneOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - OneOfSchemaModel._( + AckOneOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckOneOfSchemaModel._( common, schemas: schemas, discriminator: discriminator, ); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { @@ -1119,8 +1129,8 @@ final class OneOfSchemaModel extends SchemaModel { } } -final class AllOfSchemaModel extends SchemaModel { - const AllOfSchemaModel({ +final class AckAllOfSchemaModel extends AckSchemaModel { + const AckAllOfSchemaModel({ required this.schemas, super.title, super.description, @@ -1130,19 +1140,20 @@ final class AllOfSchemaModel extends SchemaModel { super.extensions, }); - AllOfSchemaModel._(super.common, {required this.schemas}) : super._(); + AckAllOfSchemaModel._(_AckSchemaModelCommon common, {required this.schemas}) + : super._(common); - final List schemas; + final List schemas; @override Map toJsonSchema() => finishCompositionJson('allOf', schemas); @override - AllOfSchemaModel _rebuildWithCommon(_SchemaModelCommon common) => - AllOfSchemaModel._(common, schemas: schemas); + AckAllOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckAllOfSchemaModel._(common, schemas: schemas); - SchemaModel _withJsonSchemaKeywords( + AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index e47a080b..1f635fda 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -7,21 +7,22 @@ import '../constraints/datetime_constraint.dart'; import '../context.dart'; import '../json_schema/json_schema_utils.dart'; import '../schemas/schema.dart'; -import 'package:schema_model/schema_model.dart'; +import 'ack_schema_model.dart'; +import 'ack_schema_model_warning.dart'; extension AckSchemaModelExtension< Boundary extends Object, Runtime extends Object > on AckSchema { - SchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); } final class _SchemaModelBuilder { - final _definitions = {}; + final _definitions = {}; final _targets = {}; - SchemaModel build(AckSchema schema) { + AckSchemaModel build(AckSchema schema) { final root = _build(schema); if (_definitions.isEmpty) return root; @@ -73,7 +74,7 @@ final class _SchemaModelBuilder { return merged; } - SchemaModel _build(AckSchema schema) { + AckSchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { final base = _build(schema.inner); // Defaults wrap their inner without transforming the boundary value, so @@ -99,7 +100,7 @@ final class _SchemaModelBuilder { } else { wrapped = wrapped.withWarnings([ ...wrapped.warnings, - SchemaModelWarning( + AckSchemaModelWarning( code: 'default_not_export_safe', message: 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', @@ -132,56 +133,56 @@ final class _SchemaModelBuilder { DiscriminatedObjectSchema() => _discriminated(schema), LazySchema() => _lazy(schema), _ => throw UnsupportedError( - 'Schema type ${schema.runtimeType} is not supported for SchemaModel conversion.', + 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', ), }; return schema is LazySchema ? model : _applyConstraints(model, schema); } - SchemaModel _string(StringSchema schema) { - return StringSchemaModel( + AckSchemaModel _string(StringSchema schema) { + return AckStringSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - SchemaModel _integer(IntegerSchema schema) { - return IntegerSchemaModel( + AckSchemaModel _integer(IntegerSchema schema) { + return AckIntegerSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - SchemaModel _number({String? description, required bool nullable}) { - return NumberSchemaModel(description: description, nullable: nullable); + AckSchemaModel _number({String? description, required bool nullable}) { + return AckNumberSchemaModel(description: description, nullable: nullable); } - SchemaModel _boolean(BooleanSchema schema) { - return BooleanSchemaModel( + AckSchemaModel _boolean(BooleanSchema schema) { + return AckBooleanSchemaModel( description: schema.description, nullable: schema.isNullable, ); } - SchemaModel _enum(EnumSchema schema) { - return StringSchemaModel( + AckSchemaModel _enum(EnumSchema schema) { + return AckStringSchemaModel( description: schema.description, enumValues: [for (final value in schema.values) value.name], nullable: schema.isNullable, ); } - SchemaModel _array(ListSchema schema) { - return ArraySchemaModel( + AckSchemaModel _array(ListSchema schema) { + return AckArraySchemaModel( description: schema.description, nullable: schema.isNullable, items: _build(schema.itemSchema), ); } - SchemaModel _object(ObjectSchema schema) { - final properties = {}; + AckSchemaModel _object(ObjectSchema schema) { + final properties = {}; final required = []; final ordering = []; @@ -196,43 +197,43 @@ final class _SchemaModelBuilder { } } - return ObjectSchemaModel( + return AckObjectSchemaModel( description: schema.description, nullable: schema.isNullable, properties: properties.isEmpty ? null : properties, required: required.isEmpty ? null : required, propertyOrdering: ordering.isEmpty ? null : ordering, additionalProperties: schema.additionalProperties - ? const AdditionalPropertiesAllowed() - : const AdditionalPropertiesDisallowed(), + ? const AckAdditionalPropertiesAllowed() + : const AckAdditionalPropertiesDisallowed(), ); } - SchemaModel _anyOf(AnyOfSchema schema) { - return AnyOfSchemaModel( + AckSchemaModel _anyOf(AnyOfSchema schema) { + return AckAnyOfSchemaModel( schemas: schema.schemas.map(_build).toList(growable: false), nullable: schema.isNullable, description: schema.description, ); } - SchemaModel _instance(InstanceSchema schema) { + AckSchemaModel _instance(InstanceSchema schema) { // InstanceSchema accepts arbitrary Dart instances of a runtime type with no // direct JSON representation. Adapters that flow through a codec see the // boundary schema instead; this is the fallback for a bare instance. - return AnyOfSchemaModel( + return AckAnyOfSchemaModel( schemas: [ - StringSchemaModel(description: schema.description), - NumberSchemaModel(description: schema.description), - IntegerSchemaModel(description: schema.description), - BooleanSchemaModel(description: schema.description), - ObjectSchemaModel(description: schema.description), - ArraySchemaModel(description: schema.description), + AckStringSchemaModel(description: schema.description), + AckNumberSchemaModel(description: schema.description), + AckIntegerSchemaModel(description: schema.description), + AckBooleanSchemaModel(description: schema.description), + AckObjectSchemaModel(description: schema.description), + AckArraySchemaModel(description: schema.description), ], nullable: schema.isNullable, description: schema.description, warnings: const [ - SchemaModelWarning( + AckSchemaModelWarning( code: 'ack_instance_json_boundary', message: 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', @@ -241,23 +242,23 @@ final class _SchemaModelBuilder { ); } - SchemaModel _any(AnySchema schema) { + AckSchemaModel _any(AnySchema schema) { final description = schema.description; final primitiveBranches = [ - StringSchemaModel(description: description), - NumberSchemaModel(description: description), - IntegerSchemaModel(description: description), - BooleanSchemaModel(description: description), - ObjectSchemaModel(description: description), - ArraySchemaModel(description: description), + AckStringSchemaModel(description: description), + AckNumberSchemaModel(description: description), + AckIntegerSchemaModel(description: description), + AckBooleanSchemaModel(description: description), + AckObjectSchemaModel(description: description), + AckArraySchemaModel(description: description), ]; - return AnyOfSchemaModel( + return AckAnyOfSchemaModel( schemas: primitiveBranches, nullable: schema.isNullable, description: description, warnings: const [ - SchemaModelWarning( + AckSchemaModelWarning( code: 'ack_any_json_boundary', message: 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', @@ -266,9 +267,9 @@ final class _SchemaModelBuilder { ); } - SchemaModel _discriminated(DiscriminatedObjectSchema schema) { + AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { if (schema.schemas.isEmpty) { - return ObjectSchemaModel( + return AckObjectSchemaModel( properties: const {}, required: const [], nullable: schema.isNullable, @@ -276,10 +277,10 @@ final class _SchemaModelBuilder { ); } - final branches = []; + final branches = []; for (final entry in schema.schemas.entries) { final converted = _build(schema.effectiveBranch(entry.key)); - if (converted is! ObjectSchemaModel) { + if (converted is! AckObjectSchemaModel) { throw ArgumentError( 'Discriminated branch "${entry.key}" must export as an object schema model.', ); @@ -287,9 +288,9 @@ final class _SchemaModelBuilder { branches.add(converted); } - return AnyOfSchemaModel( + return AckAnyOfSchemaModel( schemas: branches, - discriminator: SchemaDiscriminatorModel( + discriminator: AckSchemaDiscriminatorModel( propertyName: schema.discriminatorKey, ), description: schema.description, @@ -297,7 +298,7 @@ final class _SchemaModelBuilder { ); } - SchemaModel _lazy(LazySchema schema) { + AckSchemaModel _lazy(LazySchema schema) { final name = schema.name; final target = schema.target; final priorTarget = _targets[name]; @@ -317,8 +318,8 @@ final class _SchemaModelBuilder { return _lazyRef(schema); } - SchemaModel _lazyRef(LazySchema schema) { - var model = RefSchemaModel( + AckSchemaModel _lazyRef(LazySchema schema) { + var model = AckRefSchemaModel( refName: schema.name, description: schema.description, nullable: schema.isNullable, @@ -331,7 +332,7 @@ final class _SchemaModelBuilder { return model.withWarnings([ ...model.warnings, - SchemaModelWarning( + AckSchemaModelWarning( code: 'lazy_runtime_checks_not_export_safe', message: 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', @@ -344,8 +345,8 @@ final class _SchemaModelBuilder { } } -SchemaModel _applyConstraints( - SchemaModel model, +AckSchemaModel _applyConstraints( + AckSchemaModel model, AckSchema schema, ) { var next = model; @@ -363,13 +364,13 @@ SchemaModel _applyConstraints( return next; } -SchemaModel _applyDateTimeConstraint( - SchemaModel model, +AckSchemaModel _applyDateTimeConstraint( + AckSchemaModel model, DateTimeConstraint constraint, ) { return model.withWarnings([ ...model.warnings, - SchemaModelWarning( + AckSchemaModelWarning( code: 'datetime_constraint_not_draft7', message: 'DateTime range constraints are not emitted because JSON Schema Draft-7 has no standard format range keywords.', diff --git a/packages/schema_model/lib/src/schema_model_parser.dart b/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart similarity index 83% rename from packages/schema_model/lib/src/schema_model_parser.dart rename to packages/ack/lib/src/schema_model/ack_schema_model_parser.dart index c3cd4e38..55a50da1 100644 --- a/packages/schema_model/lib/src/schema_model_parser.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart @@ -1,11 +1,11 @@ -part of 'schema_model.dart'; +part of 'ack_schema_model.dart'; final class _JsonSchemaParser { - SchemaModel parse(Map json) { + AckSchemaModel parse(Map json) { return _parse(json, path: ''); } - SchemaModel _parse( + AckSchemaModel _parse( Map json, { required String path, bool nullable = false, @@ -23,7 +23,7 @@ final class _JsonSchemaParser { final wrappedRefName = _readWrappedDefinitionsRef(json); if (wrappedRefName != null) { return _applyKeywords( - RefSchemaModel(refName: wrappedRefName, nullable: nullable), + AckRefSchemaModel(refName: wrappedRefName, nullable: nullable), json, const {'allOf'}, ); @@ -33,7 +33,7 @@ final class _JsonSchemaParser { final refName = _definitionsRefName(ref); if (refName != null) { return _applyKeywords( - RefSchemaModel(refName: refName, nullable: nullable), + AckRefSchemaModel(refName: refName, nullable: nullable), json, const {r'$ref'}, ); @@ -88,28 +88,30 @@ final class _JsonSchemaParser { if (json['type'] case final String type) { return switch (type) { 'string' => _applyKeywords( - StringSchemaModel(nullable: nullable), + AckStringSchemaModel(nullable: nullable), json, const {'type'}, ), 'integer' => _applyKeywords( - IntegerSchemaModel(nullable: nullable), + AckIntegerSchemaModel(nullable: nullable), json, const {'type'}, ), 'number' => _applyKeywords( - NumberSchemaModel(nullable: nullable), + AckNumberSchemaModel(nullable: nullable), json, const {'type'}, ), 'boolean' => _applyKeywords( - BooleanSchemaModel(nullable: nullable), + AckBooleanSchemaModel(nullable: nullable), json, const {'type'}, ), 'array' => _parseArray(json, path: path, nullable: nullable), 'object' => _parseObject(json, path: path, nullable: nullable), - 'null' => _applyKeywords(const NullSchemaModel(), json, const {'type'}), + 'null' => _applyKeywords(const AckNullSchemaModel(), json, const { + 'type', + }), _ => _fallback( json, path: path, @@ -130,19 +132,19 @@ final class _JsonSchemaParser { nullable: nullable, warning: _warning( code: 'unsupported_schema_shape', - message: 'JSON Schema shape could not be mapped to a SchemaModel.', + message: 'JSON Schema shape could not be mapped to a AckSchemaModel.', path: path, ), ); } - SchemaModel _parseArray( + AckSchemaModel _parseArray( Map json, { required String path, required bool nullable, }) { - final warnings = []; - SchemaModel? items; + final warnings = []; + AckSchemaModel? items; if (json.containsKey('items')) { final itemMap = _asStringMap(json['items']); @@ -161,26 +163,26 @@ final class _JsonSchemaParser { } return _applyKeywords( - ArraySchemaModel(items: items, nullable: nullable, warnings: warnings), + AckArraySchemaModel(items: items, nullable: nullable, warnings: warnings), json, const {'type', 'items'}, ); } - SchemaModel _parseObject( + AckSchemaModel _parseObject( Map json, { required String path, required bool nullable, }) { - final warnings = []; - Map? properties; + final warnings = []; + Map? properties; List? required; List? propertyOrdering; - AdditionalPropertiesModel? additionalProperties; + AckAdditionalPropertiesModel? additionalProperties; final propertiesJson = _asStringMap(json['properties']); if (propertiesJson != null) { - final parsed = {}; + final parsed = {}; for (final entry in propertiesJson.entries) { final propertyJson = _asStringMap(entry.value); parsed[entry.key] = propertyJson == null @@ -213,11 +215,11 @@ final class _JsonSchemaParser { if (json.containsKey('additionalProperties')) { switch (json['additionalProperties']) { case true: - additionalProperties = const AdditionalPropertiesAllowed(); + additionalProperties = const AckAdditionalPropertiesAllowed(); case false: - additionalProperties = const AdditionalPropertiesDisallowed(); + additionalProperties = const AckAdditionalPropertiesDisallowed(); case final Object? value when _asStringMap(value) != null: - additionalProperties = AdditionalPropertiesSchema( + additionalProperties = AckAdditionalPropertiesSchema( _parse( _asStringMap(value)!, path: _joinPath(path, 'additionalProperties'), @@ -236,7 +238,7 @@ final class _JsonSchemaParser { } return _applyKeywords( - ObjectSchemaModel( + AckObjectSchemaModel( properties: properties, required: required, propertyOrdering: propertyOrdering, @@ -249,15 +251,15 @@ final class _JsonSchemaParser { ); } - SchemaModel _parseComposition( + AckSchemaModel _parseComposition( String keyword, List schemas, Map json, { required String path, required bool nullable, }) { - final parsedSchemas = []; - final warnings = []; + final parsedSchemas = []; + final warnings = []; for (var i = 0; i < schemas.length; i += 1) { final schema = _asStringMap(schemas[i]); @@ -275,17 +277,17 @@ final class _JsonSchemaParser { } final model = switch (keyword) { - 'anyOf' => AnyOfSchemaModel( + 'anyOf' => AckAnyOfSchemaModel( schemas: parsedSchemas, nullable: nullable, warnings: warnings, ), - 'oneOf' => OneOfSchemaModel( + 'oneOf' => AckOneOfSchemaModel( schemas: parsedSchemas, nullable: nullable, warnings: warnings, ), - 'allOf' => AllOfSchemaModel( + 'allOf' => AckAllOfSchemaModel( schemas: parsedSchemas, nullable: nullable, warnings: warnings, @@ -296,14 +298,14 @@ final class _JsonSchemaParser { return _applyKeywords(model, json, {keyword}); } - SchemaModel _parseTypeList( + AckSchemaModel _parseTypeList( Map json, List types, { required String path, required bool nullable, }) { final hasNull = types.contains('null'); - final schemas = []; + final schemas = []; for (final type in types) { if (type is! String || type == 'null') continue; @@ -326,27 +328,27 @@ final class _JsonSchemaParser { ).withWarnings([warning]); } - return AnyOfSchemaModel( + return AckAnyOfSchemaModel( schemas: schemas, nullable: nullable || hasNull, warnings: [warning], ); } - SchemaModel _fallback( + AckSchemaModel _fallback( Map json, { required String path, bool nullable = false, - SchemaModelWarning? warning, + AckSchemaModelWarning? warning, }) { - final fallback = AnyOfSchemaModel( + final fallback = AckAnyOfSchemaModel( schemas: const [ - StringSchemaModel(), - NumberSchemaModel(), - IntegerSchemaModel(), - BooleanSchemaModel(), - ObjectSchemaModel(), - ArraySchemaModel(), + AckStringSchemaModel(), + AckNumberSchemaModel(), + AckIntegerSchemaModel(), + AckBooleanSchemaModel(), + AckObjectSchemaModel(), + AckArraySchemaModel(), ], nullable: nullable, warnings: [if (warning != null) warning], @@ -354,8 +356,8 @@ final class _JsonSchemaParser { return json.isEmpty ? fallback : fallback.withJsonSchemaKeywords(json); } - SchemaModel _applyKeywords( - SchemaModel model, + AckSchemaModel _applyKeywords( + AckSchemaModel model, Map json, Set handled, ) { @@ -418,13 +420,13 @@ final class _JsonSchemaParser { return result; } - SchemaModelWarning _warning({ + AckSchemaModelWarning _warning({ required String code, required String message, required String path, Map context = const {}, }) { - return SchemaModelWarning( + return AckSchemaModelWarning( code: code, message: message, path: path.isEmpty ? null : path, diff --git a/packages/schema_model/lib/src/schema_model_warning.dart b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart similarity index 87% rename from packages/schema_model/lib/src/schema_model_warning.dart rename to packages/ack/lib/src/schema_model/ack_schema_model_warning.dart index ed573e24..f44d03ec 100644 --- a/packages/schema_model/lib/src/schema_model_warning.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart @@ -2,8 +2,8 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; @immutable -final class SchemaModelWarning { - const SchemaModelWarning({ +final class AckSchemaModelWarning { + const AckSchemaModelWarning({ required this.code, required this.message, this.path, @@ -25,7 +25,7 @@ final class SchemaModelWarning { @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! SchemaModelWarning) return false; + if (other is! AckSchemaModelWarning) return false; return code == other.code && message == other.message && path == other.path && diff --git a/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart b/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart deleted file mode 100644 index 00696e2a..00000000 --- a/packages/ack/lib/src/schema_model/legacy_schema_model_aliases.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:schema_model/schema_model.dart'; - -@Deprecated('Use SchemaModel from package:schema_model instead.') -typedef AckSchemaModel = SchemaModel; - -@Deprecated('Use StringSchemaModel from package:schema_model instead.') -typedef AckStringSchemaModel = StringSchemaModel; - -@Deprecated('Use IntegerSchemaModel from package:schema_model instead.') -typedef AckIntegerSchemaModel = IntegerSchemaModel; - -@Deprecated('Use NumberSchemaModel from package:schema_model instead.') -typedef AckNumberSchemaModel = NumberSchemaModel; - -@Deprecated('Use BooleanSchemaModel from package:schema_model instead.') -typedef AckBooleanSchemaModel = BooleanSchemaModel; - -@Deprecated('Use ArraySchemaModel from package:schema_model instead.') -typedef AckArraySchemaModel = ArraySchemaModel; - -@Deprecated('Use ObjectSchemaModel from package:schema_model instead.') -typedef AckObjectSchemaModel = ObjectSchemaModel; - -@Deprecated('Use NullSchemaModel from package:schema_model instead.') -typedef AckNullSchemaModel = NullSchemaModel; - -@Deprecated('Use AnyOfSchemaModel from package:schema_model instead.') -typedef AckAnyOfSchemaModel = AnyOfSchemaModel; - -@Deprecated('Use OneOfSchemaModel from package:schema_model instead.') -typedef AckOneOfSchemaModel = OneOfSchemaModel; - -@Deprecated('Use AllOfSchemaModel from package:schema_model instead.') -typedef AckAllOfSchemaModel = AllOfSchemaModel; - -@Deprecated('Use RefSchemaModel from package:schema_model instead.') -typedef AckRefSchemaModel = RefSchemaModel; - -@Deprecated('Use AdditionalPropertiesModel from package:schema_model instead.') -typedef AckAdditionalPropertiesModel = AdditionalPropertiesModel; - -@Deprecated( - 'Use AdditionalPropertiesAllowed from package:schema_model instead.', -) -typedef AckAdditionalPropertiesAllowed = AdditionalPropertiesAllowed; - -@Deprecated( - 'Use AdditionalPropertiesDisallowed from package:schema_model instead.', -) -typedef AckAdditionalPropertiesDisallowed = AdditionalPropertiesDisallowed; - -@Deprecated('Use AdditionalPropertiesSchema from package:schema_model instead.') -typedef AckAdditionalPropertiesSchema = AdditionalPropertiesSchema; - -@Deprecated('Use SchemaDiscriminatorModel from package:schema_model instead.') -typedef AckSchemaDiscriminatorModel = SchemaDiscriminatorModel; - -@Deprecated('Use SchemaModelWarning from package:schema_model instead.') -typedef AckSchemaModelWarning = SchemaModelWarning; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index b027a736..718911bc 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -360,26 +360,25 @@ abstract class AckSchema ), }, jsonSchema: StandardJsonSchemaConverter( - input: (options) { - if (options.target != JsonSchemaTarget.draft07) { - throw UnsupportedError('ack supports draft-07 only'); - } - return toJsonSchema(); - }, - output: (options) { - if (options.target != JsonSchemaTarget.draft07) { - throw UnsupportedError('ack supports draft-07 only'); - } - if (this is CodecSchema) { - throw UnsupportedError( - 'codec Runtime side is not JSON-representable', - ); - } - return toJsonSchema(); - }, + input: (options) => _renderJsonSchema(options.target), + output: (options) => this is CodecSchema + ? throw UnsupportedError( + 'codec Runtime side is not JSON-representable', + ) + : _renderJsonSchema(options.target), ), ); + /// Renders this schema as a Draft-7 JSON Schema map, throwing for any other + /// [target] (ack only supports Draft-7 — spec permits throwing for + /// unsupported targets). + Map _renderJsonSchema(JsonSchemaTarget target) { + if (target != JsonSchemaTarget.draft07) { + throw UnsupportedError('ack supports draft-07 only'); + } + return toJsonSchema(); + } + /// Parses and validates a value, then maps the validated value to [TOut]. SchemaResult safeParseAs( Object? value, @@ -466,7 +465,7 @@ abstract class AckSchema /// Converts this schema to a JSON Schema Draft-7 representation. /// - /// Delegates to the sealed [SchemaModel] boundary so all renderers share + /// Delegates to the sealed [AckSchemaModel] boundary so all renderers share /// the same Draft-7 output. Subclasses should not override this directly; /// instead they are dispatched in `ack_schema_model_builder.dart`. Map toJsonSchema() => toSchemaModel().toJsonSchema(); diff --git a/packages/ack/lib/src/schemas/wrapper_schema.dart b/packages/ack/lib/src/schemas/wrapper_schema.dart index 26232323..469ee8c7 100644 --- a/packages/ack/lib/src/schemas/wrapper_schema.dart +++ b/packages/ack/lib/src/schemas/wrapper_schema.dart @@ -6,7 +6,7 @@ part of 'schema.dart'; /// Wrappers add runtime behavior (e.g. codecs, defaults) while preserving an /// inner schema for boundary-shape traversal, schema-model export, and /// discriminated-branch rewriting. The canonical JSON export path is -/// `AckSchema → SchemaModel → JSON`; wrappers do not render JSON directly. +/// `AckSchema → AckSchemaModel → JSON`; wrappers do not render JSON directly. /// /// The fluent API (`nullable`, `describe`, `withConstraint`, …) is provided by /// [FluentSchema], which this mixin requires via its `on` clause; wrappers only diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index 9057b474..725aa2eb 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -14,13 +14,16 @@ void main() { final model = schema.toSchemaModel(); - expect(model, isA()); - final object = model as ObjectSchemaModel; + expect(model, isA()); + final object = model as AckObjectSchemaModel; expect(object.description, 'User payload'); - expect(object.properties!['name'], isA()); - expect((object.properties!['name']! as StringSchemaModel).minLength, 2); + expect(object.properties!['name'], isA()); + expect( + (object.properties!['name']! as AckStringSchemaModel).minLength, + 2, + ); expect(object.properties!['name']!.defaultValue, 'Ada'); - expect((object.properties!['role']! as StringSchemaModel).enumValues, [ + expect((object.properties!['role']! as AckStringSchemaModel).enumValues, [ 'admin', 'member', ]); @@ -35,16 +38,6 @@ void main() { expect(model.toJsonSchema(), {'type': 'string', 'default': 'draft'}); }); - test('rendered schema model JSON imports back to the same JSON', () { - final model = Ack.object({ - 'name': Ack.string().minLength(2), - 'tags': Ack.list(Ack.string()).optional(), - }).toSchemaModel(); - final json = model.toJsonSchema(); - - expect(SchemaModel.fromJsonSchema(json).toJsonSchema(), json); - }); - test('omits defaults that cannot be encoded through wrapped schema', () { final transformed = Ack.string() .transform((value) => int.parse(value)) @@ -77,7 +70,7 @@ void main() { 'age': Ack.integer().min(10).withDefault(5), }); - final model = schema.toSchemaModel() as ObjectSchemaModel; + final model = schema.toSchemaModel() as AckObjectSchemaModel; final json = model.toJsonSchema(); final properties = json['properties'] as Map; @@ -98,7 +91,7 @@ void main() { expect( schema.toJsonSchema(), equals(schema.toSchemaModel().toJsonSchema()), - reason: '${schema.runtimeType} should render from SchemaModel', + reason: '${schema.runtimeType} should render from AckSchemaModel', ); } @@ -154,9 +147,9 @@ void main() { }, ); - final model = schema.toSchemaModel() as AnyOfSchemaModel; - final branch = model.schemas.single as ObjectSchemaModel; - final discriminator = branch.properties!['type'] as StringSchemaModel; + final model = schema.toSchemaModel() as AckAnyOfSchemaModel; + final branch = model.schemas.single as AckObjectSchemaModel; + final discriminator = branch.properties!['type'] as AckStringSchemaModel; expect(model.discriminator!.propertyName, 'type'); expect(discriminator.constValue, 'cat'); @@ -180,9 +173,10 @@ void main() { }, ); - final model = schema.toSchemaModel() as AnyOfSchemaModel; - final branch = model.schemas.single as ObjectSchemaModel; - final discriminator = branch.properties!['type'] as StringSchemaModel; + final model = schema.toSchemaModel() as AckAnyOfSchemaModel; + final branch = model.schemas.single as AckObjectSchemaModel; + final discriminator = + branch.properties!['type'] as AckStringSchemaModel; expect(model.discriminator!.propertyName, 'type'); expect(discriminator.constValue, 'cat'); @@ -199,8 +193,8 @@ void main() { }, ); - final model = schema.toSchemaModel() as AnyOfSchemaModel; - final branch = model.schemas.single as ObjectSchemaModel; + final model = schema.toSchemaModel() as AckAnyOfSchemaModel; + final branch = model.schemas.single as AckObjectSchemaModel; expect(branch.properties!.keys, ['type', 'name']); expect(branch.required, ['type', 'name']); @@ -239,9 +233,10 @@ void main() { }, ); - final model = schema.toSchemaModel() as AnyOfSchemaModel; - final branch = model.schemas.single as ObjectSchemaModel; - final discriminator = branch.properties!['type'] as StringSchemaModel; + final model = schema.toSchemaModel() as AckAnyOfSchemaModel; + final branch = model.schemas.single as AckObjectSchemaModel; + final discriminator = + branch.properties!['type'] as AckStringSchemaModel; expect(discriminator.constValue, 'cat'); expect(branch.extensions['x-transformed'], isTrue); @@ -270,8 +265,8 @@ void main() { test('records Ack.any JSON export limitation as a warning', () { final model = Ack.any().toSchemaModel(); - expect(model, isA()); - expect((model as AnyOfSchemaModel).schemas, isNotEmpty); + expect(model, isA()); + expect((model as AckAnyOfSchemaModel).schemas, isNotEmpty); expect(model.warnings, hasLength(1)); expect(model.warnings.single.message, contains('JSON-safe values')); }); @@ -305,7 +300,7 @@ void main() { ) .toSchemaModel(); - expect((model as StringSchemaModel).minLength, isNull); + expect((model as AckStringSchemaModel).minLength, isNull); expect(model.extensions, {'minLength': 1.5}); expect(model.toJsonSchema()['minLength'], 1.5); }); diff --git a/packages/schema_model/test/schema_model_parser_test.dart b/packages/ack/test/schema_model/ack_schema_model_parser_test.dart similarity index 64% rename from packages/schema_model/test/schema_model_parser_test.dart rename to packages/ack/test/schema_model/ack_schema_model_parser_test.dart index d6709a14..ce2e2323 100644 --- a/packages/schema_model/test/schema_model_parser_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_parser_test.dart @@ -1,36 +1,36 @@ -import 'package:schema_model/schema_model.dart'; +import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { - group('SchemaModel.fromJsonSchema', () { + group('AckSchemaModel.fromJsonSchema', () { test('parses typed scalar keywords', () { - final string = SchemaModel.fromJsonSchema({ + final string = AckSchemaModel.fromJsonSchema({ 'type': 'string', 'format': 'email', 'minLength': 3, 'maxLength': 64, 'pattern': r'.+@.+', }); - final integer = SchemaModel.fromJsonSchema({ + final integer = AckSchemaModel.fromJsonSchema({ 'type': 'integer', 'minimum': 1, 'maximum': 10, 'multipleOf': 2, }); - final number = SchemaModel.fromJsonSchema({ + final number = AckSchemaModel.fromJsonSchema({ 'type': 'number', 'exclusiveMinimum': 0, 'exclusiveMaximum': 1, }); - final boolean = SchemaModel.fromJsonSchema({ + final boolean = AckSchemaModel.fromJsonSchema({ 'type': 'boolean', 'const': true, }); - expect(string, isA()); - expect(integer, isA()); - expect(number, isA()); - expect(boolean, isA()); + expect(string, isA()); + expect(integer, isA()); + expect(number, isA()); + expect(boolean, isA()); expect(string.toJsonSchema(), { 'type': 'string', 'format': 'email', @@ -55,7 +55,7 @@ void main() { test( 'parses arrays, objects, required fields, and additional properties', () { - final model = SchemaModel.fromJsonSchema({ + final model = AckSchemaModel.fromJsonSchema({ 'type': 'object', 'properties': { 'name': {'type': 'string'}, @@ -69,12 +69,15 @@ void main() { 'additionalProperties': {'type': 'string'}, }); - final object = model as ObjectSchemaModel; + final object = model as AckObjectSchemaModel; expect(object.propertyOrdering, ['name', 'tags']); - expect(object.properties!['name'], isA()); - expect(object.properties!['tags'], isA()); + expect(object.properties!['name'], isA()); + expect(object.properties!['tags'], isA()); expect(object.required, ['name']); - expect(object.additionalProperties, isA()); + expect( + object.additionalProperties, + isA(), + ); expect(model.toJsonSchema(), { 'type': 'object', 'properties': { @@ -104,9 +107,9 @@ void main() { ], }; - final model = SchemaModel.fromJsonSchema(json); + final model = AckSchemaModel.fromJsonSchema(json); - expect(model, isA()); + expect(model, isA()); expect(model.nullable, isTrue); expect(model.description, 'nickname'); expect(model.defaultValue, 'leo'); @@ -117,19 +120,19 @@ void main() { }); test('parses refs in bare and metadata-wrapped forms', () { - final bare = SchemaModel.fromJsonSchema({ + final bare = AckSchemaModel.fromJsonSchema({ r'$ref': '#/definitions/Foo~0Bar~1Baz', }); - final wrapped = SchemaModel.fromJsonSchema({ + final wrapped = AckSchemaModel.fromJsonSchema({ 'title': 'Node', 'allOf': [ {r'$ref': '#/definitions/Node'}, ], }); - expect((bare as RefSchemaModel).refName, 'Foo~Bar/Baz'); + expect((bare as AckRefSchemaModel).refName, 'Foo~Bar/Baz'); expect(bare.toJsonSchema(), {r'$ref': '#/definitions/Foo~0Bar~1Baz'}); - expect((wrapped as RefSchemaModel).refName, 'Node'); + expect((wrapped as AckRefSchemaModel).refName, 'Node'); expect(wrapped.title, 'Node'); expect(wrapped.toJsonSchema(), { 'title': 'Node', @@ -140,15 +143,15 @@ void main() { }); test('warns instead of throwing for unsupported shapes', () { - final unknown = SchemaModel.fromJsonSchema({'x-custom': true}); - final unsupportedRef = SchemaModel.fromJsonSchema({ + final unknown = AckSchemaModel.fromJsonSchema({'x-custom': true}); + final unsupportedRef = AckSchemaModel.fromJsonSchema({ r'$ref': 'https://example.com/schema.json', }); - final typeList = SchemaModel.fromJsonSchema({ + final typeList = AckSchemaModel.fromJsonSchema({ 'type': ['string', 'null'], 'minLength': 1, }); - final booleanItems = SchemaModel.fromJsonSchema({ + final booleanItems = AckSchemaModel.fromJsonSchema({ 'type': 'array', 'items': true, }); @@ -163,47 +166,47 @@ void main() { }); test('round-trips rendered JSON for model variants', () { - final corpus = [ - const StringSchemaModel( + final corpus = [ + const AckStringSchemaModel( format: 'email', minLength: 3, nullable: true, defaultValue: 'a@b.com', ), - const IntegerSchemaModel(minimum: 1, maximum: 10), - const NumberSchemaModel(multipleOf: 0.5), - const BooleanSchemaModel(constValue: false), - const ArraySchemaModel(items: StringSchemaModel(minLength: 1)), - const ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const AckIntegerSchemaModel(minimum: 1, maximum: 10), + const AckNumberSchemaModel(multipleOf: 0.5), + const AckBooleanSchemaModel(constValue: false), + const AckArraySchemaModel(items: AckStringSchemaModel(minLength: 1)), + const AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, required: ['name'], - additionalProperties: AdditionalPropertiesSchema( - IntegerSchemaModel(), + additionalProperties: AckAdditionalPropertiesSchema( + AckIntegerSchemaModel(), ), ), - const NullSchemaModel(title: 'Nothing'), - const AnyOfSchemaModel( - schemas: [StringSchemaModel(), IntegerSchemaModel()], + const AckNullSchemaModel(title: 'Nothing'), + const AckAnyOfSchemaModel( + schemas: [AckStringSchemaModel(), AckIntegerSchemaModel()], ), - const OneOfSchemaModel( + const AckOneOfSchemaModel( schemas: [ - StringSchemaModel(constValue: 'x'), - IntegerSchemaModel(), + AckStringSchemaModel(constValue: 'x'), + AckIntegerSchemaModel(), ], nullable: true, ), - const AllOfSchemaModel( + const AckAllOfSchemaModel( schemas: [ - StringSchemaModel(minLength: 2), - StringSchemaModel(maxLength: 8), + AckStringSchemaModel(minLength: 2), + AckStringSchemaModel(maxLength: 8), ], ), - const RefSchemaModel(refName: 'Node'), + const AckRefSchemaModel(refName: 'Node'), ]; for (final model in corpus) { final json = model.toJsonSchema(); - final parsed = SchemaModel.fromJsonSchema(json); + final parsed = AckSchemaModel.fromJsonSchema(json); expect( parsed.toJsonSchema(), diff --git a/packages/schema_model/test/schema_model_test.dart b/packages/ack/test/schema_model/ack_schema_model_test.dart similarity index 65% rename from packages/schema_model/test/schema_model_test.dart rename to packages/ack/test/schema_model/ack_schema_model_test.dart index ca40d115..f0d0ecb0 100644 --- a/packages/schema_model/test/schema_model_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_test.dart @@ -1,10 +1,10 @@ -import 'package:schema_model/schema_model.dart'; +import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { - group('SchemaModel sealed variants', () { + group('AckSchemaModel sealed variants', () { test('renders string constraints and const literals', () { - const model = StringSchemaModel( + const model = AckStringSchemaModel( constValue: 'cat', minLength: 3, maxLength: 12, @@ -21,15 +21,18 @@ void main() { }); test('keeps number and integer as distinct variants', () { - const integer = IntegerSchemaModel(minimum: 1); - const number = NumberSchemaModel(minimum: 1.5); + const integer = AckIntegerSchemaModel(minimum: 1); + const number = AckNumberSchemaModel(minimum: 1.5); expect(integer.toJsonSchema(), {'type': 'integer', 'minimum': 1}); expect(number.toJsonSchema(), {'type': 'number', 'minimum': 1.5}); }); test('wraps nullable primitive schemas without nullable keyword', () { - const model = StringSchemaModel(description: 'nickname', nullable: true); + const model = AckStringSchemaModel( + description: 'nickname', + nullable: true, + ); // Description is hoisted to the top level so generic JSON Schema // consumers can discover it without descending into anyOf branches. @@ -43,9 +46,9 @@ void main() { }); test('composition nullable adds one null branch', () { - const model = AnyOfSchemaModel( + const model = AckAnyOfSchemaModel( nullable: true, - schemas: [StringSchemaModel(), NullSchemaModel()], + schemas: [AckStringSchemaModel(), AckNullSchemaModel()], ); expect(model.toJsonSchema(), { @@ -57,10 +60,10 @@ void main() { }); test('renders object additional properties as one typed policy', () { - const model = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const model = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, required: ['name'], - additionalProperties: AdditionalPropertiesDisallowed(), + additionalProperties: AckAdditionalPropertiesDisallowed(), ); expect(model.toJsonSchema(), { @@ -76,8 +79,8 @@ void main() { test( 'renders explicit object required fields even with property default', () { - const model = ObjectSchemaModel( - properties: {'name': StringSchemaModel(defaultValue: 'guest')}, + const model = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel(defaultValue: 'guest')}, required: ['name'], ); @@ -92,10 +95,10 @@ void main() { ); test('renders allOf directly for adapter tests', () { - const model = AllOfSchemaModel( + const model = AckAllOfSchemaModel( schemas: [ - StringSchemaModel(minLength: 2), - StringSchemaModel(maxLength: 8), + AckStringSchemaModel(minLength: 2), + AckStringSchemaModel(maxLength: 8), ], ); @@ -108,18 +111,18 @@ void main() { }); test('uses structural warnings in equality and hashCode', () { - const left = StringSchemaModel( + const left = AckStringSchemaModel( warnings: [ - SchemaModelWarning( + AckSchemaModelWarning( code: 'test_warning', message: 'A test warning.', context: {'path': 'left'}, ), ], ); - const right = StringSchemaModel( + const right = AckStringSchemaModel( warnings: [ - SchemaModelWarning( + AckSchemaModelWarning( code: 'test_warning', message: 'A test warning.', context: {'path': 'left'}, @@ -132,17 +135,17 @@ void main() { }); test('uses non-rendered metadata in equality and hashCode', () { - const left = AnyOfSchemaModel( - schemas: [StringSchemaModel()], - discriminator: SchemaDiscriminatorModel(propertyName: 'type'), + const left = AckAnyOfSchemaModel( + schemas: [AckStringSchemaModel()], + discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), ); - const same = AnyOfSchemaModel( - schemas: [StringSchemaModel()], - discriminator: SchemaDiscriminatorModel(propertyName: 'type'), + const same = AckAnyOfSchemaModel( + schemas: [AckStringSchemaModel()], + discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), ); - const different = AnyOfSchemaModel( - schemas: [StringSchemaModel()], - discriminator: SchemaDiscriminatorModel(propertyName: 'kind'), + const different = AckAnyOfSchemaModel( + schemas: [AckStringSchemaModel()], + discriminator: AckSchemaDiscriminatorModel(propertyName: 'kind'), ); expect(left.toJsonSchema(), same.toJsonSchema()); @@ -151,22 +154,22 @@ void main() { expect(left.hashCode, same.hashCode); expect(left, isNot(different)); - const ordered = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const ordered = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, propertyOrdering: ['name'], ); - const unordered = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const unordered = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, ); expect(ordered.toJsonSchema(), unordered.toJsonSchema()); expect(ordered, isNot(unordered)); - const dateBounded = StringSchemaModel( + const dateBounded = AckStringSchemaModel( format: 'date', formatMinimum: '2026-01-01', ); - const dateUnbounded = StringSchemaModel(format: 'date'); + const dateUnbounded = AckStringSchemaModel(format: 'date'); expect(dateBounded.toJsonSchema(), dateUnbounded.toJsonSchema()); expect(dateBounded, isNot(dateUnbounded)); diff --git a/packages/ack/test/schema_model/legacy_alias_compat_test.dart b/packages/ack/test/schema_model/legacy_alias_compat_test.dart deleted file mode 100644 index 7942f5c5..00000000 --- a/packages/ack/test/schema_model/legacy_alias_compat_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package - -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -String _variantName(AckSchemaModel model) { - return switch (model) { - AckRefSchemaModel() => 'ref', - AckStringSchemaModel() => 'string', - AckIntegerSchemaModel() => 'integer', - AckNumberSchemaModel() => 'number', - AckBooleanSchemaModel() => 'boolean', - AckArraySchemaModel() => 'array', - AckObjectSchemaModel() => 'object', - AckNullSchemaModel() => 'null', - AckAnyOfSchemaModel() => 'anyOf', - AckOneOfSchemaModel() => 'oneOf', - AckAllOfSchemaModel() => 'allOf', - }; -} - -void main() { - group('legacy schema model aliases', () { - test('forward const constructors and type checks', () { - const schema = AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, - additionalProperties: AckAdditionalPropertiesSchema( - AckStringSchemaModel(), - ), - ); - - expect(schema, isA()); - expect(schema, isA()); - expect(schema.additionalProperties, isA()); - expect(schema.additionalProperties, isA()); - }); - - test('preserve type identity', () { - expect(AckObjectSchemaModel, ObjectSchemaModel); - expect(AckStringSchemaModel, StringSchemaModel); - expect(AckSchemaModelWarning, SchemaModelWarning); - expect(AckAdditionalPropertiesAllowed, AdditionalPropertiesAllowed); - }); - - test('preserve sealed exhaustiveness', () { - expect(_variantName(const AckRefSchemaModel(refName: 'Node')), 'ref'); - expect(_variantName(const AckStringSchemaModel()), 'string'); - expect(_variantName(const AckIntegerSchemaModel()), 'integer'); - expect(_variantName(const AckNumberSchemaModel()), 'number'); - expect(_variantName(const AckBooleanSchemaModel()), 'boolean'); - expect(_variantName(const AckArraySchemaModel()), 'array'); - expect(_variantName(const AckObjectSchemaModel()), 'object'); - expect(_variantName(const AckNullSchemaModel()), 'null'); - expect( - _variantName( - const AckAnyOfSchemaModel(schemas: [AckStringSchemaModel()]), - ), - 'anyOf', - ); - expect( - _variantName( - const AckOneOfSchemaModel(schemas: [AckStringSchemaModel()]), - ), - 'oneOf', - ); - expect( - _variantName( - const AckAllOfSchemaModel(schemas: [AckStringSchemaModel()]), - ), - 'allOf', - ); - }); - }); -} diff --git a/packages/ack/test/schemas/core_schema_test.dart b/packages/ack/test/schemas/core_schema_test.dart index f8b02f82..60ea92f4 100644 --- a/packages/ack/test/schemas/core_schema_test.dart +++ b/packages/ack/test/schemas/core_schema_test.dart @@ -199,5 +199,4 @@ void main() { }); }); }); - } diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 08265edb..1f11b9eb 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -205,7 +205,7 @@ void main() { ).refine((value) => true), }); - final model = categorySchema.toSchemaModel() as ObjectSchemaModel; + final model = categorySchema.toSchemaModel() as AckObjectSchemaModel; final child = model.properties!['child']!; expect(child.toJsonSchema(), {r'$ref': '#/definitions/Category'}); diff --git a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart index 8f5dd8ab..2350427d 100644 --- a/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart +++ b/packages/ack/test/schemas/nullable_constraint_json_schema_test.dart @@ -8,9 +8,9 @@ import 'package:test/test.dart'; /// Used to verify that constraints are properly merged in JSON Schema output /// for AnyOfSchema, which uses `AckSchema` and has no natural /// JsonSchemaSpec constraints. -class _TestSchemaModelConstraint extends Constraint +class _TestAckSchemaModelConstraint extends Constraint with Validator, JsonSchemaSpec { - const _TestSchemaModelConstraint() + const _TestAckSchemaModelConstraint() : super( constraintKey: 'test_marker', description: 'Test constraint for JSON Schema merging verification', @@ -105,7 +105,7 @@ void main() { final schema = Ack.anyOf([ Ack.string(), Ack.integer(), - ]).withConstraint(const _TestSchemaModelConstraint()); + ]).withConstraint(const _TestAckSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); @@ -118,7 +118,7 @@ void main() { final schema = Ack.anyOf([ Ack.string(), Ack.integer(), - ]).nullable().withConstraint(const _TestSchemaModelConstraint()); + ]).nullable().withConstraint(const _TestAckSchemaModelConstraint()); final jsonSchema = schema.toJsonSchema(); diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart index c70e58a8..9bc463df 100644 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -1,4 +1,5 @@ import 'package:ack/ack.dart'; +import 'package:schema_model/schema_model.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index 7bf09819..bdc35acc 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -18,7 +18,6 @@ - Default Firebase AI examples and live tests to `gemini-3.5-flash`. - Require `ack` `^1.0.0-beta.12-wip` for the sealed `AckSchemaModel` adapter boundary. -- Update schema-model fixture coverage to use the renamed `SchemaModel` API. - Keep `firebase_ai` and Flutter as explicit package dependencies so package tests and workspace orchestration use the Firebase AI SDK runtime. diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json index 6f309877..7670dec3 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.2", + "sourceVersion": "3.12.1", "sourceClass": "JSONSchema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json index e611fe70..3c8f007a 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.2", + "sourceVersion": "3.12.1", "sourceClass": "Schema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart index 9a7eb017..f4b761c3 100644 --- a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart +++ b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart @@ -176,7 +176,7 @@ Future _generateLiveFirebaseJson({ schemaCase.schema.toSchemaModel().toJsonSchema(), reason: 'Firebase live tests must exercise the canonical sealed ' - 'SchemaModel rendering path.', + 'AckSchemaModel rendering path.', ); final generationConfig = GenerationConfig( diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart index 0b8b2f60..2edc2bf1 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart @@ -55,7 +55,7 @@ final class SchemaModelResponseJsonSchemaCase required this.model, }); - final SchemaModel model; + final AckSchemaModel model; @override String get source => 'schema_model'; @@ -260,7 +260,7 @@ List firebaseAiResponseJsonSchemaCases() => [ 'pattern', 'extension', ], - model: StringSchemaModel( + model: AckStringSchemaModel( title: 'Status', description: 'Current status', format: 'custom-format', @@ -277,25 +277,25 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_integer_const_format', name: 'integer model const and format', features: ['integer', 'format', 'const'], - model: IntegerSchemaModel(format: 'int32', constValue: 7), + model: AckIntegerSchemaModel(format: 'int32', constValue: 7), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_number_const_format', name: 'number model const and format', features: ['number', 'format', 'const'], - model: NumberSchemaModel(format: 'double', constValue: 1.5), + model: AckNumberSchemaModel(format: 'double', constValue: 1.5), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_boolean_const', name: 'boolean model const', features: ['boolean', 'const'], - model: BooleanSchemaModel(constValue: true), + model: AckBooleanSchemaModel(constValue: true), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_nullable_default_extensions', name: 'nullable model default and extensions', features: ['string', 'nullable', 'anyOf', 'const', 'default', 'extension'], - model: StringSchemaModel( + model: AckStringSchemaModel( constValue: 'ready', nullable: true, defaultValue: 'ready', @@ -306,7 +306,7 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_array_without_item_schema', name: 'array model without item schema', features: ['array', 'minItems', 'maxItems'], - model: ArraySchemaModel(minItems: 0, maxItems: 2), + model: AckArraySchemaModel(minItems: 0, maxItems: 2), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_object_schema_additional_properties', @@ -319,34 +319,36 @@ List firebaseAiResponseJsonSchemaCases() => [ 'maxProperties', 'additionalPropertiesSchema', ], - model: ObjectSchemaModel( - properties: {'id': StringSchemaModel()}, + model: AckObjectSchemaModel( + properties: {'id': AckStringSchemaModel()}, required: ['id'], propertyOrdering: ['id'], minProperties: 1, maxProperties: 3, - additionalProperties: AdditionalPropertiesSchema(StringSchemaModel()), + additionalProperties: AckAdditionalPropertiesSchema( + AckStringSchemaModel(), + ), ), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_null', name: 'null model', features: ['null', 'title'], - model: NullSchemaModel(title: 'Nothing'), + model: AckNullSchemaModel(title: 'Nothing'), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_anyof_common_fields_explicit_null', name: 'anyOf model common fields and explicit null branch', features: ['anyOf', 'nullable', 'default', 'extension', 'null'], - model: AnyOfSchemaModel( + model: AckAnyOfSchemaModel( title: 'Flexible value', defaultValue: 'fallback', nullable: true, extensions: {'x-ack-test': true}, schemas: [ - StringSchemaModel(minLength: 1), - IntegerSchemaModel(minimum: 1), - NullSchemaModel(), + AckStringSchemaModel(minLength: 1), + AckIntegerSchemaModel(minimum: 1), + AckNullSchemaModel(), ], ), ), @@ -354,11 +356,11 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_oneof_nullable_composition', name: 'oneOf model nullable composition', features: ['oneOf', 'nullable', 'const', 'null'], - model: OneOfSchemaModel( + model: AckOneOfSchemaModel( nullable: true, schemas: [ - StringSchemaModel(constValue: 'ready'), - IntegerSchemaModel(minimum: 1), + AckStringSchemaModel(constValue: 'ready'), + AckIntegerSchemaModel(minimum: 1), ], ), ), @@ -366,38 +368,38 @@ List firebaseAiResponseJsonSchemaCases() => [ id: 'schema_model_oneof_discriminator', name: 'oneOf model discriminator metadata', features: ['oneOf', 'const'], - model: OneOfSchemaModel( + model: AckOneOfSchemaModel( schemas: [ - ObjectSchemaModel( + AckObjectSchemaModel( properties: { - 'type': StringSchemaModel(constValue: 'email'), - 'address': StringSchemaModel(format: 'email'), + 'type': AckStringSchemaModel(constValue: 'email'), + 'address': AckStringSchemaModel(format: 'email'), }, required: ['type', 'address'], ), - ObjectSchemaModel( + AckObjectSchemaModel( properties: { - 'type': StringSchemaModel(constValue: 'sms'), - 'number': StringSchemaModel(), + 'type': AckStringSchemaModel(constValue: 'sms'), + 'number': AckStringSchemaModel(), }, required: ['type', 'number'], ), ], - discriminator: SchemaDiscriminatorModel(propertyName: 'type'), + discriminator: AckSchemaDiscriminatorModel(propertyName: 'type'), ), ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_allof', name: 'allOf model', features: ['allOf'], - model: AllOfSchemaModel( + model: AckAllOfSchemaModel( schemas: [ - ObjectSchemaModel( - properties: {'id': StringSchemaModel()}, + AckObjectSchemaModel( + properties: {'id': AckStringSchemaModel()}, required: ['id'], ), - ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, required: ['name'], ), ], diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index 040f2aa9..293a7dd9 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -8,8 +8,6 @@ metadata, and composition. * Require `ack` `^1.0.0-beta.12-wip` for the sealed `AckSchemaModel` adapter boundary. -* Accept the renamed `SchemaModel` type from `package:schema_model` in - `convertAckSchemaModelToBuilder` while keeping the existing function name. ### Removed diff --git a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart index 8da36b2d..d4ad46e6 100644 --- a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart +++ b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart @@ -33,9 +33,9 @@ extension JsonSchemaBuilderExtension on AckSchema { } } -/// Converts a [SchemaModel] model directly to json_schema_builder [Schema] format. +/// Converts a [AckSchemaModel] model directly to json_schema_builder [Schema] format. /// -/// This is useful for testing or when you have a pre-built SchemaModel model. -jsb.Schema convertAckSchemaModelToBuilder(SchemaModel schema) { +/// This is useful for testing or when you have a pre-built AckSchemaModel model. +jsb.Schema convertAckSchemaModelToBuilder(AckSchemaModel schema) { return jsb.Schema.fromMap(schema.toJsonSchema()); } diff --git a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart index 5900eb2b..4f019276 100644 --- a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart @@ -302,7 +302,7 @@ void main() { }); test('preserves model defaults, const values, and extensions', () { - const schema = BooleanSchemaModel( + const schema = AckBooleanSchemaModel( constValue: true, defaultValue: true, extensions: { @@ -565,9 +565,9 @@ void main() { }); test('additionalProperties schema converts recursively', () { - const schema = ObjectSchemaModel( - additionalProperties: AdditionalPropertiesSchema( - StringSchemaModel(minLength: 1), + const schema = AckObjectSchemaModel( + additionalProperties: AckAdditionalPropertiesSchema( + AckStringSchemaModel(minLength: 1), ), ); @@ -581,10 +581,10 @@ void main() { group('allOf composition', () { test('allOf converts to allOf in json_schema_builder', () { - const jsonSchema = AllOfSchemaModel( + const jsonSchema = AckAllOfSchemaModel( schemas: [ - ObjectSchemaModel(properties: {'name': StringSchemaModel()}), - ObjectSchemaModel(properties: {'age': IntegerSchemaModel()}), + AckObjectSchemaModel(properties: {'name': AckStringSchemaModel()}), + AckObjectSchemaModel(properties: {'age': AckIntegerSchemaModel()}), ], ); @@ -608,10 +608,10 @@ void main() { }); test('allOf preserves branch schemas', () { - const jsonSchema = AllOfSchemaModel( + const jsonSchema = AckAllOfSchemaModel( schemas: [ - StringSchemaModel(minLength: 5), - StringSchemaModel(maxLength: 10), + AckStringSchemaModel(minLength: 5), + AckStringSchemaModel(maxLength: 10), ], ); @@ -627,8 +627,8 @@ void main() { group('Object property count constraints', () { test('minProperties is preserved in conversion', () { - const jsonSchema = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const jsonSchema = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, minProperties: 2, ); @@ -638,8 +638,8 @@ void main() { }); test('maxProperties is preserved in conversion', () { - const jsonSchema = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const jsonSchema = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, maxProperties: 5, ); @@ -649,8 +649,8 @@ void main() { }); test('both minProperties and maxProperties together', () { - const jsonSchema = ObjectSchemaModel( - properties: {'name': StringSchemaModel()}, + const jsonSchema = AckObjectSchemaModel( + properties: {'name': AckStringSchemaModel()}, minProperties: 1, maxProperties: 10, ); diff --git a/packages/schema_model/CHANGELOG.md b/packages/schema_model/CHANGELOG.md index 3f2b8746..525d9239 100644 --- a/packages/schema_model/CHANGELOG.md +++ b/packages/schema_model/CHANGELOG.md @@ -1,10 +1,7 @@ -## Unreleased +## 1.0.0-beta.12-wip ### Added -- Add the vendor-neutral `SchemaModel` description tree, moved from ACK's - former `AckSchemaModel`. -- Add `SchemaModel.fromJsonSchema()` for best-effort Draft-7 import and - JSON-level round trips from rendered schema models. -- Add the `StandardSchema` validation contract, standard results/issues, and - JSON Schema converter option types. +- Add the `StandardSchema` validation contract, standard results/issues, + validation options, JSON Schema converter option types, and JSON Schema target + constants. diff --git a/packages/schema_model/README.md b/packages/schema_model/README.md index 1aceea6a..f7a06fd7 100644 --- a/packages/schema_model/README.md +++ b/packages/schema_model/README.md @@ -1,27 +1,58 @@ # schema_model -Vendor-neutral schema model and standard schema contracts for Dart. +Standard Schema contracts for Dart validators and converters. -`schema_model` has two layers: +`schema_model` is a Dart port of the contracts described by +[standardschema.dev](https://standardschema.dev). It defines the small +`StandardSchema` surface that validation libraries can implement and consumers +can call without depending on a vendor-specific schema tree. -- `SchemaModel`: a structured description tree that can render Draft-7 JSON - Schema and import JSON Schema with `SchemaModel.fromJsonSchema`. -- `StandardSchema`: a small validation contract for libraries that want to - expose `validate(value)` results and optional JSON Schema converters. +The package intentionally does not define a schema model, parser, renderer, or +warning system. Libraries keep those implementation details in their own +packages and expose this contract at their boundary. + +## Implement a schema ```dart import 'package:schema_model/schema_model.dart'; -final model = SchemaModel.fromJsonSchema({ - 'type': 'object', - 'properties': { - 'name': {'type': 'string', 'minLength': 2}, - }, - 'required': ['name'], -}); +final class RequiredStringSchema implements StandardSchema { + const RequiredStringSchema(); + + @override + StandardSchemaProps get standard => StandardSchemaProps( + vendor: 'example', + validate: (value, [options]) { + if (value is String && value.isNotEmpty) { + return StandardSuccess(value); + } + + return const StandardFailure([ + StandardIssue(message: 'Expected a non-empty string'), + ]); + }, + ); +} +``` + +## Expose JSON Schema conversion + +```dart +final schema = StandardSchemaProps( + vendor: 'example', + validate: (value, [options]) => const StandardSuccess('value'), + jsonSchema: StandardJsonSchemaConverter( + input: (options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('Only Draft-7 is supported.'); + } -final jsonSchema = model.toJsonSchema(); + return {'type': 'string'}; + }, + output: (options) => {'type': 'string'}, + ), +); ``` -Validator packages can implement `StandardSchema` and expose -their contract through `standard`. +Converters return plain JSON Schema maps (`Map`). They may +throw when a schema cannot be represented for the requested target. diff --git a/packages/schema_model/lib/schema_model.dart b/packages/schema_model/lib/schema_model.dart index e5ab92f7..77774fbb 100644 --- a/packages/schema_model/lib/schema_model.dart +++ b/packages/schema_model/lib/schema_model.dart @@ -1,6 +1,4 @@ -/// Vendor-neutral schema model and standard schema contracts for Dart. +/// Standard Schema contracts for Dart validators and converters. library; -export 'src/schema_model.dart'; -export 'src/schema_model_warning.dart'; export 'src/standard_schema.dart'; diff --git a/packages/schema_model/lib/src/standard_schema.dart b/packages/schema_model/lib/src/standard_schema.dart index 1a560651..4d886632 100644 --- a/packages/schema_model/lib/src/standard_schema.dart +++ b/packages/schema_model/lib/src/standard_schema.dart @@ -1,10 +1,34 @@ import 'dart:async'; +/// A schema that conforms to the Standard Schema spec. +/// +/// Implementers expose a single [standard] property carrying the validate tier +/// (always) and, optionally, the JSON Schema tier. Port of `StandardSchemaV1` +/// from [standardschema.dev](https://standardschema.dev); the `~standard` key +/// is spelled [standard] (the tilde is a JS autocomplete hack) and the phantom +/// `types` field is dropped because Dart generics carry that information. abstract interface class StandardSchema { - /// Standard schema properties. + /// The Standard Schema properties. StandardSchemaProps get standard; } +/// Validates an unknown value, synchronously or asynchronously. +/// +/// Mirrors the spec's `validate(value, options?) => Result | Promise`. +typedef StandardValidate = + FutureOr> Function( + Object? value, [ + StandardValidateOptions? options, + ]); + +/// Converts one side (input or output) of a schema to a JSON Schema map. +/// +/// May throw for schemas that cannot be represented, or for unsupported +/// [StandardJsonSchemaOptions.target] versions (both permitted by the spec). +typedef StandardJsonSchemaConvert = + Map Function(StandardJsonSchemaOptions options); + +/// The properties of a [StandardSchema]. final class StandardSchemaProps { const StandardSchemaProps({ required this.vendor, @@ -13,64 +37,107 @@ final class StandardSchemaProps { this.jsonSchema, }); + /// The vendor name of the schema library. final String vendor; + + /// The version of the standard. Always `1` for this spec revision (Dart + /// cannot pin the literal type the way TypeScript's `version: 1` does). final int version; - final FutureOr> Function( - Object? value, [ - StandardValidateOptions? options, - ]) - validate; + + /// Validates an unknown input value. + final StandardValidate validate; + + /// The JSON Schema tier converter, or `null` if this vendor does not + /// implement the JSON Schema spec. final StandardJsonSchemaConverter? jsonSchema; } +/// Optional parameters passed to [StandardValidate]. final class StandardValidateOptions { const StandardValidateOptions({this.libraryOptions}); + /// Vendor-specific parameters, if any. final Map? libraryOptions; } +/// The result of validation: either [StandardSuccess] or [StandardFailure]. sealed class StandardResult { const StandardResult(); } +/// A successful validation result carrying the typed [value]. final class StandardSuccess extends StandardResult { const StandardSuccess(this.value); + /// The validated output value. final Output value; } +/// A failed validation result carrying one or more [issues]. final class StandardFailure extends StandardResult { const StandardFailure(this.issues); + /// The issues describing why validation failed. final List issues; } +/// A single validation issue. final class StandardIssue { const StandardIssue({required this.message, this.path = const []}); + /// The error message of the issue. final String message; + + /// The path to the offending value, as `PropertyKey` segments: a [String] + /// object key or an [int] list index. Empty for a root-level issue. final List path; } +/// The JSON Schema tier converter (`StandardJSONSchemaV1`). +/// +/// The [input]/[output] split exists because validators transform: [input] +/// describes the value accepted (the boundary side), [output] the value +/// produced (the runtime side). final class StandardJsonSchemaConverter { const StandardJsonSchemaConverter({ required this.input, required this.output, }); - final Map Function(StandardJsonSchemaOptions options) input; - final Map Function(StandardJsonSchemaOptions options) output; + /// Converts the input type to a JSON Schema map. + final StandardJsonSchemaConvert input; + + /// Converts the output type to a JSON Schema map. + final StandardJsonSchemaConvert output; } +/// Options for the JSON Schema converter methods. final class StandardJsonSchemaOptions { const StandardJsonSchemaOptions({required this.target, this.libraryOptions}); - final String target; + /// The target JSON Schema dialect. See [JsonSchemaTarget]. + final JsonSchemaTarget target; + + /// Vendor-specific parameters, if any. final Map? libraryOptions; } -abstract final class JsonSchemaTarget { - static const draft202012 = 'draft-2020-12'; - static const draft07 = 'draft-07'; - static const openapi30 = 'openapi-3.0'; +/// The target version of the generated JSON Schema. +/// +/// Mirrors the spec's +/// `Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (string & {})`: a +/// zero-cost extension type over [String] where the constants are the +/// recommended targets, but any string is accepted via the constructor +/// (`JsonSchemaTarget('my-target')`). Because it `implements String`, a +/// `JsonSchemaTarget` is usable wherever a `String` is and compares equal to +/// its underlying value. +extension type const JsonSchemaTarget(String value) implements String { + /// JSON Schema Draft 2020-12. + static const draft202012 = JsonSchemaTarget('draft-2020-12'); + + /// JSON Schema Draft 7. + static const draft07 = JsonSchemaTarget('draft-07'); + + /// OpenAPI 3.0 (a superset of JSON Schema Draft 4). + static const openapi30 = JsonSchemaTarget('openapi-3.0'); } diff --git a/packages/schema_model/pubspec.yaml b/packages/schema_model/pubspec.yaml index 2f5eb337..e075d13b 100644 --- a/packages/schema_model/pubspec.yaml +++ b/packages/schema_model/pubspec.yaml @@ -1,5 +1,5 @@ name: schema_model -description: Vendor-neutral schema model and standard schema contracts for Dart +description: Standard Schema contracts for Dart validators and converters version: 1.0.0-beta.12-wip repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues @@ -10,10 +10,6 @@ resolution: workspace environment: sdk: '>=3.8.0 <4.0.0' -dependencies: - collection: ^1.18.0 - meta: ^1.15.0 - dev_dependencies: lints: ^5.0.0 test: ^1.25.15 From e758e41eea97081760a98847c046254723bc2932 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 17:09:50 -0400 Subject: [PATCH 03/13] feat(schema-model): align with official split Standard Schema contracts Split the StandardSchema contract into StandardTyped, StandardSchema, StandardJsonSchema, and StandardSchemaWithJsonSchema to match the official Standard Schema family. AckSchema now implements the combined StandardSchemaWithJsonSchema contract. Introduce a typed SchemaPathSegment so standard issue paths distinguish string object keys from integer list indexes, preserving numeric-looking object keys as strings. --- packages/ack/CHANGELOG.md | 12 +- packages/ack/lib/src/context.dart | 66 ++++++++-- .../ack/lib/src/schemas/any_of_schema.dart | 4 +- .../schemas/discriminated_object_schema.dart | 12 +- packages/ack/lib/src/schemas/list_schema.dart | 4 +- .../ack/lib/src/schemas/object_schema.dart | 16 +-- packages/ack/lib/src/schemas/schema.dart | 41 ++++--- .../standard_schema_conformance_test.dart | 59 +++++++-- packages/schema_model/CHANGELOG.md | 8 +- packages/schema_model/README.md | 79 +++++++++--- .../schema_model/lib/src/standard_schema.dart | 114 +++++++++++++----- .../test/standard_schema_test.dart | 87 +++++++++---- 12 files changed, 371 insertions(+), 131 deletions(-) diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index dd8cf313..dfb50654 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -9,6 +9,9 @@ * Replace the interim JSON Schema model kind API with sealed `AckSchemaModel` variants and canonical `AckSchema.toSchemaModel()` adapter conversion. +* `SchemaContext.createChild(pathSegment: ...)` now takes a + `SchemaPathSegment` so standard issue paths can distinguish string object + keys from integer list indexes. ### Added @@ -20,11 +23,12 @@ * `NumberSchemaExtensions` adds fluent numeric constraints to `Ack.number()`: `.min`, `.max`, `.greaterThan`, `.lessThan`, `.positive`, `.negative`, and `.multipleOf`. -* `AckSchema.standard` implements the `StandardSchema` validation contract with - flat `StandardIssue` failures and a Draft-7 JSON Schema converter. +* `AckSchema.standard` implements the combined `StandardSchemaWithJsonSchema` + contract with flat `StandardIssue` failures and a Draft-7 JSON Schema + converter. * `SchemaContext.pathSegments` exposes raw path segments with list indices as - integers, and `SchemaError.toStandardIssues()` maps ACK errors to standard - issues. + integers while preserving numeric-looking object keys as strings, and + `SchemaError.toStandardIssues()` maps ACK errors to standard issues. * `AckSchemaModel.fromJsonSchema(...)` imports supported Draft-7 JSON Schema maps into ACK's existing schema model as a best-effort Ack feature. diff --git a/packages/ack/lib/src/context.dart b/packages/ack/lib/src/context.dart index 6b2cea8a..2ddae671 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -2,6 +2,48 @@ import 'package:meta/meta.dart'; import 'schemas/schema.dart'; +enum _SchemaPathSegmentKind { property, listIndex, passThrough } + +/// A typed path segment used by [SchemaContext]. +/// +/// String object keys and integer list indexes must stay distinct for standard +/// issue paths. Use [SchemaPathSegment.passThrough] for composition branches +/// that should not add a user-visible path segment. +@immutable +final class SchemaPathSegment { + const SchemaPathSegment.property(String key) + : _kind = _SchemaPathSegmentKind.property, + _value = key; + + const SchemaPathSegment.index(int index) + : assert(index >= 0, 'List path indexes must be non-negative.'), + _kind = _SchemaPathSegmentKind.listIndex, + _value = index; + + const SchemaPathSegment.passThrough() + : _kind = _SchemaPathSegmentKind.passThrough, + _value = null; + + final _SchemaPathSegmentKind _kind; + final Object? _value; + + Object? get _issueValue { + return switch (_kind) { + _SchemaPathSegmentKind.property => _value as String, + _SchemaPathSegmentKind.listIndex => _value as int, + _SchemaPathSegmentKind.passThrough => null, + }; + } + + String? get _jsonPointerValue { + return switch (_kind) { + _SchemaPathSegmentKind.property => _value as String, + _SchemaPathSegmentKind.listIndex => (_value as int).toString(), + _SchemaPathSegmentKind.passThrough => null, + }; + } +} + /// Represents the context in which a schema operation is occurring. @immutable class SchemaContext { @@ -9,7 +51,7 @@ class SchemaContext { final Object? value; final AnyAckSchema schema; final SchemaContext? parent; - final String? pathSegment; + final SchemaPathSegment? pathSegment; final SchemaOperation operation; const SchemaContext({ @@ -34,12 +76,13 @@ class SchemaContext { final parentPath = parent!.path; - if (pathSegment == '') { + final segment = pathSegment ?? SchemaPathSegment.property(name); + final pointerValue = segment._jsonPointerValue; + if (pointerValue == null) { return parentPath; } - final segment = pathSegment ?? name; - final escapedSegment = _escapeJsonPointerSegment(segment); + final escapedSegment = _escapeJsonPointerSegment(pointerValue); return parentPath == '#' ? '#/$escapedSegment' @@ -48,16 +91,15 @@ class SchemaContext { /// Raw path segments from root to this context. /// - /// Numeric segments are exposed as integer indices to match the standard - /// schema path shape. Empty path segments are branch pass-through markers and - /// do not add to the path. + /// Object keys are exposed as strings and list indexes as integers. Branch + /// pass-through segments do not add to the path. List get pathSegments { final parentSegments = parent?.pathSegments ?? const []; - if (parent == null || pathSegment == '') return parentSegments; + if (parent == null) return parentSegments; - final segment = pathSegment ?? name; - final index = int.tryParse(segment); - final pathValue = index != null && index >= 0 ? index : segment; + final segment = pathSegment ?? SchemaPathSegment.property(name); + final pathValue = segment._issueValue; + if (pathValue == null) return parentSegments; return [...parentSegments, pathValue]; } @@ -68,7 +110,7 @@ class SchemaContext { required String name, required AnyAckSchema schema, required Object? value, - String? pathSegment, + SchemaPathSegment? pathSegment, SchemaOperation? operation, }) { return SchemaContext( diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index ec853150..0d15dbce 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -60,7 +60,7 @@ final class AnyOfSchema extends AckSchema name: 'anyOf:$index', schema: schema, value: value, - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), ); final result = parse ? schema.parseWithContext(value, childContext) @@ -99,7 +99,7 @@ final class AnyOfSchema extends AckSchema name: 'anyOf:$index', schema: schema, value: runtime, - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 751bc435..9e9c2368 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -95,7 +95,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: null, - pathSegment: discriminatorKey, + pathSegment: SchemaPathSegment.property(discriminatorKey), ), ), ); @@ -114,7 +114,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: discValueRaw, - pathSegment: discriminatorKey, + pathSegment: SchemaPathSegment.property(discriminatorKey), ), ), ); @@ -133,7 +133,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: discValueRaw, - pathSegment: discriminatorKey, + pathSegment: SchemaPathSegment.property(discriminatorKey), ), ), ); @@ -249,7 +249,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: mapValue, - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), ); final result = effective.parseWithContext(mapValue, subSchemaContext); @@ -305,7 +305,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: runtime, - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), operation: SchemaOperation.encode, ); @@ -341,7 +341,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: runtime, - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), operation: SchemaOperation.encode, ); diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index 4f95b109..129f356c 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -73,7 +73,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: '$i', + pathSegment: SchemaPathSegment.index(i), ); final r = parse ? itemSchema.parseWithContext(item, itemCtx) @@ -125,7 +125,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: '$i', + pathSegment: SchemaPathSegment.index(i), operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index d3070415..20895134 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -66,7 +66,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ); schema .resolveDefaultWithContext(childCtx) @@ -88,7 +88,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ), ), ); @@ -102,7 +102,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: propertyValue, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ); schema .parseWithContext(propertyValue, propertyCtx) @@ -133,7 +133,7 @@ final class ObjectSchema extends AckSchema name: key, schema: this, value: mapValue[key], - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ), ), ); @@ -188,7 +188,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ); if (isEncode) { errors.add( @@ -215,7 +215,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: propertyValue, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ); if (propertyValue == null) { @@ -244,7 +244,7 @@ final class ObjectSchema extends AckSchema name: key, schema: this, value: mapValue[key], - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), ); if (isEncode) { errors.add( @@ -299,7 +299,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: hasValue ? value[key] : null, - pathSegment: key, + pathSegment: SchemaPathSegment.property(key), operation: SchemaOperation.encode, ); final Object? propertyValue; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 718911bc..5a366d55 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -5,8 +5,8 @@ import 'package:schema_model/schema_model.dart' JsonSchemaTarget, StandardFailure, StandardJsonSchemaConverter, - StandardSchema, - StandardSchemaProps, + StandardSchemaWithJsonSchema, + StandardSchemaWithJsonSchemaProps, StandardSuccess; import '../common_types.dart'; @@ -72,7 +72,7 @@ enum SchemaOperation { parse, encode } /// the public wrappers. @immutable abstract class AckSchema - implements StandardSchema { + implements StandardSchemaWithJsonSchema { final bool isNullable; final bool isOptional; final String? description; @@ -351,23 +351,24 @@ abstract class AckSchema } @override - StandardSchemaProps get standard => StandardSchemaProps( - vendor: 'ack', - validate: (value, [options]) => switch (safeParse(value)) { - Ok(value: final value) => StandardSuccess(value), - Fail(error: final error) => StandardFailure( - error.toStandardIssues(), - ), - }, - jsonSchema: StandardJsonSchemaConverter( - input: (options) => _renderJsonSchema(options.target), - output: (options) => this is CodecSchema - ? throw UnsupportedError( - 'codec Runtime side is not JSON-representable', - ) - : _renderJsonSchema(options.target), - ), - ); + StandardSchemaWithJsonSchemaProps get standard => + StandardSchemaWithJsonSchemaProps( + vendor: 'ack', + validate: (value, [options]) => switch (safeParse(value)) { + Ok(value: final value) => StandardSuccess(value), + Fail(error: final error) => StandardFailure( + error.toStandardIssues(), + ), + }, + jsonSchema: StandardJsonSchemaConverter( + input: (options) => _renderJsonSchema(options.target), + output: (options) => this is CodecSchema + ? throw UnsupportedError( + 'codec Runtime side is not JSON-representable', + ) + : _renderJsonSchema(options.target), + ), + ); /// Renders this schema as a Draft-7 JSON Schema map, throwing for any other /// [target] (ack only supports Draft-7 — spec permits throwing for diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart index 9bc463df..bcd08898 100644 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -5,7 +5,10 @@ import 'package:test/test.dart'; void main() { group('StandardSchema conformance', () { test('AckSchema implements the standard schema contract', () { - expect(Ack.string(), isA>()); + expect( + Ack.string(), + isA>(), + ); }); test( @@ -38,33 +41,75 @@ void main() { }, ); - test('SchemaContext.pathSegments mirrors JSON pointer path semantics', () { + test('SchemaContext.pathSegments preserves typed segment identity', () { final root = SchemaContext(name: 'root', schema: Ack.string(), value: {}); final property = root.createChild( name: 'tags', schema: Ack.list(Ack.string()), value: const ['x'], - pathSegment: 'tags', + pathSegment: const SchemaPathSegment.property('tags'), ); final index = property.createChild( name: '1', schema: Ack.string(), value: 'x', - pathSegment: '1', + pathSegment: const SchemaPathSegment.index(1), + ); + final numericProperty = root.createChild( + name: '1', + schema: Ack.string(), + value: 'x', + pathSegment: const SchemaPathSegment.property('1'), + ); + final emptyProperty = root.createChild( + name: '', + schema: Ack.string(), + value: 'x', + pathSegment: const SchemaPathSegment.property(''), ); final passThrough = index.createChild( name: 'anyOf', schema: Ack.string(), value: 'x', - pathSegment: '', + pathSegment: const SchemaPathSegment.passThrough(), ); expect(root.pathSegments, const []); expect(property.pathSegments, ['tags']); expect(index.pathSegments, ['tags', 1]); + expect(numericProperty.pathSegments, ['1']); + expect(emptyProperty.pathSegments, ['']); + expect(emptyProperty.path, '#/'); expect(passThrough.pathSegments, ['tags', 1]); }); + test( + 'standard issues distinguish numeric object keys from list indexes', + () { + final objectResult = Ack.object({ + '1': Ack.string().minLength(2), + }).standard.validate({'1': 'x'}); + final listResult = Ack.list( + Ack.string().minLength(2), + ).standard.validate(['ok', 'x']); + final emptyKeyResult = Ack.object({ + '': Ack.string().minLength(2), + }).standard.validate({'': 'x'}); + + expect((objectResult as StandardFailure).issues.single.path, [ + '1', + ]); + expect( + (listResult as StandardFailure?>).issues.single.path, + [1], + ); + expect( + (emptyKeyResult as StandardFailure).issues.single.path, + [''], + ); + }, + ); + test('SchemaError.toStandardIssues flattens direct and nested errors', () { final direct = Ack.string().minLength(2).safeParse('x').getError(); final nested = Ack.object({ @@ -84,7 +129,7 @@ void main() { test('standard JSON Schema converter delegates to Draft-7 rendering', () { final schema = Ack.string().minLength(2); - final converter = schema.standard.jsonSchema!; + final converter = schema.standard.jsonSchema; const draft7 = StandardJsonSchemaOptions( target: JsonSchemaTarget.draft07, ); @@ -101,7 +146,7 @@ void main() { test('standard output JSON Schema throws for codecs', () { final codec = Ack.string().transform((value) => int.parse(value)); - final converter = codec.standard.jsonSchema!; + final converter = codec.standard.jsonSchema; expect( () => converter.output( diff --git a/packages/schema_model/CHANGELOG.md b/packages/schema_model/CHANGELOG.md index 525d9239..1de5b207 100644 --- a/packages/schema_model/CHANGELOG.md +++ b/packages/schema_model/CHANGELOG.md @@ -2,6 +2,8 @@ ### Added -- Add the `StandardSchema` validation contract, standard results/issues, - validation options, JSON Schema converter option types, and JSON Schema target - constants. +- Add the `StandardTyped`, `StandardSchema`, `StandardJsonSchema`, and + `StandardSchemaWithJsonSchema` contracts, aligned with the official split + Standard Schema family. +- Add standard results/issues, validation options, JSON Schema converter option + types, and JSON Schema target constants. diff --git a/packages/schema_model/README.md b/packages/schema_model/README.md index f7a06fd7..6ac4427f 100644 --- a/packages/schema_model/README.md +++ b/packages/schema_model/README.md @@ -2,10 +2,11 @@ Standard Schema contracts for Dart validators and converters. -`schema_model` is a Dart port of the contracts described by -[standardschema.dev](https://standardschema.dev). It defines the small -`StandardSchema` surface that validation libraries can implement and consumers -can call without depending on a vendor-specific schema tree. +`schema_model` is a Dart port of the contract family described by +[standardschema.dev](https://standardschema.dev). It defines small +`StandardTyped`, `StandardSchema`, and `StandardJsonSchema` surfaces that +libraries can implement and consumers can call without depending on a +vendor-specific schema tree. The package intentionally does not define a schema model, parser, renderer, or warning system. Libraries keep those implementation details in their own @@ -38,21 +39,65 @@ final class RequiredStringSchema implements StandardSchema { ## Expose JSON Schema conversion ```dart -final schema = StandardSchemaProps( - vendor: 'example', - validate: (value, [options]) => const StandardSuccess('value'), - jsonSchema: StandardJsonSchemaConverter( - input: (options) { - if (options.target != JsonSchemaTarget.draft07) { - throw UnsupportedError('Only Draft-7 is supported.'); - } +import 'package:schema_model/schema_model.dart'; - return {'type': 'string'}; - }, - output: (options) => {'type': 'string'}, - ), -); +final class RequiredStringJsonSchema + implements StandardJsonSchema { + const RequiredStringJsonSchema(); + + @override + StandardJsonSchemaProps get standard => + StandardJsonSchemaProps( + vendor: 'example', + jsonSchema: StandardJsonSchemaConverter( + input: (options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('Only Draft-7 is supported.'); + } + + return {'type': 'string'}; + }, + output: (options) => {'type': 'string'}, + ), + ); +} ``` Converters return plain JSON Schema maps (`Map`). They may throw when a schema cannot be represented for the requested target. + +## Implement both traits + +```dart +import 'package:schema_model/schema_model.dart'; + +final class RequiredStringSchemaWithJson + implements StandardSchemaWithJsonSchema { + const RequiredStringSchemaWithJson(); + + @override + StandardSchemaWithJsonSchemaProps get standard => + StandardSchemaWithJsonSchemaProps( + vendor: 'example', + validate: (value, [options]) { + if (value is String && value.isNotEmpty) { + return StandardSuccess(value); + } + + return const StandardFailure([ + StandardIssue(message: 'Expected a non-empty string'), + ]); + }, + jsonSchema: StandardJsonSchemaConverter( + input: (options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('Only Draft-7 is supported.'); + } + + return {'type': 'string'}; + }, + output: (options) => {'type': 'string'}, + ), + ); +} +``` diff --git a/packages/schema_model/lib/src/standard_schema.dart b/packages/schema_model/lib/src/standard_schema.dart index 4d886632..495d8490 100644 --- a/packages/schema_model/lib/src/standard_schema.dart +++ b/packages/schema_model/lib/src/standard_schema.dart @@ -1,17 +1,48 @@ import 'dart:async'; -/// A schema that conforms to the Standard Schema spec. +/// An entity that exposes Standard Schema family metadata. /// -/// Implementers expose a single [standard] property carrying the validate tier -/// (always) and, optionally, the JSON Schema tier. Port of `StandardSchemaV1` -/// from [standardschema.dev](https://standardschema.dev); the `~standard` key -/// is spelled [standard] (the tilde is a JS autocomplete hack) and the phantom -/// `types` field is dropped because Dart generics carry that information. -abstract interface class StandardSchema { - /// The Standard Schema properties. +/// This is the Dart spelling of upstream `StandardTypedV1`: the upstream +/// `~standard` key is exposed as [standard], and the TypeScript-only phantom +/// `types` field is omitted because Dart generics carry input/output types. +abstract interface class StandardTyped { + /// The standard typed properties. + StandardTypedProps get standard; +} + +/// A schema that validates unknown values. +/// +/// This is the Dart spelling of upstream `StandardSchemaV1`. +abstract interface class StandardSchema + implements StandardTyped { + /// The standard schema properties. + @override StandardSchemaProps get standard; } +/// An entity that can convert its input/output sides to JSON Schema. +/// +/// This is the Dart spelling of upstream `StandardJSONSchemaV1`. It is +/// intentionally separate from [StandardSchema]; an object may implement one +/// or both traits. +abstract interface class StandardJsonSchema + implements StandardTyped { + /// The standard JSON Schema properties. + @override + StandardJsonSchemaProps get standard; +} + +/// Convenience interface for entities that implement both standard validation +/// and standard JSON Schema conversion with one [standard] property. +abstract interface class StandardSchemaWithJsonSchema + implements + StandardSchema, + StandardJsonSchema { + /// The combined standard properties. + @override + StandardSchemaWithJsonSchemaProps get standard; +} + /// Validates an unknown value, synchronously or asynchronously. /// /// Mirrors the spec's `validate(value, options?) => Result | Promise`. @@ -28,14 +59,9 @@ typedef StandardValidate = typedef StandardJsonSchemaConvert = Map Function(StandardJsonSchemaOptions options); -/// The properties of a [StandardSchema]. -final class StandardSchemaProps { - const StandardSchemaProps({ - required this.vendor, - required this.validate, - this.version = 1, - this.jsonSchema, - }); +/// The properties shared by every standard trait. +class StandardTypedProps { + const StandardTypedProps({required this.vendor, this.version = 1}); /// The vendor name of the schema library. final String vendor; @@ -43,13 +69,48 @@ final class StandardSchemaProps { /// The version of the standard. Always `1` for this spec revision (Dart /// cannot pin the literal type the way TypeScript's `version: 1` does). final int version; +} + +/// The properties of a [StandardSchema]. +class StandardSchemaProps + extends StandardTypedProps { + const StandardSchemaProps({ + required super.vendor, + required this.validate, + super.version, + }); /// Validates an unknown input value. final StandardValidate validate; +} + +/// The properties of a [StandardJsonSchema]. +class StandardJsonSchemaProps + extends StandardTypedProps { + const StandardJsonSchemaProps({ + required super.vendor, + required this.jsonSchema, + super.version, + }); + + /// The JSON Schema tier converter. + final StandardJsonSchemaConverter jsonSchema; +} + +/// The properties of a [StandardSchemaWithJsonSchema]. +class StandardSchemaWithJsonSchemaProps + extends StandardSchemaProps + implements StandardJsonSchemaProps { + const StandardSchemaWithJsonSchemaProps({ + required super.vendor, + required super.validate, + required this.jsonSchema, + super.version, + }); - /// The JSON Schema tier converter, or `null` if this vendor does not - /// implement the JSON Schema spec. - final StandardJsonSchemaConverter? jsonSchema; + /// The JSON Schema tier converter. + @override + final StandardJsonSchemaConverter jsonSchema; } /// Optional parameters passed to [StandardValidate]. @@ -88,16 +149,16 @@ final class StandardIssue { /// The error message of the issue. final String message; - /// The path to the offending value, as `PropertyKey` segments: a [String] + /// The path to the offending value, as property-key segments: a [String] /// object key or an [int] list index. Empty for a root-level issue. final List path; } -/// The JSON Schema tier converter (`StandardJSONSchemaV1`). +/// The JSON Schema tier converter. /// /// The [input]/[output] split exists because validators transform: [input] -/// describes the value accepted (the boundary side), [output] the value -/// produced (the runtime side). +/// describes the value accepted at the boundary, while [output] describes the +/// value produced at runtime. final class StandardJsonSchemaConverter { const StandardJsonSchemaConverter({ required this.input, @@ -126,11 +187,8 @@ final class StandardJsonSchemaOptions { /// /// Mirrors the spec's /// `Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (string & {})`: a -/// zero-cost extension type over [String] where the constants are the -/// recommended targets, but any string is accepted via the constructor -/// (`JsonSchemaTarget('my-target')`). Because it `implements String`, a -/// `JsonSchemaTarget` is usable wherever a `String` is and compares equal to -/// its underlying value. +/// zero-cost extension type over [String] where the constants are recommended +/// targets, but any string is accepted via the constructor. extension type const JsonSchemaTarget(String value) implements String { /// JSON Schema Draft 2020-12. static const draft202012 = JsonSchemaTarget('draft-2020-12'); diff --git a/packages/schema_model/test/standard_schema_test.dart b/packages/schema_model/test/standard_schema_test.dart index 34bd3952..e1374257 100644 --- a/packages/schema_model/test/standard_schema_test.dart +++ b/packages/schema_model/test/standard_schema_test.dart @@ -9,11 +9,10 @@ Future> _resolve( return result; } -final class _FakeSchema implements StandardSchema { - const _FakeSchema({this.async = false, this.includeJsonSchema = true}); +final class _ValidationOnlySchema implements StandardSchema { + const _ValidationOnlySchema({this.async = false}); final bool async; - final bool includeJsonSchema; @override StandardSchemaProps get standard => StandardSchemaProps( @@ -27,27 +26,52 @@ final class _FakeSchema implements StandardSchema { StandardIssue(message: 'Not ok', path: ['items', 1]), ]); }, - jsonSchema: includeJsonSchema - ? StandardJsonSchemaConverter( - input: (options) => { - r'$schema': options.target, - 'type': 'string', - if (options.libraryOptions case final options?) - 'x-options': options, - }, - output: (options) => {'type': 'integer'}, - ) - : null, ); } +final class _JsonSchemaOnlySchema implements StandardJsonSchema { + const _JsonSchemaOnlySchema(); + + @override + StandardJsonSchemaProps get standard => StandardJsonSchemaProps( + vendor: 'fake-json', + jsonSchema: StandardJsonSchemaConverter( + input: (options) => { + r'$schema': options.target, + 'type': 'string', + if (options.libraryOptions case final options?) 'x-options': options, + }, + output: (options) => {'type': 'integer'}, + ), + ); +} + +final class _CombinedSchema + implements StandardSchemaWithJsonSchema { + const _CombinedSchema(); + + @override + StandardSchemaWithJsonSchemaProps get standard => + StandardSchemaWithJsonSchemaProps( + vendor: 'fake-combined', + validate: (value, [options]) => value == 'ok' + ? const StandardSuccess(1) + : const StandardFailure([StandardIssue(message: 'Not ok')]), + jsonSchema: StandardJsonSchemaConverter( + input: (options) => {'type': 'string'}, + output: (options) => {'type': 'integer'}, + ), + ); +} + void main() { group('StandardSchema', () { test('carries vendor, version, and success or failure results', () async { - const schema = _FakeSchema(); + const schema = _ValidationOnlySchema(); expect(schema.standard.vendor, 'fake'); expect(schema.standard.version, 1); + expect(schema, isNot(isA>())); final success = await _resolve(schema.standard.validate('ok')); final failure = await _resolve(schema.standard.validate('bad')); @@ -60,7 +84,7 @@ void main() { }); test('allows async validation and validate options', () async { - const schema = _FakeSchema(async: true); + const schema = _ValidationOnlySchema(async: true); final result = await _resolve( schema.standard.validate( @@ -72,13 +96,14 @@ void main() { expect(result, isA>()); }); - test('models optional JSON Schema converters', () { - const withConverter = _FakeSchema(); - const withoutConverter = _FakeSchema(includeJsonSchema: false); + test('models JSON Schema converters as a separate trait', () { + const schema = _JsonSchemaOnlySchema(); - expect(withoutConverter.standard.jsonSchema, isNull); + expect(schema.standard.vendor, 'fake-json'); + expect(schema.standard.version, 1); + expect(schema, isNot(isA>())); expect( - withConverter.standard.jsonSchema!.input( + schema.standard.jsonSchema.input( const StandardJsonSchemaOptions( target: JsonSchemaTarget.draft07, libraryOptions: {'dialect': 'draft7'}, @@ -91,11 +116,29 @@ void main() { }, ); expect( - withConverter.standard.jsonSchema!.output( + schema.standard.jsonSchema.output( const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), ), {'type': 'integer'}, ); }); + + test( + 'allows a schema to implement validation and JSON Schema together', + () { + const schema = _CombinedSchema(); + + expect(schema, isA>()); + expect(schema, isA>()); + expect(schema.standard.vendor, 'fake-combined'); + expect(schema.standard.validate('ok'), isA>()); + expect( + schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'string'}, + ); + }, + ); }); } From c49347ef2e737ae40b114e71dc37e38849d9e692 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 10 Jun 2026 18:22:02 -0400 Subject: [PATCH 04/13] feat(standard-schema): rename package, add utils tier, re-export from ack Rename schema_model -> standard_schema to match @standard-schema/spec; the internal ack AckSchemaModel (lib/src/schema_model/) is unaffected. Add the opt-in utils.dart library (getDotPath + StandardSchemaError) porting @standard-schema/utils. Re-export the standard_schema contract types from package:ack so consumers no longer need a separate dependency. Document spec deviations and the SchemaPathSegment migration. Publish prep: standard_schema 0.0.1-dev.0 with ack's dependency constraint updated to match. --- .github/workflows/release.yml | 2 +- PUBLISHING.md | 10 ++-- README.md | 2 +- packages/ack/CHANGELOG.md | 12 ++++ packages/ack/lib/ack.dart | 4 ++ packages/ack/lib/src/schemas/schema.dart | 2 +- .../lib/src/validation/standard_issues.dart | 2 +- packages/ack/pubspec.yaml | 2 +- .../standard_schema_conformance_test.dart | 1 - .../standard_schema_dotpath_test.dart | 28 +++++++++ .../standard_schema_export_test.dart | 30 ++++++++++ packages/schema_model/CHANGELOG.md | 9 --- .../.pubignore | 0 packages/standard_schema/CHANGELOG.md | 15 +++++ .../{schema_model => standard_schema}/LICENSE | 0 .../README.md | 59 ++++++++++++++---- .../analysis_options.yaml | 0 .../lib/src/standard_schema.dart | 8 ++- packages/standard_schema/lib/src/utils.dart | 45 ++++++++++++++ .../lib/standard_schema.dart} | 0 packages/standard_schema/lib/utils.dart | 8 +++ .../pubspec.yaml | 4 +- .../test/standard_schema_test.dart | 2 +- packages/standard_schema/test/utils_test.dart | 60 +++++++++++++++++++ pubspec.yaml | 6 +- scripts/update_release_changelog.dart | 2 +- 26 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 packages/ack/test/validation/standard_schema_dotpath_test.dart create mode 100644 packages/ack/test/validation/standard_schema_export_test.dart delete mode 100644 packages/schema_model/CHANGELOG.md rename packages/{schema_model => standard_schema}/.pubignore (100%) create mode 100644 packages/standard_schema/CHANGELOG.md rename packages/{schema_model => standard_schema}/LICENSE (100%) rename packages/{schema_model => standard_schema}/README.md (54%) rename packages/{schema_model => standard_schema}/analysis_options.yaml (100%) rename packages/{schema_model => standard_schema}/lib/src/standard_schema.dart (93%) create mode 100644 packages/standard_schema/lib/src/utils.dart rename packages/{schema_model/lib/schema_model.dart => standard_schema/lib/standard_schema.dart} (100%) create mode 100644 packages/standard_schema/lib/utils.dart rename packages/{schema_model => standard_schema}/pubspec.yaml (87%) rename packages/{schema_model => standard_schema}/test/standard_schema_test.dart (98%) create mode 100644 packages/standard_schema/test/utils_test.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6139494..2268ab0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,4 +21,4 @@ jobs: ack_generator ack_json_schema_builder ack_firebase_ai - schema_model + standard_schema diff --git a/PUBLISHING.md b/PUBLISHING.md index 07b72e20..d599ceaf 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -19,7 +19,7 @@ Before creating a release: 1. Ensure all changes are committed and pushed to the `main` branch 2. Verify that all tests pass by running `melos test` (include `melos run validate-jsonschema` and `melos run test:gen` for full coverage) 3. Check that the documentation is up to date across the repo and docs site -4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`, `schema_model`) +4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`, `standard_schema`) 5. Ensure package CHANGELOG entries are finalized before tagging. If you want a link-only entry for a version, you can run `dart scripts/update_release_changelog.dart [tag]` after `melos version`. ### 2. Create a GitHub Release @@ -101,7 +101,7 @@ If you need to publish packages manually: ```bash # Dry-run each package (validation only) -for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai schema_model; do +for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai standard_schema; do (cd packages/$pkg && dart pub publish --dry-run) || exit 1 done @@ -109,11 +109,11 @@ done melos run publish ``` -### First publish for `schema_model` +### First publish for `standard_schema` -`schema_model` is a new package. Pub.dev automated publishing can only be +`standard_schema` is a new package. Pub.dev automated publishing can only be enabled after the package exists, so the first version must be published -manually with `dart pub publish` from `packages/schema_model` before the +manually with `dart pub publish` from `packages/standard_schema` before the release tag. After that first upload, enable GitHub Actions publishing in the package's pub.dev admin settings so future tagged releases can use the workflow. diff --git a/README.md b/README.md index 9b1d1cd0..7a96753d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). This repository is a monorepo containing: - **[ack](./packages/ack)**: Core validation library with fluent schema building API -- **[schema_model](./packages/schema_model)**: Standard Schema contracts for Dart validators and converters +- **[standard_schema](./packages/standard_schema)**: Standard Schema contracts for Dart validators and converters - **[ack_generator](./packages/ack_generator)**: Code generator for `@AckType()` extension-type wrappers - **[ack_firebase_ai](./packages/ack_firebase_ai)**: Firebase AI (Gemini) schema converter for structured output generation - **[example](./example)**: Example projects demonstrating usage of all packages diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index dfb50654..018c040a 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -31,6 +31,9 @@ `SchemaError.toStandardIssues()` maps ACK errors to standard issues. * `AckSchemaModel.fromJsonSchema(...)` imports supported Draft-7 JSON Schema maps into ACK's existing schema model as a best-effort Ack feature. +* `package:ack/ack.dart` now re-exports the `standard_schema` contract types + (`StandardSchema`, `StandardResult`, `StandardIssue`, `JsonSchemaTarget`, + …) so they can be used without depending on `standard_schema` directly. ### Changed @@ -44,6 +47,15 @@ * Re-run tests for code paths that parse or encode `double`/`num` values. If a boundary must accept `NaN` or infinities, model that value outside the JSON numeric schema path before validation. +* Custom schema authors and manual `SchemaContext` callers should replace raw + `pathSegment` strings with typed segments: + + ```dart + // Migrating manual SchemaContext.createChild(pathSegment: ...) calls: + pathSegment: SchemaPathSegment.property(key) // object key (String) + pathSegment: SchemaPathSegment.index(i) // list index (int) + pathSegment: const SchemaPathSegment.passThrough() // composition branch routing + ``` ## 1.0.0-beta.11 diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 3cf92dea..90a03843 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -34,5 +34,9 @@ export 'src/schemas/schema.dart' export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; export 'src/validation/standard_issues.dart'; +// Re-export the Standard Schema contract types so consumers of package:ack +// can name AckSchema.standard's result/contract types without a separate +// dependency on package:standard_schema. +export 'package:standard_schema/standard_schema.dart'; // Validation results export 'src/validation/schema_result.dart'; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 5a366d55..d91ca495 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,6 +1,6 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; -import 'package:schema_model/schema_model.dart' +import 'package:standard_schema/standard_schema.dart' show JsonSchemaTarget, StandardFailure, diff --git a/packages/ack/lib/src/validation/standard_issues.dart b/packages/ack/lib/src/validation/standard_issues.dart index 7c1d5caa..1e29b269 100644 --- a/packages/ack/lib/src/validation/standard_issues.dart +++ b/packages/ack/lib/src/validation/standard_issues.dart @@ -1,4 +1,4 @@ -import 'package:schema_model/schema_model.dart'; +import 'package:standard_schema/standard_schema.dart'; import 'schema_error.dart'; diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 5361e6f1..ef43af69 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -12,7 +12,7 @@ environment: dependencies: meta: ^1.15.0 collection: ^1.18.0 - schema_model: ^1.0.0-beta.12-wip + standard_schema: '>=0.0.1-dev.0 <0.1.0' dev_dependencies: test: ^1.25.15 diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart index bcd08898..c5656ee9 100644 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -1,5 +1,4 @@ import 'package:ack/ack.dart'; -import 'package:schema_model/schema_model.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/ack/test/validation/standard_schema_dotpath_test.dart b/packages/ack/test/validation/standard_schema_dotpath_test.dart new file mode 100644 index 00000000..e61103be --- /dev/null +++ b/packages/ack/test/validation/standard_schema_dotpath_test.dart @@ -0,0 +1,28 @@ +import 'package:ack/ack.dart'; +import 'package:standard_schema/utils.dart'; +import 'package:test/test.dart'; + +void main() { + test('getDotPath renders nested ack issue paths as spec dot-paths', () { + final schema = Ack.object({ + 'user': Ack.object({ + 'tags': Ack.list(Ack.string().minLength(2)), + 'age': Ack.integer(), + }), + }); + + final result = schema.standard.validate({ + 'user': { + 'tags': ['ok', 'x'], + 'age': 'old', + }, + }); + + final failure = result as StandardFailure; + + expect(failure.issues.map(getDotPath).toList(), [ + 'user.tags.1', + 'user.age', + ]); + }); +} diff --git a/packages/ack/test/validation/standard_schema_export_test.dart b/packages/ack/test/validation/standard_schema_export_test.dart new file mode 100644 index 00000000..9d64f755 --- /dev/null +++ b/packages/ack/test/validation/standard_schema_export_test.dart @@ -0,0 +1,30 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('standard_schema re-export via ack.dart', () { + test( + 'AckSchema.standard contract types are reachable without direct import', + () { + final schema = Ack.string(); + + expect(schema, isA>()); + + final success = schema.standard.validate('ok'); + expect(success, isA>()); + expect((success as StandardSuccess).value, 'ok'); + + final failure = schema.standard.validate(1); + expect(failure, isA>()); + expect((failure as StandardFailure).issues, [ + isA(), + ]); + + final jsonSchema = schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ); + expect(jsonSchema, isA>()); + }, + ); + }); +} diff --git a/packages/schema_model/CHANGELOG.md b/packages/schema_model/CHANGELOG.md deleted file mode 100644 index 1de5b207..00000000 --- a/packages/schema_model/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -## 1.0.0-beta.12-wip - -### Added - -- Add the `StandardTyped`, `StandardSchema`, `StandardJsonSchema`, and - `StandardSchemaWithJsonSchema` contracts, aligned with the official split - Standard Schema family. -- Add standard results/issues, validation options, JSON Schema converter option - types, and JSON Schema target constants. diff --git a/packages/schema_model/.pubignore b/packages/standard_schema/.pubignore similarity index 100% rename from packages/schema_model/.pubignore rename to packages/standard_schema/.pubignore diff --git a/packages/standard_schema/CHANGELOG.md b/packages/standard_schema/CHANGELOG.md new file mode 100644 index 00000000..aa05d624 --- /dev/null +++ b/packages/standard_schema/CHANGELOG.md @@ -0,0 +1,15 @@ +## 0.0.1-dev.0 + +Initial dev release reserving the `standard_schema` name on pub.dev. + +### Added + +- Add the `StandardTyped`, `StandardSchema`, and `StandardJsonSchema` + contracts, porting the two official Standard Schema interfaces, plus the + Dart-only `StandardSchemaWithJsonSchema` convenience for combined + implementers. +- Add standard results/issues, validation options, JSON Schema converter option + types, and JSON Schema target constants. +- Add the opt-in `utils.dart` library with `getDotPath` (renders an issue path + in dot notation, e.g. `user.tags.1`) and `StandardSchemaError` (a throwable + wrapping a failure's issues), porting `@standard-schema/utils`. diff --git a/packages/schema_model/LICENSE b/packages/standard_schema/LICENSE similarity index 100% rename from packages/schema_model/LICENSE rename to packages/standard_schema/LICENSE diff --git a/packages/schema_model/README.md b/packages/standard_schema/README.md similarity index 54% rename from packages/schema_model/README.md rename to packages/standard_schema/README.md index 6ac4427f..0478e993 100644 --- a/packages/schema_model/README.md +++ b/packages/standard_schema/README.md @@ -1,21 +1,23 @@ -# schema_model +# standard_schema Standard Schema contracts for Dart validators and converters. -`schema_model` is a Dart port of the contract family described by -[standardschema.dev](https://standardschema.dev). It defines small -`StandardTyped`, `StandardSchema`, and `StandardJsonSchema` surfaces that -libraries can implement and consumers can call without depending on a -vendor-specific schema tree. +`standard_schema` is a Dart port of the contract family described by +[standardschema.dev](https://standardschema.dev). It ports the two official +interfaces — `StandardSchemaV1` and `StandardJSONSchemaV1` — as +`StandardSchema` and `StandardJsonSchema`, plus a Dart-only combined convenience +(`StandardSchemaWithJsonSchema`) for implementers that satisfy both with one +`standard` getter. -The package intentionally does not define a schema model, parser, renderer, or -warning system. Libraries keep those implementation details in their own -packages and expose this contract at their boundary. +Libraries implement these small surfaces and consumers call them without +depending on a vendor-specific schema tree. The package does not define a JSON +schema model, parser, renderer, or warning system; those stay in vendor +packages (for example, Ack's `AckSchemaModel` in `package:ack`). ## Implement a schema ```dart -import 'package:schema_model/schema_model.dart'; +import 'package:standard_schema/standard_schema.dart'; final class RequiredStringSchema implements StandardSchema { const RequiredStringSchema(); @@ -39,7 +41,7 @@ final class RequiredStringSchema implements StandardSchema { ## Expose JSON Schema conversion ```dart -import 'package:schema_model/schema_model.dart'; +import 'package:standard_schema/standard_schema.dart'; final class RequiredStringJsonSchema implements StandardJsonSchema { @@ -68,8 +70,12 @@ throw when a schema cannot be represented for the requested target. ## Implement both traits +`StandardSchemaWithJsonSchema` is a Dart-only convenience — not a separate +upstream interface. It models the structural intersection of the two official +`~standard` Props when one getter must satisfy both traits. + ```dart -import 'package:schema_model/schema_model.dart'; +import 'package:standard_schema/standard_schema.dart'; final class RequiredStringSchemaWithJson implements StandardSchemaWithJsonSchema { @@ -101,3 +107,32 @@ final class RequiredStringSchemaWithJson ); } ``` + +## Utilities + +Optional helpers for consuming validation issues live in a separate, opt-in +library, mirroring upstream's `@standard-schema/utils` package. Import it +explicitly: + +```dart +import 'package:standard_schema/utils.dart'; +``` + +- `getDotPath(issue)` renders an issue's `path` in dot notation (for example + `user.tags.1`), or returns `null` when the issue has no path. +- `StandardSchemaError(issues)` wraps a failure's issues as a throwable whose + `message` is the first issue's message. + +```dart +final result = schema.standard.validate(value); + +if (result is StandardFailure) { + // Render each issue's path in dot notation: + for (final issue in result.issues) { + print('${getDotPath(issue) ?? ''}: ${issue.message}'); + } + + // Or throw the whole failure as a single error: + throw StandardSchemaError(result.issues); +} +``` diff --git a/packages/schema_model/analysis_options.yaml b/packages/standard_schema/analysis_options.yaml similarity index 100% rename from packages/schema_model/analysis_options.yaml rename to packages/standard_schema/analysis_options.yaml diff --git a/packages/schema_model/lib/src/standard_schema.dart b/packages/standard_schema/lib/src/standard_schema.dart similarity index 93% rename from packages/schema_model/lib/src/standard_schema.dart rename to packages/standard_schema/lib/src/standard_schema.dart index 495d8490..9de6575c 100644 --- a/packages/schema_model/lib/src/standard_schema.dart +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -32,8 +32,14 @@ abstract interface class StandardJsonSchema StandardJsonSchemaProps get standard; } -/// Convenience interface for entities that implement both standard validation +/// Dart-only convenience for entities that implement both standard validation /// and standard JSON Schema conversion with one [standard] property. +/// +/// Not a separate upstream interface. Upstream defines exactly two interfaces +/// (`StandardSchemaV1`, `StandardJSONSchemaV1`), each with its own `~standard`. +/// A TypeScript schema implementing both has one `~standard` that structurally +/// satisfies both Props. This interface models that intersection for Dart, +/// where one getter cannot return two unrelated types. abstract interface class StandardSchemaWithJsonSchema implements StandardSchema, diff --git a/packages/standard_schema/lib/src/utils.dart b/packages/standard_schema/lib/src/utils.dart new file mode 100644 index 00000000..467523dd --- /dev/null +++ b/packages/standard_schema/lib/src/utils.dart @@ -0,0 +1,45 @@ +import 'standard_schema.dart'; + +/// Returns the dot-notation path of an [issue] (for example `user.tags.1`), or +/// `null` when the issue has no path or any segment is not a string or number. +/// +/// Direct port of `getDotPath` from `@standard-schema/utils`. Ack emits issue +/// paths as bare property keys ([String] object keys and [int] list indexes — +/// the spec's `PropertyKey` form), so the upstream `{key}`-object segment branch +/// is unreachable here; the type check is kept for faithfulness to the source. +String? getDotPath(StandardIssue issue) { + if (issue.path.isEmpty) return null; + final dotPath = StringBuffer(); + for (final segment in issue.path) { + if (segment is String || segment is num) { + if (dotPath.isNotEmpty) dotPath.write('.'); + dotPath.write(segment); + } else { + return null; + } + } + return dotPath.toString(); +} + +/// An [Exception] wrapping the [issues] of a failed Standard Schema validation, +/// exposing the first issue's [message]. The standard way to throw on failure. +/// +/// Port of `SchemaError` from `@standard-schema/utils`, renamed to avoid +/// colliding with Ack's own `SchemaError` (which `package:ack` re-exports). +class StandardSchemaError implements Exception { + /// Wraps [issues]. Throws [ArgumentError] when [issues] is empty: a failure + /// always carries at least one issue, and [message] is taken from the first. + StandardSchemaError(this.issues) + : message = issues.isEmpty + ? throw ArgumentError.value(issues, 'issues', 'must not be empty') + : issues.first.message; + + /// The issues describing why validation failed. Never empty. + final List issues; + + /// The error message, taken from the first issue. + final String message; + + @override + String toString() => 'StandardSchemaError: $message'; +} diff --git a/packages/schema_model/lib/schema_model.dart b/packages/standard_schema/lib/standard_schema.dart similarity index 100% rename from packages/schema_model/lib/schema_model.dart rename to packages/standard_schema/lib/standard_schema.dart diff --git a/packages/standard_schema/lib/utils.dart b/packages/standard_schema/lib/utils.dart new file mode 100644 index 00000000..c56badcf --- /dev/null +++ b/packages/standard_schema/lib/utils.dart @@ -0,0 +1,8 @@ +/// Optional helpers for consuming Standard Schema issues. +/// +/// Mirrors upstream's separate, optional `@standard-schema/utils` package. Kept +/// out of the main `standard_schema.dart` barrel so it stays opt-in: import it +/// explicitly with `import 'package:standard_schema/utils.dart';`. +library; + +export 'src/utils.dart'; diff --git a/packages/schema_model/pubspec.yaml b/packages/standard_schema/pubspec.yaml similarity index 87% rename from packages/schema_model/pubspec.yaml rename to packages/standard_schema/pubspec.yaml index e075d13b..635132e2 100644 --- a/packages/schema_model/pubspec.yaml +++ b/packages/standard_schema/pubspec.yaml @@ -1,6 +1,6 @@ -name: schema_model +name: standard_schema description: Standard Schema contracts for Dart validators and converters -version: 1.0.0-beta.12-wip +version: 0.0.1-dev.0 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://docs.page/btwld/ack diff --git a/packages/schema_model/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart similarity index 98% rename from packages/schema_model/test/standard_schema_test.dart rename to packages/standard_schema/test/standard_schema_test.dart index e1374257..b7b9cbf8 100644 --- a/packages/schema_model/test/standard_schema_test.dart +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:schema_model/schema_model.dart'; +import 'package:standard_schema/standard_schema.dart'; import 'package:test/test.dart'; Future> _resolve( diff --git a/packages/standard_schema/test/utils_test.dart b/packages/standard_schema/test/utils_test.dart new file mode 100644 index 00000000..418d6fe2 --- /dev/null +++ b/packages/standard_schema/test/utils_test.dart @@ -0,0 +1,60 @@ +import 'package:standard_schema/standard_schema.dart'; +import 'package:standard_schema/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('getDotPath', () { + test('returns null when the issue has no path', () { + expect(getDotPath(const StandardIssue(message: 'm')), isNull); + expect(getDotPath(const StandardIssue(message: 'm', path: [])), isNull); + }); + + test('returns null when a segment is not a string or number', () { + // `true` stands in for any non-string/number segment (the spec's symbol + // form); `getDotPath` bails out rather than rendering it. + expect( + getDotPath(const StandardIssue(message: 'm', path: [true])), + isNull, + ); + }); + + test('joins string and number segments with dots', () { + expect( + getDotPath(const StandardIssue(message: 'm', path: ['a', 'b'])), + 'a.b', + ); + expect( + getDotPath(const StandardIssue(message: 'm', path: ['a', 0, 'b'])), + 'a.0.b', + ); + expect( + getDotPath( + const StandardIssue( + message: 'm', + path: ['nested', 0, 'dot', 0, 'path'], + ), + ), + 'nested.0.dot.0.path', + ); + }); + }); + + group('StandardSchemaError', () { + test('wraps issues and exposes the first issue message', () { + const issues = [ + StandardIssue(message: 'first', path: ['a']), + StandardIssue(message: 'second'), + ]; + final error = StandardSchemaError(issues); + + expect(error, isA()); + expect(error.issues, same(issues)); + expect(error.message, 'first'); + expect(error.toString(), 'StandardSchemaError: first'); + }); + + test('throws when constructed with no issues', () { + expect(() => StandardSchemaError(const []), throwsArgumentError); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 5723796b..70909506 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ workspace: - packages/ack_generator - packages/ack_firebase_ai - packages/ack_json_schema_builder - - packages/schema_model + - packages/standard_schema - example dependencies: @@ -56,7 +56,7 @@ melos: - ack_generator - ack_firebase_ai - ack_json_schema_builder - - schema_model + - standard_schema - ack_example - ack_annotations @@ -68,7 +68,7 @@ melos: - ack - ack_generator - ack_json_schema_builder - - schema_model + - standard_schema - ack_example dependsOn: - "ack" # This will ensure 'ack' is prioritized diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index 29965f89..e9d1ec2f 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -36,7 +36,7 @@ void main(List args) { 'packages/ack_generator/CHANGELOG.md', 'packages/ack_firebase_ai/CHANGELOG.md', 'packages/ack_json_schema_builder/CHANGELOG.md', - 'packages/schema_model/CHANGELOG.md', + 'packages/standard_schema/CHANGELOG.md', ]; for (final path in changelogPaths) { From 9214e1161512a829e14b2b0a4ce68d36e286ad8c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 11 Jun 2026 10:48:50 -0400 Subject: [PATCH 05/13] fix(ack_firebase_ai): regenerate fixtures for firebase_ai 3.12.2 CI resolves firebase_ai ^3.12.1 to 3.12.2 (the lock is gitignored, so it floats); the native fixture manifest tests assert the recorded sourceVersion matches the resolved package version. Regenerated via tool/generate_firebase_ai_response_json_schema_fixtures.dart -- only sourceVersion changed (3.12.1 -> 3.12.2); schema output is byte-identical. Fixes the failing 'manifest tracks every case and feature' tests. --- .../test/fixtures/firebase_ai_native_json_schema/manifest.json | 2 +- .../test/fixtures/firebase_ai_native_schema/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json index 7670dec3..6f309877 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.1", + "sourceVersion": "3.12.2", "sourceClass": "JSONSchema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json index 3c8f007a..e611fe70 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.1", + "sourceVersion": "3.12.2", "sourceClass": "Schema", "fixtureCount": 30, "featureCoverage": { From e1e12bde05ee58288171df851a48577a1adb1e1c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 11 Jun 2026 11:15:38 -0400 Subject: [PATCH 06/13] Stop asserting firebase_ai fixture source version --- .../test/to_firebase_ai_native_schema_fixture_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart index 56e9480f..d32e4353 100644 --- a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart +++ b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart @@ -17,7 +17,10 @@ void main() { final sourceCases = firebaseAiResponseJsonSchemaCases(); expect(fixtures.sourcePackage, 'firebase_ai'); - expect(fixtures.sourceVersion, firebaseAiPackageVersion()); + // `sourceVersion` is recorded as provenance only; the per-case tests + // below compare live firebase_ai output to the golden fixtures, which + // is the real staleness guard. Asserting the exact resolved version + // only produces false CI failures on benign firebase_ai patch bumps. expect(fixtures.sourceClass, family.sourceClass); expect(fixtures.fixtureCount, cases.length); expect(fixtures.ids, sourceCases.map((schemaCase) => schemaCase.id)); From 926ca77ba5f4bae1e6709941070eea5a9a9febf9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 11 Jun 2026 17:03:19 -0400 Subject: [PATCH 07/13] Apply automated Dart and DCM fixes --- .../constraints/comparison_constraint.dart | 8 ++ .../ack/lib/src/constraints/constraint.dart | 1 + .../src/constraints/datetime_constraint.dart | 19 ++-- .../src/constraints/duration_constraint.dart | 1 + .../list_unique_items_constraint.dart | 9 +- .../src/constraints/string_ip_constraint.dart | 1 + .../string_literal_constraint.dart | 1 + .../ack/lib/src/constraints/validators.dart | 3 + packages/ack/lib/src/context.dart | 8 +- .../ack_schema_model_builder.dart | 23 +++-- .../schema_model/ack_schema_model_parser.dart | 20 ++-- .../ack_schema_model_warning.dart | 11 ++- .../ack/lib/src/schemas/any_of_schema.dart | 45 +++++---- packages/ack/lib/src/schemas/any_schema.dart | 8 +- .../ack/lib/src/schemas/boolean_schema.dart | 7 +- .../ack/lib/src/schemas/codec_schema.dart | 20 ++-- .../ack/lib/src/schemas/default_schema.dart | 99 ++++++++++--------- packages/ack/lib/src/schemas/enum_schema.dart | 14 ++- .../extensions/ack_schema_extensions.dart | 2 +- .../extensions/object_schema_extensions.dart | 1 + .../ack/lib/src/schemas/fluent_schema.dart | 1 + .../ack/lib/src/schemas/instance_schema.dart | 10 +- packages/ack/lib/src/schemas/lazy_schema.dart | 26 ++--- packages/ack/lib/src/schemas/list_schema.dart | 38 +++---- packages/ack/lib/src/schemas/num_schema.dart | 25 +++-- .../ack/lib/src/schemas/string_schema.dart | 7 +- .../src/schemas/testing/testing_schemas.dart | 8 +- .../ack/lib/src/utils/collection_utils.dart | 4 + packages/ack/lib/src/utils/default_utils.dart | 2 + packages/ack/lib/src/utils/json_utils.dart | 2 +- packages/ack/lib/src/utils/string_utils.dart | 5 +- .../ack/lib/src/validation/schema_error.dart | 7 +- .../lib/src/validation/standard_issues.dart | 5 +- 33 files changed, 262 insertions(+), 179 deletions(-) diff --git a/packages/ack/lib/src/constraints/comparison_constraint.dart b/packages/ack/lib/src/constraints/comparison_constraint.dart index c8b8546b..de13fd1f 100644 --- a/packages/ack/lib/src/constraints/comparison_constraint.dart +++ b/packages/ack/lib/src/constraints/comparison_constraint.dart @@ -18,6 +18,7 @@ _ConstraintCategory _categorize(String constraintKey) { if (constraintKey.startsWith('object_')) { return _ConstraintCategory.objectProperties; } + return _ConstraintCategory.numeric; } @@ -145,6 +146,7 @@ class ComparisonConstraint extends Constraint 'multipleOf value cannot be zero', ); } + return ComparisonConstraint( type: ComparisonType.eq, threshold: 0, @@ -241,6 +243,7 @@ class ComparisonConstraint extends Constraint @override bool isValid(T value) { final num extracted = valueExtractor(value); + return switch (type) { ComparisonType.gt => extracted > threshold, ComparisonType.gte => extracted >= threshold, @@ -253,9 +256,11 @@ class ComparisonConstraint extends Constraint // - Close to the multiple itself (e.g., 0.6 % 0.1 = 0.0999... ≈ 0.1) final rem = extracted.abs(); final multiple = multipleValue!.abs(); + return rem < _multipleOfEpsilon || (multiple - rem).abs() < _multipleOfEpsilon; } + return extracted == threshold; }(), ComparisonType.range => @@ -271,6 +276,7 @@ class ComparisonConstraint extends Constraint if (customMessageBuilder != null) { return customMessageBuilder!(nonNullValue, extracted); } + // Default messages return switch (type) { ComparisonType.gt => 'Must be greater than $threshold, got $extracted.', @@ -322,6 +328,7 @@ class ComparisonConstraint extends Constraint 'maxLength': threshold.toInt(), }; } + return {'const': threshold}; }(), ComparisonType.range => switch (category) { @@ -350,6 +357,7 @@ class ComparisonConstraint extends Constraint if (identical(this, other)) return true; if (other is! ComparisonConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && type == other.type && diff --git a/packages/ack/lib/src/constraints/constraint.dart b/packages/ack/lib/src/constraints/constraint.dart index bc96586a..c9e0e6e2 100644 --- a/packages/ack/lib/src/constraints/constraint.dart +++ b/packages/ack/lib/src/constraints/constraint.dart @@ -25,6 +25,7 @@ abstract class Constraint { if (identical(this, other)) return true; if (other is! Constraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description; } diff --git a/packages/ack/lib/src/constraints/datetime_constraint.dart b/packages/ack/lib/src/constraints/datetime_constraint.dart index 714155f8..f3d90579 100644 --- a/packages/ack/lib/src/constraints/datetime_constraint.dart +++ b/packages/ack/lib/src/constraints/datetime_constraint.dart @@ -10,7 +10,6 @@ import 'constraint.dart'; class DateTimeConstraint extends Constraint with Validator, JsonSchemaSpec { final DateTime reference; - final bool _isMinimum; /// The JSON Schema format associated with the boundary schema. /// @@ -21,6 +20,8 @@ class DateTimeConstraint extends Constraint /// The reference value rendered in the boundary schema's format. final String formattedReference; + final bool _isMinimum; + const DateTimeConstraint._({ required this.reference, required bool isMinimum, @@ -78,6 +79,14 @@ class DateTimeConstraint extends Constraint ); } + String _formatValue(DateTime value) { + if (jsonSchemaFormat == 'date') return _dateOnly(value); + + return value.toIso8601String(); + } + + String get comparisonType => _isMinimum ? 'min' : 'max'; + @override bool isValid(DateTime value) => _isMinimum ? !value.isBefore(reference) : !value.isAfter(reference); @@ -96,8 +105,6 @@ class DateTimeConstraint extends Constraint }; } - String get comparisonType => _isMinimum ? 'min' : 'max'; - @override // `formatMinimum`/`formatMaximum` are Draft 2019-09+ extensions that // Draft-7 consumers do not understand. The model builder surfaces these @@ -111,6 +118,7 @@ class DateTimeConstraint extends Constraint if (identical(this, other)) return true; if (other is! DateTimeConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && _isMinimum == other._isMinimum && @@ -129,11 +137,6 @@ class DateTimeConstraint extends Constraint jsonSchemaFormat, formattedReference, ); - - String _formatValue(DateTime value) { - if (jsonSchemaFormat == 'date') return _dateOnly(value); - return value.toIso8601String(); - } } String _dateOnly(DateTime reference) { diff --git a/packages/ack/lib/src/constraints/duration_constraint.dart b/packages/ack/lib/src/constraints/duration_constraint.dart index b29c48f2..1f21e586 100644 --- a/packages/ack/lib/src/constraints/duration_constraint.dart +++ b/packages/ack/lib/src/constraints/duration_constraint.dart @@ -93,6 +93,7 @@ class DurationConstraint extends Constraint if (identical(this, other)) return true; if (other is! DurationConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && type == other.type && diff --git a/packages/ack/lib/src/constraints/list_unique_items_constraint.dart b/packages/ack/lib/src/constraints/list_unique_items_constraint.dart index c5c9b835..7b9925c3 100644 --- a/packages/ack/lib/src/constraints/list_unique_items_constraint.dart +++ b/packages/ack/lib/src/constraints/list_unique_items_constraint.dart @@ -26,6 +26,7 @@ class ListUniqueItemsConstraint extends Constraint> } final joined = uniqueDuplicates.map((e) => '"$e"').join(', '); + return 'List items must be unique. Duplicates found: $joined.'; } @@ -97,6 +98,7 @@ int _deepHashCode(Object? value) { for (final item in value) { hash = Object.hash(hash, _deepHashCode(item)); } + return hash; } @@ -108,6 +110,7 @@ int _deepHashCode(Object? value) { final h = _deepHashCode(item); combined ^= h ^ (h >>> 16); } + return Object.hash(value.runtimeType, value.length, combined); } @@ -120,6 +123,7 @@ int _deepHashCode(Object? value) { final entryHash = Object.hash(keyHash, valueHash); combined ^= entryHash ^ (entryHash >>> 16); } + return Object.hash(value.runtimeType, value.length, combined); } @@ -128,6 +132,7 @@ int _deepHashCode(Object? value) { for (final item in value) { hash = Object.hash(hash, _deepHashCode(item)); } + return hash; } @@ -135,8 +140,8 @@ int _deepHashCode(Object? value) { } class _DuplicateGroup { - _DuplicateGroup(this.value); - final T value; + int count = 1; + _DuplicateGroup(this.value); } diff --git a/packages/ack/lib/src/constraints/string_ip_constraint.dart b/packages/ack/lib/src/constraints/string_ip_constraint.dart index ec93a743..21489d30 100644 --- a/packages/ack/lib/src/constraints/string_ip_constraint.dart +++ b/packages/ack/lib/src/constraints/string_ip_constraint.dart @@ -51,6 +51,7 @@ class StringIpConstraint extends Constraint if (identical(this, other)) return true; if (other is! StringIpConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && version == other.version; diff --git a/packages/ack/lib/src/constraints/string_literal_constraint.dart b/packages/ack/lib/src/constraints/string_literal_constraint.dart index a390f3be..58daff60 100644 --- a/packages/ack/lib/src/constraints/string_literal_constraint.dart +++ b/packages/ack/lib/src/constraints/string_literal_constraint.dart @@ -33,6 +33,7 @@ class StringLiteralConstraint extends Constraint if (identical(this, other)) return true; if (other is! StringLiteralConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && expectedValue == other.expectedValue; diff --git a/packages/ack/lib/src/constraints/validators.dart b/packages/ack/lib/src/constraints/validators.dart index 73eea31f..d4e6c8a4 100644 --- a/packages/ack/lib/src/constraints/validators.dart +++ b/packages/ack/lib/src/constraints/validators.dart @@ -65,6 +65,7 @@ class InvalidTypeConstraint extends Constraint if (identical(this, other)) return true; if (other is! InvalidTypeConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && expectedType == other.expectedType && @@ -114,6 +115,7 @@ class ObjectNoAdditionalPropertiesConstraint extends Constraint if (identical(this, other)) return true; if (other is! ObjectNoAdditionalPropertiesConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && unexpectedPropertyKey == other.unexpectedPropertyKey; @@ -154,6 +156,7 @@ class ObjectRequiredPropertiesConstraint extends Constraint if (identical(this, other)) return true; if (other is! ObjectRequiredPropertiesConstraint) return false; if (runtimeType != other.runtimeType) return false; + return constraintKey == other.constraintKey && description == other.description && missingPropertyKey == other.missingPropertyKey; diff --git a/packages/ack/lib/src/context.dart b/packages/ack/lib/src/context.dart index 2ddae671..653ee4c4 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -11,6 +11,10 @@ enum _SchemaPathSegmentKind { property, listIndex, passThrough } /// that should not add a user-visible path segment. @immutable final class SchemaPathSegment { + final _SchemaPathSegmentKind _kind; + + final Object? _value; + const SchemaPathSegment.property(String key) : _kind = _SchemaPathSegmentKind.property, _value = key; @@ -24,9 +28,6 @@ final class SchemaPathSegment { : _kind = _SchemaPathSegmentKind.passThrough, _value = null; - final _SchemaPathSegmentKind _kind; - final Object? _value; - Object? get _issueValue { return switch (_kind) { _SchemaPathSegmentKind.property => _value as String, @@ -100,6 +101,7 @@ class SchemaContext { final segment = pathSegment ?? SchemaPathSegment.property(name); final pathValue = segment._issueValue; if (pathValue == null) return parentSegments; + return [...parentSegments, pathValue]; } diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 1f635fda..24ea9474 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -22,16 +22,6 @@ final class _SchemaModelBuilder { final _definitions = {}; final _targets = {}; - AckSchemaModel build(AckSchema schema) { - final root = _build(schema); - if (_definitions.isEmpty) return root; - - return root.withExtensions({ - ...root.extensions, - 'definitions': _mergeRootDefinitions(root.extensions['definitions']), - }); - } - Map _mergeRootDefinitions(Object? existingDefinitions) { final lazyDefinitions = { for (final entry in _definitions.entries) @@ -71,6 +61,7 @@ final class _SchemaModelBuilder { } merged[entry.key] = entry.value; } + return merged; } @@ -309,12 +300,14 @@ final class _SchemaModelBuilder { 'schemas. Use unique names per recursive target.', ); } + return _lazyRef(schema); } _targets[name] = target; _definitions[name] = null; _definitions[name] = _build(target); + return _lazyRef(schema); } @@ -343,6 +336,16 @@ final class _SchemaModelBuilder { ), ]); } + + AckSchemaModel build(AckSchema schema) { + final root = _build(schema); + if (_definitions.isEmpty) return root; + + return root.withExtensions({ + ...root.extensions, + 'definitions': _mergeRootDefinitions(root.extensions['definitions']), + }); + } } AckSchemaModel _applyConstraints( diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart b/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart index 55a50da1..efe95016 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart @@ -1,10 +1,6 @@ part of 'ack_schema_model.dart'; final class _JsonSchemaParser { - AckSchemaModel parse(Map json) { - return _parse(json, path: ''); - } - AckSchemaModel _parse( Map json, { required String path, @@ -17,6 +13,7 @@ final class _JsonSchemaParser { path: _joinPath(path, 'anyOf/${nullableUnion.schemaIndex}'), nullable: true, ); + return _applyKeywords(parsed, json, const {'anyOf'}); } @@ -353,6 +350,7 @@ final class _JsonSchemaParser { nullable: nullable, warnings: [if (warning != null) warning], ); + return json.isEmpty ? fallback : fallback.withJsonSchemaKeywords(json); } @@ -364,6 +362,7 @@ final class _JsonSchemaParser { final keywords = Map.fromEntries( json.entries.where((entry) => !handled.contains(entry.key)), ); + return keywords.isEmpty ? model : model.withJsonSchemaKeywords(keywords); } @@ -382,6 +381,7 @@ final class _JsonSchemaParser { return _NullableUnion(schema: first, schemaIndex: 0); } } + return null; } @@ -394,6 +394,7 @@ final class _JsonSchemaParser { return _definitionsRefName(ref); } } + return null; } @@ -404,6 +405,7 @@ final class _JsonSchemaParser { String? _definitionsRefName(String ref) { const prefix = '#/definitions/'; if (!ref.startsWith(prefix)) return null; + return _unescapeJsonPointerToken(ref.substring(prefix.length)); } @@ -417,6 +419,7 @@ final class _JsonSchemaParser { if (key is! String) return null; result[key] = entry.value; } + return result; } @@ -436,17 +439,22 @@ final class _JsonSchemaParser { String _joinPath(String parent, String child) { if (parent.isEmpty) return child; + return '$parent/$child'; } String _unescapeJsonPointerToken(String value) { return value.replaceAll('~1', '/').replaceAll('~0', '~'); } + + AckSchemaModel parse(Map json) { + return _parse(json, path: ''); + } } final class _NullableUnion { - const _NullableUnion({required this.schema, required this.schemaIndex}); - final Map schema; + final int schemaIndex; + const _NullableUnion({required this.schema, required this.schemaIndex}); } diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart index f44d03ec..aabc58c8 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart @@ -3,6 +3,11 @@ import 'package:meta/meta.dart'; @immutable final class AckSchemaModelWarning { + final String code; + + final String message; + final String? path; + final Map context; const AckSchemaModelWarning({ required this.code, required this.message, @@ -10,11 +15,6 @@ final class AckSchemaModelWarning { this.context = const {}, }); - final String code; - final String message; - final String? path; - final Map context; - Map toJson() => { 'code': code, 'message': message, @@ -26,6 +26,7 @@ final class AckSchemaModelWarning { bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! AckSchemaModelWarning) return false; + return code == other.code && message == other.message && path == other.path && diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index 0d15dbce..56b60a2f 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -21,26 +21,6 @@ final class AnyOfSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.anyOf; - - bool get _anyBranchNullable => schemas.any((s) => s.isNullable); - - @override - bool get acceptsNull => super.acceptsNull || _anyBranchNullable; - - @override - @protected - SchemaResult parseWithContext(Object? value, SchemaContext context) => - _tryBranches(value, context, parse: true); - - @override - @protected - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) => _tryBranches(value, context, parse: false); - SchemaResult _tryBranches( Object? value, SchemaContext context, { @@ -68,6 +48,7 @@ final class AnyOfSchema extends AckSchema if (result.isOk) { final v = result.getOrNull(); if (v == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(v, context); } errors.add(result.getError()); @@ -75,6 +56,7 @@ final class AnyOfSchema extends AckSchema if (parse && value == null) { if (acceptsNull) return SchemaResult.ok(null); + return failNonNullable(context); } @@ -83,6 +65,20 @@ final class AnyOfSchema extends AckSchema ); } + bool get _anyBranchNullable => schemas.any((s) => s.isNullable); + + @override + @protected + SchemaResult parseWithContext(Object? value, SchemaContext context) => + _tryBranches(value, context, parse: true); + + @override + @protected + SchemaResult validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) => _tryBranches(value, context, parse: false); + @override @protected SchemaResult encodeWithContext(Object value, SchemaContext context) { @@ -133,6 +129,7 @@ final class AnyOfSchema extends AckSchema ); } } + return SchemaResult.fail( SchemaNestedError(errors: errors, context: context), ); @@ -172,12 +169,20 @@ final class AnyOfSchema extends AckSchema if (identical(this, other)) return true; if (other is! AnyOfSchema) return false; const listEq = ListEquality(); + return baseFieldsEqual(other) && listEq.equals(schemas, other.schemas); } + @override + SchemaType get schemaType => SchemaType.anyOf; + + @override + bool get acceptsNull => super.acceptsNull || _anyBranchNullable; + @override int get hashCode { const listEq = ListEquality(); + return Object.hash(baseFieldsHashCode, listEq.hash(schemas)); } } diff --git a/packages/ack/lib/src/schemas/any_schema.dart b/packages/ack/lib/src/schemas/any_schema.dart index 8f80dbe0..9876ba4d 100644 --- a/packages/ack/lib/src/schemas/any_schema.dart +++ b/packages/ack/lib/src/schemas/any_schema.dart @@ -12,9 +12,6 @@ final class AnySchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.any; - @override @protected SchemaResult validateRuntimeWithContext( @@ -32,6 +29,7 @@ final class AnySchema extends AckSchema ), ); } + return applyConstraintsAndRefinements(value!, context); } @@ -56,9 +54,13 @@ final class AnySchema extends AckSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! AnySchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.any; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/schemas/boolean_schema.dart b/packages/ack/lib/src/schemas/boolean_schema.dart index b66c676b..5008a684 100644 --- a/packages/ack/lib/src/schemas/boolean_schema.dart +++ b/packages/ack/lib/src/schemas/boolean_schema.dart @@ -12,9 +12,6 @@ final class BooleanSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.boolean; - @override @protected SchemaResult validateRuntimeWithContext( @@ -58,9 +55,13 @@ final class BooleanSchema extends AckSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! BooleanSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.boolean; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index e7f1b4cd..8bdb7028 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -50,7 +50,7 @@ final class CodecSchema List> constraints = const [], List> refinements = const [], }) { - return CodecSchema._( + return CodecSchema._( inputSchema: inputSchema, outputSchema: outputSchema, decoder: (value) => decoder(value as InputRuntime), @@ -63,12 +63,6 @@ final class CodecSchema ); } - @override - AnyAckSchema get inner => inputSchema as AnyAckSchema; - - @override - SchemaType get schemaType => inputSchema.schemaType; - @override @protected SchemaResult parseWithContext(Object? value, SchemaContext context) { @@ -117,6 +111,7 @@ final class CodecSchema } final validated = outputResult.getOrNull()!; + return applyConstraintsAndRefinements(validated, context); } @@ -156,7 +151,7 @@ final class CodecSchema @override CodecSchema copyWithInner(AnyAckSchema newInner) { - return CodecSchema._( + return CodecSchema._( inputSchema: newInner as AckSchema, outputSchema: outputSchema, decoder: _decoder, @@ -177,7 +172,7 @@ final class CodecSchema List>? constraints, List>? refinements, }) { - return CodecSchema._( + return CodecSchema._( inputSchema: inputSchema, outputSchema: outputSchema, decoder: _decoder, @@ -194,11 +189,18 @@ final class CodecSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! CodecSchema) return false; + return baseFieldsEqual(other) && inputSchema == other.inputSchema && outputSchema == other.outputSchema; } + @override + AnyAckSchema get inner => inputSchema as AnyAckSchema; + + @override + SchemaType get schemaType => inputSchema.schemaType; + @override int get hashCode => Object.hash(baseFieldsHashCode, inputSchema, outputSchema); diff --git a/packages/ack/lib/src/schemas/default_schema.dart b/packages/ack/lib/src/schemas/default_schema.dart index a7add2ce..1e5519f2 100644 --- a/packages/ack/lib/src/schemas/default_schema.dart +++ b/packages/ack/lib/src/schemas/default_schema.dart @@ -21,23 +21,36 @@ final class DefaultSchema super.description, }); - @override - SchemaType get schemaType => inner.schemaType; - - // DefaultSchema is treated as optional/nullable based on inner so callers - // can use it as a property without further annotation. - @override - bool get isNullable => super.isNullable || inner.isNullable; - - @override - bool get isOptional => super.isOptional || inner.isOptional; - - // Constraints and refinements live on the wrapped schema. - @override - List> get constraints => inner.constraints; + /// Resolves and validates this schema's runtime default value. + /// + /// Defaults are runtime values rather than boundary values, so callers that + /// need a default should use this method instead of parsing `null`. + @internal + SchemaResult resolveDefaultWithContext(SchemaContext context) { + // Defaults are runtime values, not boundary values, so validate via the + // runtime path. `cloneDefault` returns unmodifiable collection copies when + // it can; mutable collection defaults are rejected if the inner schema + // would otherwise return the original reference. + final cloned = cloneDefault(defaultValue); + final clonedSafely = cloned is Runtime && !identical(cloned, defaultValue); + final effective = (cloned is Runtime) ? cloned : defaultValue; + final result = inner.validateRuntimeWithContext(effective, context); + if (result.isOk && + !clonedSafely && + _isCollectionDefault(defaultValue) && + identical(result.getOrNull(), defaultValue)) { + return SchemaResult.fail( + SchemaValidationError( + message: + 'Default collection value for $runtimeType could not be ' + 'cloned safely as $Runtime.', + context: context, + ), + ); + } - @override - List> get refinements => inner.refinements; + return result; + } @override @protected @@ -45,6 +58,7 @@ final class DefaultSchema if (value == null) { return resolveDefaultWithContext(context); } + return inner.parseWithContext(value, context); } @@ -68,7 +82,7 @@ final class DefaultSchema @override DefaultSchema copyWithInner(AnyAckSchema newInner) { - return DefaultSchema( + return DefaultSchema( inner: newInner as AckSchema, defaultValue: defaultValue, isNullable: super.isNullable, @@ -92,7 +106,7 @@ final class DefaultSchema refinements: refinements, ); - return DefaultSchema( + return DefaultSchema( inner: updatedInner, defaultValue: defaultValue, isNullable: isNullable ?? super.isNullable, @@ -105,6 +119,7 @@ final class DefaultSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! DefaultSchema) return false; + return inner == other.inner && defaultValue == other.defaultValue && isNullable == other.isNullable && @@ -112,39 +127,27 @@ final class DefaultSchema description == other.description; } + @override + SchemaType get schemaType => inner.schemaType; + + // DefaultSchema is treated as optional/nullable based on inner so callers + // can use it as a property without further annotation. + @override + bool get isNullable => super.isNullable || inner.isNullable; + + @override + bool get isOptional => super.isOptional || inner.isOptional; + + // Constraints and refinements live on the wrapped schema. + @override + List> get constraints => inner.constraints; + + @override + List> get refinements => inner.refinements; + @override int get hashCode => Object.hash(inner, defaultValue, isNullable, isOptional, description); - - /// Resolves and validates this schema's runtime default value. - /// - /// Defaults are runtime values rather than boundary values, so callers that - /// need a default should use this method instead of parsing `null`. - @internal - SchemaResult resolveDefaultWithContext(SchemaContext context) { - // Defaults are runtime values, not boundary values, so validate via the - // runtime path. `cloneDefault` returns unmodifiable collection copies when - // it can; mutable collection defaults are rejected if the inner schema - // would otherwise return the original reference. - final cloned = cloneDefault(defaultValue); - final clonedSafely = cloned is Runtime && !identical(cloned, defaultValue); - final effective = (cloned is Runtime) ? cloned : defaultValue; - final result = inner.validateRuntimeWithContext(effective, context); - if (result.isOk && - !clonedSafely && - _isCollectionDefault(defaultValue) && - identical(result.getOrNull(), defaultValue)) { - return SchemaResult.fail( - SchemaValidationError( - message: - 'Default collection value for $runtimeType could not be ' - 'cloned safely as $Runtime.', - context: context, - ), - ); - } - return result; - } } bool _isCollectionDefault(Object value) => diff --git a/packages/ack/lib/src/schemas/enum_schema.dart b/packages/ack/lib/src/schemas/enum_schema.dart index 99309639..2a5c1dba 100644 --- a/packages/ack/lib/src/schemas/enum_schema.dart +++ b/packages/ack/lib/src/schemas/enum_schema.dart @@ -16,9 +16,6 @@ final class EnumSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.enum_; - @override @protected SchemaResult parseWithContext(Object? value, SchemaContext context) { @@ -93,6 +90,7 @@ final class EnumSchema extends AckSchema ), ); } + return applyConstraintsAndRefinements(value, context); } @@ -101,6 +99,7 @@ final class EnumSchema extends AckSchema SchemaResult encodeWithContext(T value, SchemaContext context) { final validated = validateRuntimeWithContext(value, context); if (validated.isFail) return SchemaResult.fail(validated.getError()); + return SchemaResult.ok(value.name); } @@ -112,7 +111,7 @@ final class EnumSchema extends AckSchema List>? constraints, List>? refinements, }) { - return EnumSchema( + return EnumSchema( values: values, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, @@ -127,12 +126,17 @@ final class EnumSchema extends AckSchema if (identical(this, other)) return true; if (other is! EnumSchema) return false; final listEq = ListEquality(); + return baseFieldsEqual(other) && listEq.equals(values, other.values); } + @override + SchemaType get schemaType => SchemaType.enum_; + @override int get hashCode { final listEq = ListEquality(); + return Object.hash(baseFieldsHashCode, listEq.hash(values)); } } @@ -153,6 +157,7 @@ class _EnumValuesConstraint extends Constraint { if (other is! _EnumValuesConstraint) return false; if (runtimeType != other.runtimeType) return false; const listEq = ListEquality(); + return constraintKey == other.constraintKey && description == other.description && listEq.equals(allowed, other.allowed); @@ -161,6 +166,7 @@ class _EnumValuesConstraint extends Constraint { @override int get hashCode { const listEq = ListEquality(); + return Object.hash( runtimeType, constraintKey, diff --git a/packages/ack/lib/src/schemas/extensions/ack_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/ack_schema_extensions.dart index 8ceb9fd0..23e51901 100644 --- a/packages/ack/lib/src/schemas/extensions/ack_schema_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/ack_schema_extensions.dart @@ -31,7 +31,7 @@ extension AckSchemaExtensions required Runtime Function(R value) encode, AckSchema? output, }) { - return CodecSchema.create( + return CodecSchema.create( inputSchema: this, outputSchema: output ?? InstanceSchema(), decoder: decode, diff --git a/packages/ack/lib/src/schemas/extensions/object_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/object_schema_extensions.dart index cd332eab..47cbf2a7 100644 --- a/packages/ack/lib/src/schemas/extensions/object_schema_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/object_schema_extensions.dart @@ -17,6 +17,7 @@ extension ObjectSchemaExtensions on ObjectSchema { /// Merges this schema with another [ObjectSchema]. ObjectSchema merge(ObjectSchema other) { final newProperties = {...properties, ...other.properties}; + return copyWith(properties: newProperties); } diff --git a/packages/ack/lib/src/schemas/fluent_schema.dart b/packages/ack/lib/src/schemas/fluent_schema.dart index afcda279..85bb77e6 100644 --- a/packages/ack/lib/src/schemas/fluent_schema.dart +++ b/packages/ack/lib/src/schemas/fluent_schema.dart @@ -65,6 +65,7 @@ mixin FluentSchema< String message = 'The value did not pass the custom validation.', }) { final newRefinement = (validate: validate, message: message); + return copyWith(refinements: [...refinements, newRefinement]); } diff --git a/packages/ack/lib/src/schemas/instance_schema.dart b/packages/ack/lib/src/schemas/instance_schema.dart index f634f248..978d0cf0 100644 --- a/packages/ack/lib/src/schemas/instance_schema.dart +++ b/packages/ack/lib/src/schemas/instance_schema.dart @@ -22,9 +22,6 @@ final class InstanceSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.any; - @override @protected SchemaResult validateRuntimeWithContext( @@ -41,6 +38,7 @@ final class InstanceSchema extends AckSchema ), ); } + return applyConstraintsAndRefinements(value, context); } @@ -52,7 +50,7 @@ final class InstanceSchema extends AckSchema List>? constraints, List>? refinements, }) { - return InstanceSchema( + return InstanceSchema( isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -65,9 +63,13 @@ final class InstanceSchema extends AckSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! InstanceSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.any; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index ba7617dc..c6cd6e62 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -9,6 +9,13 @@ part of 'schema.dart'; final class LazySchema extends AckSchema with FluentSchema> { + /// Human-readable name for this deferred schema reference. + final String name; + + final AckSchema Function() _builder; + + late final AckSchema _target = _builder(); + LazySchema( this.name, this._builder, { @@ -19,13 +26,6 @@ final class LazySchema super.refinements, }); - /// Human-readable name for this deferred schema reference. - final String name; - - final AckSchema Function() _builder; - - late final AckSchema _target = _builder(); - @internal AckSchema get target => _target; @@ -35,9 +35,6 @@ final class LazySchema @internal int get runtimeRefinementCount => _refinements.length; - @override - SchemaType get schemaType => SchemaType.lazy; - @override @protected SchemaResult parseWithContext(Object? value, SchemaContext context) { @@ -49,6 +46,7 @@ final class LazySchema final runtime = result.getOrNull(); if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); } @@ -66,6 +64,7 @@ final class LazySchema final runtime = result.getOrNull(); if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); } @@ -77,6 +76,7 @@ final class LazySchema ) { final ownChecked = applyConstraintsAndRefinements(value, context); if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError()); + return _target.encodeWithContext(ownChecked.getOrThrow()!, context); } @@ -88,7 +88,7 @@ final class LazySchema List>? constraints, List>? refinements, }) { - return LazySchema( + return LazySchema( name, _builder, isNullable: isNullable ?? this.isNullable, @@ -106,11 +106,15 @@ final class LazySchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! LazySchema) return false; + return baseFieldsEqual(other) && name == other.name && identical(_builder, other._builder); } + @override + SchemaType get schemaType => SchemaType.lazy; + @override int get hashCode { return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder)); diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index 129f356c..eb2bb78d 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -30,23 +30,6 @@ final class ListSchema } } - @override - SchemaType get schemaType => SchemaType.array; - - @override - @protected - SchemaResult> parseWithContext( - Object? value, - SchemaContext context, - ) => _processItems(value, context, parse: true); - - @override - @protected - SchemaResult> validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) => _processItems(value, context, parse: false); - SchemaResult> _processItems( Object? value, SchemaContext context, { @@ -108,6 +91,20 @@ final class ListSchema ); } + @override + @protected + SchemaResult> parseWithContext( + Object? value, + SchemaContext context, + ) => _processItems(value, context, parse: true); + + @override + @protected + SchemaResult> validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) => _processItems(value, context, parse: false); + @override @protected SchemaResult> encodeWithContext( @@ -161,6 +158,7 @@ final class ListSchema SchemaNestedError(errors: errors, context: context), ); } + return SchemaResult.ok(List.unmodifiable(encoded)); } @@ -172,7 +170,7 @@ final class ListSchema List>>? constraints, List>>? refinements, }) { - return ListSchema( + return ListSchema( itemSchema, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, @@ -197,9 +195,13 @@ final class ListSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! ListSchema) return false; + return baseFieldsEqual(other) && itemSchema == other.itemSchema; } + @override + SchemaType get schemaType => SchemaType.array; + @override int get hashCode => Object.hash(baseFieldsHashCode, itemSchema); } diff --git a/packages/ack/lib/src/schemas/num_schema.dart b/packages/ack/lib/src/schemas/num_schema.dart index 747281cd..91336a82 100644 --- a/packages/ack/lib/src/schemas/num_schema.dart +++ b/packages/ack/lib/src/schemas/num_schema.dart @@ -20,6 +20,7 @@ sealed class NumSchema extends AckSchema { if (value is double && !value.isFinite) { final constraint = NumberFiniteConstraint(); final error = constraint.validate(value); + return SchemaResult.fail( SchemaConstraintsError( constraints: error != null ? [error] : const [], @@ -46,9 +47,6 @@ final class IntegerSchema extends NumSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.integer; - @override @protected SchemaResult validateRuntimeWithContext( @@ -67,6 +65,7 @@ final class IntegerSchema extends NumSchema ), ); } + return applyConstraintsAndRefinements(value, context); } @@ -91,9 +90,13 @@ final class IntegerSchema extends NumSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! IntegerSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.integer; + @override int get hashCode => baseFieldsHashCode; } @@ -112,9 +115,6 @@ final class DoubleSchema extends NumSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.number; - @override @protected SchemaResult validateRuntimeWithContext( @@ -133,6 +133,7 @@ final class DoubleSchema extends NumSchema ), ); } + return applyConstraintsAndRefinements(value, context); } @@ -157,9 +158,13 @@ final class DoubleSchema extends NumSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! DoubleSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.number; + @override int get hashCode => baseFieldsHashCode; } @@ -178,9 +183,6 @@ final class NumberSchema extends NumSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.number; - @override @protected SchemaResult validateRuntimeWithContext( @@ -198,6 +200,7 @@ final class NumberSchema extends NumSchema ), ); } + return applyConstraintsAndRefinements(value, context); } @@ -222,9 +225,13 @@ final class NumberSchema extends NumSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! NumberSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.number; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index 2af1ac39..8342661e 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -12,9 +12,6 @@ final class StringSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.string; - @override @protected SchemaResult validateRuntimeWithContext( @@ -58,9 +55,13 @@ final class StringSchema extends AckSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! StringSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.string; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/schemas/testing/testing_schemas.dart b/packages/ack/lib/src/schemas/testing/testing_schemas.dart index 2ee4ba3d..7505329d 100644 --- a/packages/ack/lib/src/schemas/testing/testing_schemas.dart +++ b/packages/ack/lib/src/schemas/testing/testing_schemas.dart @@ -13,9 +13,6 @@ final class TestUnsupportedAckSchema extends AckSchema super.refinements, }); - @override - SchemaType get schemaType => SchemaType.any; - @override @protected SchemaResult validateRuntimeWithContext( @@ -24,6 +21,7 @@ final class TestUnsupportedAckSchema extends AckSchema ) { final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; + return applyConstraintsAndRefinements(value!, context); } @@ -48,9 +46,13 @@ final class TestUnsupportedAckSchema extends AckSchema bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! TestUnsupportedAckSchema) return false; + return baseFieldsEqual(other); } + @override + SchemaType get schemaType => SchemaType.any; + @override int get hashCode => baseFieldsHashCode; } diff --git a/packages/ack/lib/src/utils/collection_utils.dart b/packages/ack/lib/src/utils/collection_utils.dart index 6b78b8f2..f713518a 100644 --- a/packages/ack/lib/src/utils/collection_utils.dart +++ b/packages/ack/lib/src/utils/collection_utils.dart @@ -27,6 +27,7 @@ bool deepEquals(Object? a, Object? b) { for (var i = 0; i < a.length; i++) { if (!deepEquals(a[i], b[i])) return false; } + return true; } @@ -44,6 +45,7 @@ bool deepEquals(Object? a, Object? b) { } if (!found) return false; } + return true; } @@ -54,6 +56,7 @@ bool deepEquals(Object? a, Object? b) { if (!b.containsKey(key)) return false; if (!deepEquals(a[key], b[key])) return false; } + return true; } @@ -65,6 +68,7 @@ bool deepEquals(Object? a, Object? b) { if (!iterB.moveNext()) return false; if (!deepEquals(iterA.current, iterB.current)) return false; } + return !iterB.moveNext(); // Ensure b has no more elements } diff --git a/packages/ack/lib/src/utils/default_utils.dart b/packages/ack/lib/src/utils/default_utils.dart index b2f054cf..39bebd0f 100644 --- a/packages/ack/lib/src/utils/default_utils.dart +++ b/packages/ack/lib/src/utils/default_utils.dart @@ -27,6 +27,7 @@ Object? cloneDefault(Object? value) { value.forEach((key, entryValue) { cloned[key as String] = cloneDefault(entryValue); }); + return Map.unmodifiable(cloned); } @@ -34,6 +35,7 @@ Object? cloneDefault(Object? value) { value.forEach((key, entryValue) { cloned[key] = cloneDefault(entryValue); }); + return Map.unmodifiable(cloned); } diff --git a/packages/ack/lib/src/utils/json_utils.dart b/packages/ack/lib/src/utils/json_utils.dart index b254c01c..ce7a8875 100644 --- a/packages/ack/lib/src/utils/json_utils.dart +++ b/packages/ack/lib/src/utils/json_utils.dart @@ -18,7 +18,7 @@ Map deepMerge( Map map1, Map map2, ) { - final result = Map.from(map1); + final result = Map.of(map1); for (final key in map2.keys) { final value1 = result[key]; final value2 = map2[key]; diff --git a/packages/ack/lib/src/utils/string_utils.dart b/packages/ack/lib/src/utils/string_utils.dart index e2123c70..49db1f9b 100644 --- a/packages/ack/lib/src/utils/string_utils.dart +++ b/packages/ack/lib/src/utils/string_utils.dart @@ -89,6 +89,7 @@ String buildDidYouMeanSuggestion(String value, List allowedValues) { if (closest != null && closest != value) { return ' Did you mean "$closest"?'; } + return ''; } @@ -109,8 +110,8 @@ int _levenshteinDistance(String s1, String s2) { if (s1.isEmpty) return s2.length; if (s2.isEmpty) return s1.length; - List v0 = List.filled(s2.length + 1, 0, growable: false); - List v1 = List.filled(s2.length + 1, 0, growable: false); + List v0 = List.filled(s2.length + 1, 0, growable: false); + List v1 = List.filled(s2.length + 1, 0, growable: false); for (int i = 0; i <= s2.length; i++) { v0[i] = i; diff --git a/packages/ack/lib/src/validation/schema_error.dart b/packages/ack/lib/src/validation/schema_error.dart index b90b6406..168d251a 100644 --- a/packages/ack/lib/src/validation/schema_error.dart +++ b/packages/ack/lib/src/validation/schema_error.dart @@ -51,6 +51,9 @@ abstract class SchemaError { @immutable class TypeMismatchError extends SchemaError { + final SchemaType _expectedJsonType; + + final SchemaType _actualJsonType; TypeMismatchError({ required SchemaType expectedType, required SchemaType actualType, @@ -62,9 +65,6 @@ class TypeMismatchError extends SchemaError { context: context, ); - final SchemaType _expectedJsonType; - final SchemaType _actualJsonType; - String get expectedType => _expectedJsonType.typeName; String get actualType => _actualJsonType.typeName; @@ -97,6 +97,7 @@ class SchemaConstraintsError extends SchemaError { return constraintError; } } + return null; } diff --git a/packages/ack/lib/src/validation/standard_issues.dart b/packages/ack/lib/src/validation/standard_issues.dart index 1e29b269..ab61b8d3 100644 --- a/packages/ack/lib/src/validation/standard_issues.dart +++ b/packages/ack/lib/src/validation/standard_issues.dart @@ -5,11 +5,10 @@ import 'schema_error.dart'; extension StandardIssueConversion on SchemaError { List toStandardIssues() { return switch (this) { - SchemaNestedError(errors: final errors) when errors.isNotEmpty => [ + SchemaNestedError(:final errors) when errors.isNotEmpty => [ for (final error in errors) ...error.toStandardIssues(), ], - SchemaConstraintsError(constraints: final constraints) - when constraints.isNotEmpty => + SchemaConstraintsError(:final constraints) when constraints.isNotEmpty => [ for (final constraint in constraints) StandardIssue( From 19a89089b5c13a750b976d5ddcf23fc742bec3b6 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 12 Jun 2026 14:39:07 -0400 Subject: [PATCH 08/13] feat: add standalone standard schema package --- .github/workflows/release.yml | 1 - PUBLISHING.md | 13 +- README.md | 1 - packages/ack/CHANGELOG.md | 28 +- packages/ack/lib/ack.dart | 5 - packages/ack/lib/src/context.dart | 69 +-- .../src/schema_model/ack_schema_model.dart | 6 - .../schema_model/ack_schema_model_parser.dart | 460 ------------------ .../ack/lib/src/schemas/any_of_schema.dart | 4 +- .../schemas/discriminated_object_schema.dart | 12 +- packages/ack/lib/src/schemas/list_schema.dart | 4 +- .../ack/lib/src/schemas/object_schema.dart | 16 +- packages/ack/lib/src/schemas/schema.dart | 42 +- .../lib/src/validation/standard_issues.dart | 22 - packages/ack/pubspec.yaml | 1 - .../ack_schema_model_parser_test.dart | 219 --------- .../standard_schema_conformance_test.dart | 158 ------ .../standard_schema_dotpath_test.dart | 28 -- .../standard_schema_export_test.dart | 30 -- ...irebase_ai_native_schema_fixture_test.dart | 5 +- packages/standard_schema/CHANGELOG.md | 10 +- packages/standard_schema/README.md | 11 +- .../example/standard_schema_example.dart | 93 ++++ .../lib/src/standard_schema.dart | 35 +- packages/standard_schema/lib/src/utils.dart | 15 +- .../test/standard_schema_test.dart | 105 +++- packages/standard_schema/test/utils_test.dart | 66 ++- scripts/update_release_changelog.dart | 1 - 28 files changed, 319 insertions(+), 1141 deletions(-) delete mode 100644 packages/ack/lib/src/schema_model/ack_schema_model_parser.dart delete mode 100644 packages/ack/lib/src/validation/standard_issues.dart delete mode 100644 packages/ack/test/schema_model/ack_schema_model_parser_test.dart delete mode 100644 packages/ack/test/validation/standard_schema_conformance_test.dart delete mode 100644 packages/ack/test/validation/standard_schema_dotpath_test.dart delete mode 100644 packages/ack/test/validation/standard_schema_export_test.dart create mode 100644 packages/standard_schema/example/standard_schema_example.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2268ab0b..326ba7d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,4 +21,3 @@ jobs: ack_generator ack_json_schema_builder ack_firebase_ai - standard_schema diff --git a/PUBLISHING.md b/PUBLISHING.md index d599ceaf..515d4ab5 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -19,7 +19,7 @@ Before creating a release: 1. Ensure all changes are committed and pushed to the `main` branch 2. Verify that all tests pass by running `melos test` (include `melos run validate-jsonschema` and `melos run test:gen` for full coverage) 3. Check that the documentation is up to date across the repo and docs site -4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`, `standard_schema`) +4. Decide on the new version number following [Semantic Versioning](https://semver.org/) and apply it consistently to every publishable package (`ack`, `ack_annotations`, `ack_generator`, `ack_firebase_ai`, `ack_json_schema_builder`) 5. Ensure package CHANGELOG entries are finalized before tagging. If you want a link-only entry for a version, you can run `dart scripts/update_release_changelog.dart [tag]` after `melos version`. ### 2. Create a GitHub Release @@ -101,7 +101,7 @@ If you need to publish packages manually: ```bash # Dry-run each package (validation only) -for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai standard_schema; do +for pkg in ack ack_annotations ack_generator ack_json_schema_builder ack_firebase_ai; do (cd packages/$pkg && dart pub publish --dry-run) || exit 1 done @@ -109,15 +109,6 @@ done melos run publish ``` -### First publish for `standard_schema` - -`standard_schema` is a new package. Pub.dev automated publishing can only be -enabled after the package exists, so the first version must be published -manually with `dart pub publish` from `packages/standard_schema` before the -release tag. After that first upload, enable GitHub Actions publishing in the -package's pub.dev admin settings so future tagged releases can use the -workflow. - ## Troubleshooting ### Release Workflow Fails diff --git a/README.md b/README.md index 7a96753d..23006a05 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). This repository is a monorepo containing: - **[ack](./packages/ack)**: Core validation library with fluent schema building API -- **[standard_schema](./packages/standard_schema)**: Standard Schema contracts for Dart validators and converters - **[ack_generator](./packages/ack_generator)**: Code generator for `@AckType()` extension-type wrappers - **[ack_firebase_ai](./packages/ack_firebase_ai)**: Firebase AI (Gemini) schema converter for structured output generation - **[example](./example)**: Example projects demonstrating usage of all packages diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 018c040a..de83083e 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,4 +1,4 @@ -## 1.0.0-beta.12-wip +## Unreleased ### Breaking Changes @@ -9,9 +9,6 @@ * Replace the interim JSON Schema model kind API with sealed `AckSchemaModel` variants and canonical `AckSchema.toSchemaModel()` adapter conversion. -* `SchemaContext.createChild(pathSegment: ...)` now takes a - `SchemaPathSegment` so standard issue paths can distinguish string object - keys from integer list indexes. ### Added @@ -23,39 +20,18 @@ * `NumberSchemaExtensions` adds fluent numeric constraints to `Ack.number()`: `.min`, `.max`, `.greaterThan`, `.lessThan`, `.positive`, `.negative`, and `.multipleOf`. -* `AckSchema.standard` implements the combined `StandardSchemaWithJsonSchema` - contract with flat `StandardIssue` failures and a Draft-7 JSON Schema - converter. -* `SchemaContext.pathSegments` exposes raw path segments with list indices as - integers while preserving numeric-looking object keys as strings, and - `SchemaError.toStandardIssues()` maps ACK errors to standard issues. -* `AckSchemaModel.fromJsonSchema(...)` imports supported Draft-7 JSON Schema - maps into ACK's existing schema model as a best-effort Ack feature. -* `package:ack/ack.dart` now re-exports the `standard_schema` contract types - (`StandardSchema`, `StandardResult`, `StandardIssue`, `JsonSchemaTarget`, - …) so they can be used without depending on `standard_schema` directly. ### Changed * Project discriminated schemas through union-owned discriminator branches. * Preserve defaults, const values, extension keywords, transformed metadata, - composition, and JSON Schema constraints through the `AckSchemaModel` - boundary. + composition, and JSON Schema constraints through the schema model boundary. ### Migration * Re-run tests for code paths that parse or encode `double`/`num` values. If a boundary must accept `NaN` or infinities, model that value outside the JSON numeric schema path before validation. -* Custom schema authors and manual `SchemaContext` callers should replace raw - `pathSegment` strings with typed segments: - - ```dart - // Migrating manual SchemaContext.createChild(pathSegment: ...) calls: - pathSegment: SchemaPathSegment.property(key) // object key (String) - pathSegment: SchemaPathSegment.index(i) // list index (int) - pathSegment: const SchemaPathSegment.passThrough() // composition branch routing - ``` ## 1.0.0-beta.11 diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 90a03843..06b66251 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -33,10 +33,5 @@ export 'src/schemas/schema.dart' hide AnyAckSchema, Refinement, SchemaOperation, WrapperSchema; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; -export 'src/validation/standard_issues.dart'; -// Re-export the Standard Schema contract types so consumers of package:ack -// can name AckSchema.standard's result/contract types without a separate -// dependency on package:standard_schema. -export 'package:standard_schema/standard_schema.dart'; // Validation results export 'src/validation/schema_result.dart'; diff --git a/packages/ack/lib/src/context.dart b/packages/ack/lib/src/context.dart index 653ee4c4..118e27b1 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -2,49 +2,6 @@ import 'package:meta/meta.dart'; import 'schemas/schema.dart'; -enum _SchemaPathSegmentKind { property, listIndex, passThrough } - -/// A typed path segment used by [SchemaContext]. -/// -/// String object keys and integer list indexes must stay distinct for standard -/// issue paths. Use [SchemaPathSegment.passThrough] for composition branches -/// that should not add a user-visible path segment. -@immutable -final class SchemaPathSegment { - final _SchemaPathSegmentKind _kind; - - final Object? _value; - - const SchemaPathSegment.property(String key) - : _kind = _SchemaPathSegmentKind.property, - _value = key; - - const SchemaPathSegment.index(int index) - : assert(index >= 0, 'List path indexes must be non-negative.'), - _kind = _SchemaPathSegmentKind.listIndex, - _value = index; - - const SchemaPathSegment.passThrough() - : _kind = _SchemaPathSegmentKind.passThrough, - _value = null; - - Object? get _issueValue { - return switch (_kind) { - _SchemaPathSegmentKind.property => _value as String, - _SchemaPathSegmentKind.listIndex => _value as int, - _SchemaPathSegmentKind.passThrough => null, - }; - } - - String? get _jsonPointerValue { - return switch (_kind) { - _SchemaPathSegmentKind.property => _value as String, - _SchemaPathSegmentKind.listIndex => (_value as int).toString(), - _SchemaPathSegmentKind.passThrough => null, - }; - } -} - /// Represents the context in which a schema operation is occurring. @immutable class SchemaContext { @@ -52,7 +9,7 @@ class SchemaContext { final Object? value; final AnyAckSchema schema; final SchemaContext? parent; - final SchemaPathSegment? pathSegment; + final String? pathSegment; final SchemaOperation operation; const SchemaContext({ @@ -77,34 +34,18 @@ class SchemaContext { final parentPath = parent!.path; - final segment = pathSegment ?? SchemaPathSegment.property(name); - final pointerValue = segment._jsonPointerValue; - if (pointerValue == null) { + if (pathSegment == '') { return parentPath; } - final escapedSegment = _escapeJsonPointerSegment(pointerValue); + final segment = pathSegment ?? name; + final escapedSegment = _escapeJsonPointerSegment(segment); return parentPath == '#' ? '#/$escapedSegment' : '$parentPath/$escapedSegment'; } - /// Raw path segments from root to this context. - /// - /// Object keys are exposed as strings and list indexes as integers. Branch - /// pass-through segments do not add to the path. - List get pathSegments { - final parentSegments = parent?.pathSegments ?? const []; - if (parent == null) return parentSegments; - - final segment = pathSegment ?? SchemaPathSegment.property(name); - final pathValue = segment._issueValue; - if (pathValue == null) return parentSegments; - - return [...parentSegments, pathValue]; - } - /// Creates a child context for nested validation. /// /// The child inherits the parent's [operation] unless overridden. @@ -112,7 +53,7 @@ class SchemaContext { required String name, required AnyAckSchema schema, required Object? value, - SchemaPathSegment? pathSegment, + String? pathSegment, SchemaOperation? operation, }) { return SchemaContext( diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 8aa9f587..25893fee 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -3,8 +3,6 @@ import 'package:meta/meta.dart'; import 'ack_schema_model_warning.dart'; -part 'ack_schema_model_parser.dart'; - const _unset = Object(); const _nullSchemaJson = {'type': 'null'}; @@ -137,10 +135,6 @@ sealed class AckSchemaModel { this.extensions = const {}, }); - factory AckSchemaModel.fromJsonSchema(Map json) { - return _JsonSchemaParser().parse(json); - } - AckSchemaModel._(_AckSchemaModelCommon common) : title = common.title, description = common.description, diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart b/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart deleted file mode 100644 index efe95016..00000000 --- a/packages/ack/lib/src/schema_model/ack_schema_model_parser.dart +++ /dev/null @@ -1,460 +0,0 @@ -part of 'ack_schema_model.dart'; - -final class _JsonSchemaParser { - AckSchemaModel _parse( - Map json, { - required String path, - bool nullable = false, - }) { - final nullableUnion = _readNullableUnion(json); - if (nullableUnion != null) { - final parsed = _parse( - nullableUnion.schema, - path: _joinPath(path, 'anyOf/${nullableUnion.schemaIndex}'), - nullable: true, - ); - - return _applyKeywords(parsed, json, const {'anyOf'}); - } - - final wrappedRefName = _readWrappedDefinitionsRef(json); - if (wrappedRefName != null) { - return _applyKeywords( - AckRefSchemaModel(refName: wrappedRefName, nullable: nullable), - json, - const {'allOf'}, - ); - } - - if (json[r'$ref'] case final String ref) { - final refName = _definitionsRefName(ref); - if (refName != null) { - return _applyKeywords( - AckRefSchemaModel(refName: refName, nullable: nullable), - json, - const {r'$ref'}, - ); - } - - return _fallback( - json, - path: path, - nullable: nullable, - warning: _warning( - code: 'unsupported_ref', - message: - 'Only local #/definitions/ JSON Schema references can be imported.', - path: path, - context: {'ref': ref}, - ), - ); - } - - if (json['type'] case final List types) { - return _parseTypeList(json, types, path: path, nullable: nullable); - } - - if (json['anyOf'] case final List schemas) { - return _parseComposition( - 'anyOf', - schemas, - json, - path: path, - nullable: nullable, - ); - } - if (json['oneOf'] case final List schemas) { - return _parseComposition( - 'oneOf', - schemas, - json, - path: path, - nullable: nullable, - ); - } - if (json['allOf'] case final List schemas) { - return _parseComposition( - 'allOf', - schemas, - json, - path: path, - nullable: nullable, - ); - } - - if (json['type'] case final String type) { - return switch (type) { - 'string' => _applyKeywords( - AckStringSchemaModel(nullable: nullable), - json, - const {'type'}, - ), - 'integer' => _applyKeywords( - AckIntegerSchemaModel(nullable: nullable), - json, - const {'type'}, - ), - 'number' => _applyKeywords( - AckNumberSchemaModel(nullable: nullable), - json, - const {'type'}, - ), - 'boolean' => _applyKeywords( - AckBooleanSchemaModel(nullable: nullable), - json, - const {'type'}, - ), - 'array' => _parseArray(json, path: path, nullable: nullable), - 'object' => _parseObject(json, path: path, nullable: nullable), - 'null' => _applyKeywords(const AckNullSchemaModel(), json, const { - 'type', - }), - _ => _fallback( - json, - path: path, - nullable: nullable, - warning: _warning( - code: 'unsupported_type', - message: 'JSON Schema type "$type" is not supported.', - path: path, - context: {'type': type}, - ), - ), - }; - } - - return _fallback( - json, - path: path, - nullable: nullable, - warning: _warning( - code: 'unsupported_schema_shape', - message: 'JSON Schema shape could not be mapped to a AckSchemaModel.', - path: path, - ), - ); - } - - AckSchemaModel _parseArray( - Map json, { - required String path, - required bool nullable, - }) { - final warnings = []; - AckSchemaModel? items; - - if (json.containsKey('items')) { - final itemMap = _asStringMap(json['items']); - if (itemMap != null) { - items = _parse(itemMap, path: _joinPath(path, 'items')); - } else { - warnings.add( - _warning( - code: 'unsupported_items_schema', - message: - 'Boolean or non-object JSON Schema items are not imported.', - path: _joinPath(path, 'items'), - ), - ); - } - } - - return _applyKeywords( - AckArraySchemaModel(items: items, nullable: nullable, warnings: warnings), - json, - const {'type', 'items'}, - ); - } - - AckSchemaModel _parseObject( - Map json, { - required String path, - required bool nullable, - }) { - final warnings = []; - Map? properties; - List? required; - List? propertyOrdering; - AckAdditionalPropertiesModel? additionalProperties; - - final propertiesJson = _asStringMap(json['properties']); - if (propertiesJson != null) { - final parsed = {}; - for (final entry in propertiesJson.entries) { - final propertyJson = _asStringMap(entry.value); - parsed[entry.key] = propertyJson == null - ? _fallback( - const {}, - path: _joinPath(path, 'properties/${entry.key}'), - warning: _warning( - code: 'unsupported_property_schema', - message: 'Object property schemas must be JSON objects.', - path: _joinPath(path, 'properties/${entry.key}'), - ), - ) - : _parse( - propertyJson, - path: _joinPath(path, 'properties/${entry.key}'), - ); - } - properties = parsed.isEmpty ? null : parsed; - propertyOrdering = parsed.keys.toList(growable: false); - } - - if (json['required'] case final List values) { - required = [ - for (final value in values) - if (value is String) value, - ]; - if (required.isEmpty) required = null; - } - - if (json.containsKey('additionalProperties')) { - switch (json['additionalProperties']) { - case true: - additionalProperties = const AckAdditionalPropertiesAllowed(); - case false: - additionalProperties = const AckAdditionalPropertiesDisallowed(); - case final Object? value when _asStringMap(value) != null: - additionalProperties = AckAdditionalPropertiesSchema( - _parse( - _asStringMap(value)!, - path: _joinPath(path, 'additionalProperties'), - ), - ); - default: - warnings.add( - _warning( - code: 'unsupported_additional_properties', - message: - 'additionalProperties must be a boolean or JSON Schema object.', - path: _joinPath(path, 'additionalProperties'), - ), - ); - } - } - - return _applyKeywords( - AckObjectSchemaModel( - properties: properties, - required: required, - propertyOrdering: propertyOrdering, - additionalProperties: additionalProperties, - nullable: nullable, - warnings: warnings, - ), - json, - const {'type', 'properties', 'required', 'additionalProperties'}, - ); - } - - AckSchemaModel _parseComposition( - String keyword, - List schemas, - Map json, { - required String path, - required bool nullable, - }) { - final parsedSchemas = []; - final warnings = []; - - for (var i = 0; i < schemas.length; i += 1) { - final schema = _asStringMap(schemas[i]); - if (schema == null) { - warnings.add( - _warning( - code: 'unsupported_composition_branch', - message: 'Composition branches must be JSON Schema objects.', - path: _joinPath(path, '$keyword/$i'), - ), - ); - continue; - } - parsedSchemas.add(_parse(schema, path: _joinPath(path, '$keyword/$i'))); - } - - final model = switch (keyword) { - 'anyOf' => AckAnyOfSchemaModel( - schemas: parsedSchemas, - nullable: nullable, - warnings: warnings, - ), - 'oneOf' => AckOneOfSchemaModel( - schemas: parsedSchemas, - nullable: nullable, - warnings: warnings, - ), - 'allOf' => AckAllOfSchemaModel( - schemas: parsedSchemas, - nullable: nullable, - warnings: warnings, - ), - _ => throw StateError('Unsupported composition keyword $keyword'), - }; - - return _applyKeywords(model, json, {keyword}); - } - - AckSchemaModel _parseTypeList( - Map json, - List types, { - required String path, - required bool nullable, - }) { - final hasNull = types.contains('null'); - final schemas = []; - - for (final type in types) { - if (type is! String || type == 'null') continue; - schemas.add(_parse({...json, 'type': type}, path: path)); - } - - final warning = _warning( - code: 'unsupported_type_array', - message: - 'JSON Schema type arrays are imported as a best-effort composition.', - path: path, - context: {'type': types}, - ); - - if (schemas.isEmpty) { - return _fallback( - json, - path: path, - nullable: nullable || hasNull, - ).withWarnings([warning]); - } - - return AckAnyOfSchemaModel( - schemas: schemas, - nullable: nullable || hasNull, - warnings: [warning], - ); - } - - AckSchemaModel _fallback( - Map json, { - required String path, - bool nullable = false, - AckSchemaModelWarning? warning, - }) { - final fallback = AckAnyOfSchemaModel( - schemas: const [ - AckStringSchemaModel(), - AckNumberSchemaModel(), - AckIntegerSchemaModel(), - AckBooleanSchemaModel(), - AckObjectSchemaModel(), - AckArraySchemaModel(), - ], - nullable: nullable, - warnings: [if (warning != null) warning], - ); - - return json.isEmpty ? fallback : fallback.withJsonSchemaKeywords(json); - } - - AckSchemaModel _applyKeywords( - AckSchemaModel model, - Map json, - Set handled, - ) { - final keywords = Map.fromEntries( - json.entries.where((entry) => !handled.contains(entry.key)), - ); - - return keywords.isEmpty ? model : model.withJsonSchemaKeywords(keywords); - } - - _NullableUnion? _readNullableUnion(Map json) { - if (json['anyOf'] case final List schemas) { - if (schemas.length != 2) return null; - - final first = _asStringMap(schemas[0]); - final second = _asStringMap(schemas[1]); - if (first == null || second == null) return null; - - if (_isNullSchema(first)) { - return _NullableUnion(schema: second, schemaIndex: 1); - } - if (_isNullSchema(second)) { - return _NullableUnion(schema: first, schemaIndex: 0); - } - } - - return null; - } - - String? _readWrappedDefinitionsRef(Map json) { - if (json['allOf'] case final List schemas) { - if (schemas.length != 1) return null; - final refSchema = _asStringMap(schemas.single); - if (refSchema == null || refSchema.length != 1) return null; - if (refSchema[r'$ref'] case final String ref) { - return _definitionsRefName(ref); - } - } - - return null; - } - - bool _isNullSchema(Map json) { - return json.length == 1 && json['type'] == 'null'; - } - - String? _definitionsRefName(String ref) { - const prefix = '#/definitions/'; - if (!ref.startsWith(prefix)) return null; - - return _unescapeJsonPointerToken(ref.substring(prefix.length)); - } - - Map? _asStringMap(Object? value) { - if (value is Map) return value; - if (value is! Map) return null; - - final result = {}; - for (final entry in value.entries) { - final key = entry.key; - if (key is! String) return null; - result[key] = entry.value; - } - - return result; - } - - AckSchemaModelWarning _warning({ - required String code, - required String message, - required String path, - Map context = const {}, - }) { - return AckSchemaModelWarning( - code: code, - message: message, - path: path.isEmpty ? null : path, - context: context, - ); - } - - String _joinPath(String parent, String child) { - if (parent.isEmpty) return child; - - return '$parent/$child'; - } - - String _unescapeJsonPointerToken(String value) { - return value.replaceAll('~1', '/').replaceAll('~0', '~'); - } - - AckSchemaModel parse(Map json) { - return _parse(json, path: ''); - } -} - -final class _NullableUnion { - final Map schema; - - final int schemaIndex; - const _NullableUnion({required this.schema, required this.schemaIndex}); -} diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index 56b60a2f..4449be8d 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -40,7 +40,7 @@ final class AnyOfSchema extends AckSchema name: 'anyOf:$index', schema: schema, value: value, - pathSegment: const SchemaPathSegment.passThrough(), + pathSegment: '', ); final result = parse ? schema.parseWithContext(value, childContext) @@ -95,7 +95,7 @@ final class AnyOfSchema extends AckSchema name: 'anyOf:$index', schema: schema, value: runtime, - pathSegment: const SchemaPathSegment.passThrough(), + pathSegment: '', operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 787ea8da..f1105bbc 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -97,7 +97,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: null, - pathSegment: SchemaPathSegment.property(discriminatorKey), + pathSegment: discriminatorKey, ), ), ); @@ -116,7 +116,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: discValueRaw, - pathSegment: SchemaPathSegment.property(discriminatorKey), + pathSegment: discriminatorKey, ), ), ); @@ -135,7 +135,7 @@ final class DiscriminatedObjectSchema name: discriminatorKey, schema: const StringSchema(), value: discValueRaw, - pathSegment: SchemaPathSegment.property(discriminatorKey), + pathSegment: discriminatorKey, ), ), ); @@ -304,7 +304,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: mapValue, - pathSegment: const SchemaPathSegment.passThrough(), + pathSegment: '', ); final result = effective.parseWithContext(mapValue, subSchemaContext); @@ -371,7 +371,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: runtime, - pathSegment: const SchemaPathSegment.passThrough(), + pathSegment: '', operation: SchemaOperation.encode, ); @@ -405,7 +405,7 @@ final class DiscriminatedObjectSchema name: 'when $discriminatorKey="$discValue"', schema: effective, value: runtime, - pathSegment: const SchemaPathSegment.passThrough(), + pathSegment: '', operation: SchemaOperation.encode, ); diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index eb2bb78d..3bd083cd 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -56,7 +56,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: SchemaPathSegment.index(i), + pathSegment: '$i', ); final r = parse ? itemSchema.parseWithContext(item, itemCtx) @@ -122,7 +122,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: SchemaPathSegment.index(i), + pathSegment: '$i', operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index 20895134..d3070415 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -66,7 +66,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ); schema .resolveDefaultWithContext(childCtx) @@ -88,7 +88,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ), ), ); @@ -102,7 +102,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: propertyValue, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ); schema .parseWithContext(propertyValue, propertyCtx) @@ -133,7 +133,7 @@ final class ObjectSchema extends AckSchema name: key, schema: this, value: mapValue[key], - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ), ), ); @@ -188,7 +188,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: null, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ); if (isEncode) { errors.add( @@ -215,7 +215,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: propertyValue, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ); if (propertyValue == null) { @@ -244,7 +244,7 @@ final class ObjectSchema extends AckSchema name: key, schema: this, value: mapValue[key], - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, ); if (isEncode) { errors.add( @@ -299,7 +299,7 @@ final class ObjectSchema extends AckSchema name: key, schema: schema, value: hasValue ? value[key] : null, - pathSegment: SchemaPathSegment.property(key), + pathSegment: key, operation: SchemaOperation.encode, ); final Object? propertyValue; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index d91ca495..313c4f83 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,13 +1,5 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; -import 'package:standard_schema/standard_schema.dart' - show - JsonSchemaTarget, - StandardFailure, - StandardJsonSchemaConverter, - StandardSchemaWithJsonSchema, - StandardSchemaWithJsonSchemaProps, - StandardSuccess; import '../common_types.dart'; import '../constraints/constraint.dart'; @@ -19,7 +11,6 @@ import '../helpers.dart'; import '../schema_model/ack_schema_model_builder.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; -import '../validation/standard_issues.dart'; part 'any_of_schema.dart'; part 'any_schema.dart'; @@ -71,8 +62,7 @@ enum SchemaOperation { parse, encode } /// methods. Subclasses override the three methods; they should not override /// the public wrappers. @immutable -abstract class AckSchema - implements StandardSchemaWithJsonSchema { +abstract class AckSchema { final bool isNullable; final bool isOptional; final String? description; @@ -350,36 +340,6 @@ abstract class AckSchema return parseWithContext(value, context); } - @override - StandardSchemaWithJsonSchemaProps get standard => - StandardSchemaWithJsonSchemaProps( - vendor: 'ack', - validate: (value, [options]) => switch (safeParse(value)) { - Ok(value: final value) => StandardSuccess(value), - Fail(error: final error) => StandardFailure( - error.toStandardIssues(), - ), - }, - jsonSchema: StandardJsonSchemaConverter( - input: (options) => _renderJsonSchema(options.target), - output: (options) => this is CodecSchema - ? throw UnsupportedError( - 'codec Runtime side is not JSON-representable', - ) - : _renderJsonSchema(options.target), - ), - ); - - /// Renders this schema as a Draft-7 JSON Schema map, throwing for any other - /// [target] (ack only supports Draft-7 — spec permits throwing for - /// unsupported targets). - Map _renderJsonSchema(JsonSchemaTarget target) { - if (target != JsonSchemaTarget.draft07) { - throw UnsupportedError('ack supports draft-07 only'); - } - return toJsonSchema(); - } - /// Parses and validates a value, then maps the validated value to [TOut]. SchemaResult safeParseAs( Object? value, diff --git a/packages/ack/lib/src/validation/standard_issues.dart b/packages/ack/lib/src/validation/standard_issues.dart deleted file mode 100644 index ab61b8d3..00000000 --- a/packages/ack/lib/src/validation/standard_issues.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:standard_schema/standard_schema.dart'; - -import 'schema_error.dart'; - -extension StandardIssueConversion on SchemaError { - List toStandardIssues() { - return switch (this) { - SchemaNestedError(:final errors) when errors.isNotEmpty => [ - for (final error in errors) ...error.toStandardIssues(), - ], - SchemaConstraintsError(:final constraints) when constraints.isNotEmpty => - [ - for (final constraint in constraints) - StandardIssue( - message: constraint.message, - path: context.pathSegments, - ), - ], - _ => [StandardIssue(message: message, path: context.pathSegments)], - }; - } -} diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index ef43af69..cb549cba 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -12,7 +12,6 @@ environment: dependencies: meta: ^1.15.0 collection: ^1.18.0 - standard_schema: '>=0.0.1-dev.0 <0.1.0' dev_dependencies: test: ^1.25.15 diff --git a/packages/ack/test/schema_model/ack_schema_model_parser_test.dart b/packages/ack/test/schema_model/ack_schema_model_parser_test.dart deleted file mode 100644 index ce2e2323..00000000 --- a/packages/ack/test/schema_model/ack_schema_model_parser_test.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('AckSchemaModel.fromJsonSchema', () { - test('parses typed scalar keywords', () { - final string = AckSchemaModel.fromJsonSchema({ - 'type': 'string', - 'format': 'email', - 'minLength': 3, - 'maxLength': 64, - 'pattern': r'.+@.+', - }); - final integer = AckSchemaModel.fromJsonSchema({ - 'type': 'integer', - 'minimum': 1, - 'maximum': 10, - 'multipleOf': 2, - }); - final number = AckSchemaModel.fromJsonSchema({ - 'type': 'number', - 'exclusiveMinimum': 0, - 'exclusiveMaximum': 1, - }); - final boolean = AckSchemaModel.fromJsonSchema({ - 'type': 'boolean', - 'const': true, - }); - - expect(string, isA()); - expect(integer, isA()); - expect(number, isA()); - expect(boolean, isA()); - expect(string.toJsonSchema(), { - 'type': 'string', - 'format': 'email', - 'minLength': 3, - 'maxLength': 64, - 'pattern': r'.+@.+', - }); - expect(integer.toJsonSchema(), { - 'type': 'integer', - 'minimum': 1, - 'maximum': 10, - 'multipleOf': 2, - }); - expect(number.toJsonSchema(), { - 'type': 'number', - 'exclusiveMinimum': 0, - 'exclusiveMaximum': 1, - }); - expect(boolean.toJsonSchema(), {'type': 'boolean', 'const': true}); - }); - - test( - 'parses arrays, objects, required fields, and additional properties', - () { - final model = AckSchemaModel.fromJsonSchema({ - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'tags': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - }, - 'required': ['name'], - 'minProperties': 1, - 'additionalProperties': {'type': 'string'}, - }); - - final object = model as AckObjectSchemaModel; - expect(object.propertyOrdering, ['name', 'tags']); - expect(object.properties!['name'], isA()); - expect(object.properties!['tags'], isA()); - expect(object.required, ['name']); - expect( - object.additionalProperties, - isA(), - ); - expect(model.toJsonSchema(), { - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'tags': { - 'type': 'array', - 'items': {'type': 'integer'}, - }, - }, - 'required': ['name'], - 'minProperties': 1, - 'additionalProperties': {'type': 'string'}, - }); - }, - ); - - test('canonicalizes nullable anyOf wrappers back to nullable models', () { - final json = { - 'description': 'nickname', - 'default': 'leo', - 'definitions': { - 'User': {'type': 'object'}, - }, - 'anyOf': [ - {'type': 'string', 'minLength': 1}, - {'type': 'null'}, - ], - }; - - final model = AckSchemaModel.fromJsonSchema(json); - - expect(model, isA()); - expect(model.nullable, isTrue); - expect(model.description, 'nickname'); - expect(model.defaultValue, 'leo'); - expect(model.extensions['definitions'], { - 'User': {'type': 'object'}, - }); - expect(model.toJsonSchema(), json); - }); - - test('parses refs in bare and metadata-wrapped forms', () { - final bare = AckSchemaModel.fromJsonSchema({ - r'$ref': '#/definitions/Foo~0Bar~1Baz', - }); - final wrapped = AckSchemaModel.fromJsonSchema({ - 'title': 'Node', - 'allOf': [ - {r'$ref': '#/definitions/Node'}, - ], - }); - - expect((bare as AckRefSchemaModel).refName, 'Foo~Bar/Baz'); - expect(bare.toJsonSchema(), {r'$ref': '#/definitions/Foo~0Bar~1Baz'}); - expect((wrapped as AckRefSchemaModel).refName, 'Node'); - expect(wrapped.title, 'Node'); - expect(wrapped.toJsonSchema(), { - 'title': 'Node', - 'allOf': [ - {r'$ref': '#/definitions/Node'}, - ], - }); - }); - - test('warns instead of throwing for unsupported shapes', () { - final unknown = AckSchemaModel.fromJsonSchema({'x-custom': true}); - final unsupportedRef = AckSchemaModel.fromJsonSchema({ - r'$ref': 'https://example.com/schema.json', - }); - final typeList = AckSchemaModel.fromJsonSchema({ - 'type': ['string', 'null'], - 'minLength': 1, - }); - final booleanItems = AckSchemaModel.fromJsonSchema({ - 'type': 'array', - 'items': true, - }); - - expect(unknown.warnings.single.code, 'unsupported_schema_shape'); - expect(unknown.extensions['x-custom'], isTrue); - expect(unsupportedRef.warnings.single.code, 'unsupported_ref'); - expect(typeList.warnings.single.code, 'unsupported_type_array'); - expect(typeList.nullable, isTrue); - expect(booleanItems.warnings.single.code, 'unsupported_items_schema'); - expect(booleanItems.toJsonSchema(), {'type': 'array'}); - }); - - test('round-trips rendered JSON for model variants', () { - final corpus = [ - const AckStringSchemaModel( - format: 'email', - minLength: 3, - nullable: true, - defaultValue: 'a@b.com', - ), - const AckIntegerSchemaModel(minimum: 1, maximum: 10), - const AckNumberSchemaModel(multipleOf: 0.5), - const AckBooleanSchemaModel(constValue: false), - const AckArraySchemaModel(items: AckStringSchemaModel(minLength: 1)), - const AckObjectSchemaModel( - properties: {'name': AckStringSchemaModel()}, - required: ['name'], - additionalProperties: AckAdditionalPropertiesSchema( - AckIntegerSchemaModel(), - ), - ), - const AckNullSchemaModel(title: 'Nothing'), - const AckAnyOfSchemaModel( - schemas: [AckStringSchemaModel(), AckIntegerSchemaModel()], - ), - const AckOneOfSchemaModel( - schemas: [ - AckStringSchemaModel(constValue: 'x'), - AckIntegerSchemaModel(), - ], - nullable: true, - ), - const AckAllOfSchemaModel( - schemas: [ - AckStringSchemaModel(minLength: 2), - AckStringSchemaModel(maxLength: 8), - ], - ), - const AckRefSchemaModel(refName: 'Node'), - ]; - - for (final model in corpus) { - final json = model.toJsonSchema(); - final parsed = AckSchemaModel.fromJsonSchema(json); - - expect( - parsed.toJsonSchema(), - json, - reason: model.runtimeType.toString(), - ); - } - }); - }); -} diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart deleted file mode 100644 index c5656ee9..00000000 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('StandardSchema conformance', () { - test('AckSchema implements the standard schema contract', () { - expect( - Ack.string(), - isA>(), - ); - }); - - test( - 'standard validate maps successful nullable nulls without type errors', - () { - final result = Ack.string().nullable().standard.validate(null); - - expect(result, isA>()); - expect((result as StandardSuccess).value, isNull); - }, - ); - - test( - 'standard validate maps nested failures to flat issues with paths', - () { - final schema = Ack.object({ - 'user': Ack.object({'tags': Ack.list(Ack.string().minLength(2))}), - }); - - final result = schema.standard.validate({ - 'user': { - 'tags': ['ok', 'x'], - }, - }); - - expect(result, isA>()); - final failure = result as StandardFailure; - expect(failure.issues, hasLength(1)); - expect(failure.issues.single.path, ['user', 'tags', 1]); - }, - ); - - test('SchemaContext.pathSegments preserves typed segment identity', () { - final root = SchemaContext(name: 'root', schema: Ack.string(), value: {}); - final property = root.createChild( - name: 'tags', - schema: Ack.list(Ack.string()), - value: const ['x'], - pathSegment: const SchemaPathSegment.property('tags'), - ); - final index = property.createChild( - name: '1', - schema: Ack.string(), - value: 'x', - pathSegment: const SchemaPathSegment.index(1), - ); - final numericProperty = root.createChild( - name: '1', - schema: Ack.string(), - value: 'x', - pathSegment: const SchemaPathSegment.property('1'), - ); - final emptyProperty = root.createChild( - name: '', - schema: Ack.string(), - value: 'x', - pathSegment: const SchemaPathSegment.property(''), - ); - final passThrough = index.createChild( - name: 'anyOf', - schema: Ack.string(), - value: 'x', - pathSegment: const SchemaPathSegment.passThrough(), - ); - - expect(root.pathSegments, const []); - expect(property.pathSegments, ['tags']); - expect(index.pathSegments, ['tags', 1]); - expect(numericProperty.pathSegments, ['1']); - expect(emptyProperty.pathSegments, ['']); - expect(emptyProperty.path, '#/'); - expect(passThrough.pathSegments, ['tags', 1]); - }); - - test( - 'standard issues distinguish numeric object keys from list indexes', - () { - final objectResult = Ack.object({ - '1': Ack.string().minLength(2), - }).standard.validate({'1': 'x'}); - final listResult = Ack.list( - Ack.string().minLength(2), - ).standard.validate(['ok', 'x']); - final emptyKeyResult = Ack.object({ - '': Ack.string().minLength(2), - }).standard.validate({'': 'x'}); - - expect((objectResult as StandardFailure).issues.single.path, [ - '1', - ]); - expect( - (listResult as StandardFailure?>).issues.single.path, - [1], - ); - expect( - (emptyKeyResult as StandardFailure).issues.single.path, - [''], - ); - }, - ); - - test('SchemaError.toStandardIssues flattens direct and nested errors', () { - final direct = Ack.string().minLength(2).safeParse('x').getError(); - final nested = Ack.object({ - 'name': Ack.string().minLength(2), - 'age': Ack.integer(), - }).safeParse({'name': 'x', 'age': 'old'}).getError(); - - expect(direct.toStandardIssues(), hasLength(1)); - expect(direct.toStandardIssues().single.path, const []); - - final issues = nested.toStandardIssues(); - expect(issues.map((issue) => issue.path), [ - ['name'], - ['age'], - ]); - }); - - test('standard JSON Schema converter delegates to Draft-7 rendering', () { - final schema = Ack.string().minLength(2); - final converter = schema.standard.jsonSchema; - const draft7 = StandardJsonSchemaOptions( - target: JsonSchemaTarget.draft07, - ); - - expect(converter.input(draft7), schema.toJsonSchema()); - expect(converter.output(draft7), schema.toJsonSchema()); - expect( - () => converter.input( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.openapi30), - ), - throwsUnsupportedError, - ); - }); - - test('standard output JSON Schema throws for codecs', () { - final codec = Ack.string().transform((value) => int.parse(value)); - final converter = codec.standard.jsonSchema; - - expect( - () => converter.output( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), - throwsUnsupportedError, - ); - }); - }); -} diff --git a/packages/ack/test/validation/standard_schema_dotpath_test.dart b/packages/ack/test/validation/standard_schema_dotpath_test.dart deleted file mode 100644 index e61103be..00000000 --- a/packages/ack/test/validation/standard_schema_dotpath_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:standard_schema/utils.dart'; -import 'package:test/test.dart'; - -void main() { - test('getDotPath renders nested ack issue paths as spec dot-paths', () { - final schema = Ack.object({ - 'user': Ack.object({ - 'tags': Ack.list(Ack.string().minLength(2)), - 'age': Ack.integer(), - }), - }); - - final result = schema.standard.validate({ - 'user': { - 'tags': ['ok', 'x'], - 'age': 'old', - }, - }); - - final failure = result as StandardFailure; - - expect(failure.issues.map(getDotPath).toList(), [ - 'user.tags.1', - 'user.age', - ]); - }); -} diff --git a/packages/ack/test/validation/standard_schema_export_test.dart b/packages/ack/test/validation/standard_schema_export_test.dart deleted file mode 100644 index 9d64f755..00000000 --- a/packages/ack/test/validation/standard_schema_export_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('standard_schema re-export via ack.dart', () { - test( - 'AckSchema.standard contract types are reachable without direct import', - () { - final schema = Ack.string(); - - expect(schema, isA>()); - - final success = schema.standard.validate('ok'); - expect(success, isA>()); - expect((success as StandardSuccess).value, 'ok'); - - final failure = schema.standard.validate(1); - expect(failure, isA>()); - expect((failure as StandardFailure).issues, [ - isA(), - ]); - - final jsonSchema = schema.standard.jsonSchema.input( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ); - expect(jsonSchema, isA>()); - }, - ); - }); -} diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart index d32e4353..56e9480f 100644 --- a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart +++ b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart @@ -17,10 +17,7 @@ void main() { final sourceCases = firebaseAiResponseJsonSchemaCases(); expect(fixtures.sourcePackage, 'firebase_ai'); - // `sourceVersion` is recorded as provenance only; the per-case tests - // below compare live firebase_ai output to the golden fixtures, which - // is the real staleness guard. Asserting the exact resolved version - // only produces false CI failures on benign firebase_ai patch bumps. + expect(fixtures.sourceVersion, firebaseAiPackageVersion()); expect(fixtures.sourceClass, family.sourceClass); expect(fixtures.fixtureCount, cases.length); expect(fixtures.ids, sourceCases.map((schemaCase) => schemaCase.id)); diff --git a/packages/standard_schema/CHANGELOG.md b/packages/standard_schema/CHANGELOG.md index aa05d624..8b11520d 100644 --- a/packages/standard_schema/CHANGELOG.md +++ b/packages/standard_schema/CHANGELOG.md @@ -8,8 +8,14 @@ Initial dev release reserving the `standard_schema` name on pub.dev. contracts, porting the two official Standard Schema interfaces, plus the Dart-only `StandardSchemaWithJsonSchema` convenience for combined implementers. -- Add standard results/issues, validation options, JSON Schema converter option - types, and JSON Schema target constants. +- Add standard results/issues, path segments, validation options, JSON Schema + converter option types, and JSON Schema target constants. - Add the opt-in `utils.dart` library with `getDotPath` (renders an issue path in dot notation, e.g. `user.tags.1`) and `StandardSchemaError` (a throwable wrapping a failure's issues), porting `@standard-schema/utils`. +- Make the Standard Schema version marker fixed at `1`, matching the upstream + `version: 1` contract. +- Store failure issues, issue paths, and schema error issues as unmodifiable + snapshots. +- Add a package example covering validation, transformed output, JSON Schema + conversion, and dot-path rendering. diff --git a/packages/standard_schema/README.md b/packages/standard_schema/README.md index 0478e993..64b1ed83 100644 --- a/packages/standard_schema/README.md +++ b/packages/standard_schema/README.md @@ -30,7 +30,7 @@ final class RequiredStringSchema implements StandardSchema { return StandardSuccess(value); } - return const StandardFailure([ + return StandardFailure([ StandardIssue(message: 'Expected a non-empty string'), ]); }, @@ -90,7 +90,7 @@ final class RequiredStringSchemaWithJson return StandardSuccess(value); } - return const StandardFailure([ + return StandardFailure([ StandardIssue(message: 'Expected a non-empty string'), ]); }, @@ -118,13 +118,14 @@ explicitly: import 'package:standard_schema/utils.dart'; ``` -- `getDotPath(issue)` renders an issue's `path` in dot notation (for example - `user.tags.1`), or returns `null` when the issue has no path. +- `getDotPath(issue)` renders raw path keys and `StandardPathSegment(key: ...)` + entries in dot notation (for example `user.tags.1`), or returns `null` when + the issue has no path or contains a key that is not a string or number. - `StandardSchemaError(issues)` wraps a failure's issues as a throwable whose `message` is the first issue's message. ```dart -final result = schema.standard.validate(value); +final result = await Future.value(schema.standard.validate(value)); if (result is StandardFailure) { // Render each issue's path in dot notation: diff --git a/packages/standard_schema/example/standard_schema_example.dart b/packages/standard_schema/example/standard_schema_example.dart new file mode 100644 index 00000000..c4478234 --- /dev/null +++ b/packages/standard_schema/example/standard_schema_example.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:standard_schema/standard_schema.dart'; +import 'package:standard_schema/utils.dart'; + +final class RequiredStringSchema implements StandardSchema { + const RequiredStringSchema(); + + @override + StandardSchemaProps get standard => StandardSchemaProps( + vendor: 'example', + validate: (value, [options]) { + if (value is String && value.isNotEmpty) { + return StandardSuccess(value); + } + + return StandardFailure([ + StandardIssue( + message: 'Expected a non-empty string', + path: ['user', 'name'], + ), + ]); + }, + ); +} + +final class StringToIntSchema implements StandardSchema { + const StringToIntSchema(); + + @override + StandardSchemaProps get standard => StandardSchemaProps( + vendor: 'example', + validate: (value, [options]) { + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return StandardSuccess(parsed); + } + } + + return StandardFailure([ + StandardIssue(message: 'Expected an integer string', path: ['age']), + ]); + }, + ); +} + +final class RequiredStringWithJsonSchema + implements StandardSchemaWithJsonSchema { + const RequiredStringWithJsonSchema(); + + @override + StandardSchemaWithJsonSchemaProps get standard => + StandardSchemaWithJsonSchemaProps( + vendor: 'example', + validate: const RequiredStringSchema().standard.validate, + jsonSchema: StandardJsonSchemaConverter( + input: (options) => { + r'$schema': options.target, + 'type': 'string', + 'minLength': 1, + }, + output: (options) => {'type': 'string', 'minLength': 1}, + ), + ); +} + +Future main() async { + await printResult(const RequiredStringSchema().standard.validate('Ada')); + await printResult(const StringToIntSchema().standard.validate('42')); + + final schema = const RequiredStringWithJsonSchema(); + final inputJsonSchema = schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ); + print(inputJsonSchema); + + await printResult(schema.standard.validate('')); +} + +Future printResult(FutureOr> result) async { + final resolved = await Future.value(result); + + switch (resolved) { + case StandardSuccess(value: final value): + print('Valid: $value'); + case StandardFailure(issues: final issues): + for (final issue in issues) { + final path = getDotPath(issue) ?? ''; + print('$path: ${issue.message}'); + } + } +} diff --git a/packages/standard_schema/lib/src/standard_schema.dart b/packages/standard_schema/lib/src/standard_schema.dart index 9de6575c..e5aa80a1 100644 --- a/packages/standard_schema/lib/src/standard_schema.dart +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -67,24 +67,20 @@ typedef StandardJsonSchemaConvert = /// The properties shared by every standard trait. class StandardTypedProps { - const StandardTypedProps({required this.vendor, this.version = 1}); + const StandardTypedProps({required this.vendor}); /// The vendor name of the schema library. final String vendor; /// The version of the standard. Always `1` for this spec revision (Dart /// cannot pin the literal type the way TypeScript's `version: 1` does). - final int version; + final int version = 1; } /// The properties of a [StandardSchema]. class StandardSchemaProps extends StandardTypedProps { - const StandardSchemaProps({ - required super.vendor, - required this.validate, - super.version, - }); + const StandardSchemaProps({required super.vendor, required this.validate}); /// Validates an unknown input value. final StandardValidate validate; @@ -96,7 +92,6 @@ class StandardJsonSchemaProps const StandardJsonSchemaProps({ required super.vendor, required this.jsonSchema, - super.version, }); /// The JSON Schema tier converter. @@ -111,7 +106,6 @@ class StandardSchemaWithJsonSchemaProps required super.vendor, required super.validate, required this.jsonSchema, - super.version, }); /// The JSON Schema tier converter. @@ -142,7 +136,8 @@ final class StandardSuccess extends StandardResult { /// A failed validation result carrying one or more [issues]. final class StandardFailure extends StandardResult { - const StandardFailure(this.issues); + StandardFailure(List issues) + : issues = List.unmodifiable(issues); /// The issues describing why validation failed. final List issues; @@ -150,16 +145,30 @@ final class StandardFailure extends StandardResult { /// A single validation issue. final class StandardIssue { - const StandardIssue({required this.message, this.path = const []}); + StandardIssue({required this.message, List path = const []}) + : path = List.unmodifiable(path); /// The error message of the issue. final String message; - /// The path to the offending value, as property-key segments: a [String] - /// object key or an [int] list index. Empty for a root-level issue. + /// The path to the offending value, if any. + /// + /// Each entry is either a raw property key (for example a [String] object key + /// or [num] index) or a [StandardPathSegment] object with a [key]. Empty for a + /// root-level issue. final List path; } +/// An object path segment in a [StandardIssue.path]. +/// +/// This is the Dart spelling of upstream `StandardSchemaV1.PathSegment`. +final class StandardPathSegment { + const StandardPathSegment({required this.key}); + + /// The key representing a path segment. + final Object key; +} + /// The JSON Schema tier converter. /// /// The [input]/[output] split exists because validators transform: [input] diff --git a/packages/standard_schema/lib/src/utils.dart b/packages/standard_schema/lib/src/utils.dart index 467523dd..64a71ab9 100644 --- a/packages/standard_schema/lib/src/utils.dart +++ b/packages/standard_schema/lib/src/utils.dart @@ -3,17 +3,15 @@ import 'standard_schema.dart'; /// Returns the dot-notation path of an [issue] (for example `user.tags.1`), or /// `null` when the issue has no path or any segment is not a string or number. /// -/// Direct port of `getDotPath` from `@standard-schema/utils`. Ack emits issue -/// paths as bare property keys ([String] object keys and [int] list indexes — -/// the spec's `PropertyKey` form), so the upstream `{key}`-object segment branch -/// is unreachable here; the type check is kept for faithfulness to the source. +/// Direct port of `getDotPath` from `@standard-schema/utils`. String? getDotPath(StandardIssue issue) { if (issue.path.isEmpty) return null; final dotPath = StringBuffer(); for (final segment in issue.path) { - if (segment is String || segment is num) { + final key = segment is StandardPathSegment ? segment.key : segment; + if (key is String || key is num) { if (dotPath.isNotEmpty) dotPath.write('.'); - dotPath.write(segment); + dotPath.write(key); } else { return null; } @@ -29,10 +27,11 @@ String? getDotPath(StandardIssue issue) { class StandardSchemaError implements Exception { /// Wraps [issues]. Throws [ArgumentError] when [issues] is empty: a failure /// always carries at least one issue, and [message] is taken from the first. - StandardSchemaError(this.issues) + StandardSchemaError(List issues) : message = issues.isEmpty ? throw ArgumentError.value(issues, 'issues', 'must not be empty') - : issues.first.message; + : issues.first.message, + issues = List.unmodifiable(issues); /// The issues describing why validation failed. Never empty. final List issues; diff --git a/packages/standard_schema/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart index b7b9cbf8..7830b3ac 100644 --- a/packages/standard_schema/test/standard_schema_test.dart +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -22,7 +22,7 @@ final class _ValidationOnlySchema implements StandardSchema { final result = const StandardSuccess(1); return async ? Future>.value(result) : result; } - return const StandardFailure([ + return StandardFailure([ StandardIssue(message: 'Not ok', path: ['items', 1]), ]); }, @@ -56,7 +56,7 @@ final class _CombinedSchema vendor: 'fake-combined', validate: (value, [options]) => value == 'ok' ? const StandardSuccess(1) - : const StandardFailure([StandardIssue(message: 'Not ok')]), + : StandardFailure([StandardIssue(message: 'Not ok')]), jsonSchema: StandardJsonSchemaConverter( input: (options) => {'type': 'string'}, output: (options) => {'type': 'integer'}, @@ -66,6 +66,67 @@ final class _CombinedSchema void main() { group('StandardSchema', () { + test( + 'does not accept constructor overrides for the fixed spec version', + () { + StandardResult validate( + Object? value, [ + StandardValidateOptions? _, + ]) { + return const StandardSuccess(1); + } + + Map convert(StandardJsonSchemaOptions _) => {}; + + expect( + () => Function.apply(StandardTypedProps.new, const [], { + #vendor: 'fake', + #version: 2, + }), + throwsNoSuchMethodError, + ); + expect( + () => Function.apply( + StandardSchemaProps.new, + const [], + {#vendor: 'fake', #validate: validate, #version: 2}, + ), + throwsNoSuchMethodError, + ); + expect( + () => Function.apply( + StandardJsonSchemaProps.new, + const [], + { + #vendor: 'fake', + #jsonSchema: StandardJsonSchemaConverter( + input: convert, + output: convert, + ), + #version: 2, + }, + ), + throwsNoSuchMethodError, + ); + expect( + () => Function.apply( + StandardSchemaWithJsonSchemaProps.new, + const [], + { + #vendor: 'fake', + #validate: validate, + #jsonSchema: StandardJsonSchemaConverter( + input: convert, + output: convert, + ), + #version: 2, + }, + ), + throwsNoSuchMethodError, + ); + }, + ); + test('carries vendor, version, and success or failure results', () async { const schema = _ValidationOnlySchema(); @@ -140,5 +201,45 @@ void main() { ); }, ); + + test('stores validation failure issues as an unmodifiable snapshot', () { + final issues = [StandardIssue(message: 'first')]; + final failure = StandardFailure(issues); + + issues.add(StandardIssue(message: 'second')); + + expect(failure.issues, hasLength(1)); + expect(failure.issues.single.message, 'first'); + expect( + () => failure.issues.add(StandardIssue(message: 'third')), + throwsUnsupportedError, + ); + }); + + test('stores issue paths as unmodifiable snapshots', () { + final path = ['user']; + final issue = StandardIssue(message: 'Required', path: path); + + path.add('email'); + + expect(issue.path, ['user']); + expect(() => issue.path.add('name'), throwsUnsupportedError); + }); + + test('supports README-style async validation consumption', () async { + final schema = StandardSchemaProps( + vendor: 'fake', + validate: (value, [options]) async { + return StandardFailure([ + StandardIssue(message: 'Not ok', path: ['value']), + ]); + }, + ); + + final result = await Future.value(schema.validate('bad')); + + expect(result, isA>()); + expect((result as StandardFailure).issues.single.message, 'Not ok'); + }); }); } diff --git a/packages/standard_schema/test/utils_test.dart b/packages/standard_schema/test/utils_test.dart index 418d6fe2..59c14003 100644 --- a/packages/standard_schema/test/utils_test.dart +++ b/packages/standard_schema/test/utils_test.dart @@ -5,54 +5,90 @@ import 'package:test/test.dart'; void main() { group('getDotPath', () { test('returns null when the issue has no path', () { - expect(getDotPath(const StandardIssue(message: 'm')), isNull); - expect(getDotPath(const StandardIssue(message: 'm', path: [])), isNull); + expect(getDotPath(StandardIssue(message: 'm')), isNull); + expect(getDotPath(StandardIssue(message: 'm', path: [])), isNull); }); test('returns null when a segment is not a string or number', () { // `true` stands in for any non-string/number segment (the spec's symbol // form); `getDotPath` bails out rather than rendering it. - expect( - getDotPath(const StandardIssue(message: 'm', path: [true])), - isNull, - ); + expect(getDotPath(StandardIssue(message: 'm', path: [true])), isNull); }); test('joins string and number segments with dots', () { + expect(getDotPath(StandardIssue(message: 'm', path: ['a', 'b'])), 'a.b'); expect( - getDotPath(const StandardIssue(message: 'm', path: ['a', 'b'])), - 'a.b', + getDotPath(StandardIssue(message: 'm', path: ['a', 0, 'b'])), + 'a.0.b', ); expect( - getDotPath(const StandardIssue(message: 'm', path: ['a', 0, 'b'])), - 'a.0.b', + getDotPath( + StandardIssue(message: 'm', path: ['nested', 0, 'dot', 0, 'path']), + ), + 'nested.0.dot.0.path', ); + }); + + test('joins path segment objects by their keys', () { expect( getDotPath( - const StandardIssue( + StandardIssue( message: 'm', - path: ['nested', 0, 'dot', 0, 'path'], + path: [ + StandardPathSegment(key: 'items'), + StandardPathSegment(key: 1), + 'name', + ], ), ), - 'nested.0.dot.0.path', + 'items.1.name', ); }); + + test( + 'returns null when a path segment object key is not string or number', + () { + expect( + getDotPath( + StandardIssue( + message: 'm', + path: [StandardPathSegment(key: #field)], + ), + ), + isNull, + ); + }, + ); }); group('StandardSchemaError', () { test('wraps issues and exposes the first issue message', () { - const issues = [ + final issues = [ StandardIssue(message: 'first', path: ['a']), StandardIssue(message: 'second'), ]; final error = StandardSchemaError(issues); expect(error, isA()); - expect(error.issues, same(issues)); + expect(error.issues, issues); expect(error.message, 'first'); expect(error.toString(), 'StandardSchemaError: first'); }); + test('stores issues as an unmodifiable snapshot', () { + final issues = [StandardIssue(message: 'first')]; + final error = StandardSchemaError(issues); + + issues.add(StandardIssue(message: 'second')); + + expect(error.issues, hasLength(1)); + expect(error.issues.single.message, 'first'); + expect( + () => error.issues.add(StandardIssue(message: 'third')), + throwsUnsupportedError, + ); + }); + test('throws when constructed with no issues', () { expect(() => StandardSchemaError(const []), throwsArgumentError); }); diff --git a/scripts/update_release_changelog.dart b/scripts/update_release_changelog.dart index e9d1ec2f..19157807 100644 --- a/scripts/update_release_changelog.dart +++ b/scripts/update_release_changelog.dart @@ -36,7 +36,6 @@ void main(List args) { 'packages/ack_generator/CHANGELOG.md', 'packages/ack_firebase_ai/CHANGELOG.md', 'packages/ack_json_schema_builder/CHANGELOG.md', - 'packages/standard_schema/CHANGELOG.md', ]; for (final path in changelogPaths) { From ff53f08fe3e1760452dce9ae1004b504712b071a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 12 Jun 2026 15:15:44 -0400 Subject: [PATCH 09/13] fix: polish standard schema package API --- .../lib/src/standard_schema.dart | 8 +++--- packages/standard_schema/lib/src/utils.dart | 17 ++++++++---- .../test/standard_schema_test.dart | 26 +++++++++++++++++++ packages/standard_schema/test/utils_test.dart | 14 ++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/standard_schema/lib/src/standard_schema.dart b/packages/standard_schema/lib/src/standard_schema.dart index e5aa80a1..e9a8fc65 100644 --- a/packages/standard_schema/lib/src/standard_schema.dart +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -136,7 +136,7 @@ final class StandardSuccess extends StandardResult { /// A failed validation result carrying one or more [issues]. final class StandardFailure extends StandardResult { - StandardFailure(List issues) + StandardFailure(Iterable issues) : issues = List.unmodifiable(issues); /// The issues describing why validation failed. @@ -145,7 +145,7 @@ final class StandardFailure extends StandardResult { /// A single validation issue. final class StandardIssue { - StandardIssue({required this.message, List path = const []}) + StandardIssue({required this.message, Iterable path = const []}) : path = List.unmodifiable(path); /// The error message of the issue. @@ -154,8 +154,8 @@ final class StandardIssue { /// The path to the offending value, if any. /// /// Each entry is either a raw property key (for example a [String] object key - /// or [num] index) or a [StandardPathSegment] object with a [key]. Empty for a - /// root-level issue. + /// or [num] index) or a [StandardPathSegment] object with a + /// [StandardPathSegment.key]. Empty for a root-level issue. final List path; } diff --git a/packages/standard_schema/lib/src/utils.dart b/packages/standard_schema/lib/src/utils.dart index 64a71ab9..a70f5c12 100644 --- a/packages/standard_schema/lib/src/utils.dart +++ b/packages/standard_schema/lib/src/utils.dart @@ -27,11 +27,18 @@ String? getDotPath(StandardIssue issue) { class StandardSchemaError implements Exception { /// Wraps [issues]. Throws [ArgumentError] when [issues] is empty: a failure /// always carries at least one issue, and [message] is taken from the first. - StandardSchemaError(List issues) - : message = issues.isEmpty - ? throw ArgumentError.value(issues, 'issues', 'must not be empty') - : issues.first.message, - issues = List.unmodifiable(issues); + StandardSchemaError(Iterable issues) + : this._(_snapshotIssues(issues)); + + StandardSchemaError._(this.issues) : message = issues.first.message; + + static List _snapshotIssues(Iterable issues) { + final issueList = List.unmodifiable(issues); + if (issueList.isEmpty) { + throw ArgumentError.value(issues, 'issues', 'must not be empty'); + } + return issueList; + } /// The issues describing why validation failed. Never empty. final List issues; diff --git a/packages/standard_schema/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart index 7830b3ac..073d0d2d 100644 --- a/packages/standard_schema/test/standard_schema_test.dart +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -216,6 +216,20 @@ void main() { ); }); + test('accepts iterable validation failure issues', () { + Iterable issues() sync* { + yield StandardIssue(message: 'first'); + } + + final failure = StandardFailure(issues()); + + expect(failure.issues.single.message, 'first'); + expect( + () => failure.issues.add(StandardIssue(message: 'second')), + throwsUnsupportedError, + ); + }); + test('stores issue paths as unmodifiable snapshots', () { final path = ['user']; final issue = StandardIssue(message: 'Required', path: path); @@ -226,6 +240,18 @@ void main() { expect(() => issue.path.add('name'), throwsUnsupportedError); }); + test('accepts iterable issue paths', () { + Iterable path() sync* { + yield 'user'; + yield 'email'; + } + + final issue = StandardIssue(message: 'Required', path: path()); + + expect(issue.path, ['user', 'email']); + expect(() => issue.path.add('name'), throwsUnsupportedError); + }); + test('supports README-style async validation consumption', () async { final schema = StandardSchemaProps( vendor: 'fake', diff --git a/packages/standard_schema/test/utils_test.dart b/packages/standard_schema/test/utils_test.dart index 59c14003..0621171c 100644 --- a/packages/standard_schema/test/utils_test.dart +++ b/packages/standard_schema/test/utils_test.dart @@ -89,6 +89,20 @@ void main() { ); }); + test('accepts iterable issues', () { + Iterable issues() sync* { + yield StandardIssue(message: 'first'); + } + + final error = StandardSchemaError(issues()); + + expect(error.issues.single.message, 'first'); + expect( + () => error.issues.add(StandardIssue(message: 'second')), + throwsUnsupportedError, + ); + }); + test('throws when constructed with no issues', () { expect(() => StandardSchemaError(const []), throwsArgumentError); }); From 2bf300ad55e66ea300dcb5ea687d0f58623221ef Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 12 Jun 2026 18:13:18 -0400 Subject: [PATCH 10/13] feat: expose standard schema from ack --- packages/ack/CHANGELOG.md | 3 + packages/ack/lib/ack.dart | 1 + packages/ack/lib/src/schemas/schema.dart | 87 +++++++++++- .../ack/lib/src/validation/schema_error.dart | 53 ++++++++ packages/ack/pubspec.yaml | 1 + .../standard_schema_conformance_test.dart | 127 ++++++++++++++++++ packages/standard_schema/README.md | 36 +++++ .../test/standard_schema_test.dart | 50 +++++++ 8 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 packages/ack/test/validation/standard_schema_conformance_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index de83083e..c63843bd 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -20,6 +20,9 @@ * `NumberSchemaExtensions` adds fluent numeric constraints to `Ack.number()`: `.min`, `.max`, `.greaterThan`, `.lessThan`, `.positive`, `.negative`, and `.multipleOf`. +* `AckSchema.standard` exposes the Standard Schema validation and JSON Schema + converter contracts, and `package:ack/ack.dart` re-exports the + `standard_schema` contract types. ### Changed diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 06b66251..5c3a204c 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -33,5 +33,6 @@ export 'src/schemas/schema.dart' hide AnyAckSchema, Refinement, SchemaOperation, WrapperSchema; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; +export 'package:standard_schema/standard_schema.dart'; // Validation results export 'src/validation/schema_result.dart'; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 313c4f83..d6392e1e 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:standard_schema/standard_schema.dart'; import '../common_types.dart'; import '../constraints/constraint.dart'; @@ -8,6 +9,7 @@ import '../constraints/pattern_constraint.dart'; import '../constraints/validators.dart'; import '../context.dart'; import '../helpers.dart'; +import '../schema_model/ack_schema_model.dart'; import '../schema_model/ack_schema_model_builder.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; @@ -62,7 +64,8 @@ enum SchemaOperation { parse, encode } /// methods. Subclasses override the three methods; they should not override /// the public wrappers. @immutable -abstract class AckSchema { +abstract class AckSchema + implements StandardSchemaWithJsonSchema { final bool isNullable; final bool isOptional; final String? description; @@ -229,6 +232,88 @@ abstract class AckSchema { @protected bool get acceptsNull => isNullable; + @override + StandardSchemaWithJsonSchemaProps get standard => + StandardSchemaWithJsonSchemaProps( + vendor: 'ack', + validate: _validateStandard, + jsonSchema: StandardJsonSchemaConverter( + input: _standardJsonSchemaInput, + output: _standardJsonSchemaOutput, + ), + ); + + StandardResult _validateStandard( + Object? value, [ + StandardValidateOptions? options, + ]) { + return switch (safeParse(value)) { + Ok(value: final value) => StandardSuccess(value), + Fail(error: final error) => StandardFailure( + error.toStandardIssues(), + ), + }; + } + + Map _standardJsonSchemaInput( + StandardJsonSchemaOptions options, + ) { + _checkStandardJsonSchemaTarget(options); + return toJsonSchema(); + } + + Map _standardJsonSchemaOutput( + StandardJsonSchemaOptions options, + ) { + _checkStandardJsonSchemaTarget(options); + + if (this case CodecSchema(outputSchema: final outputSchema)) { + final runtimeOutputSchema = outputSchema as AckSchema; + final model = runtimeOutputSchema.toSchemaModel(); + if (_hasRuntimeOnlyJsonBoundary(model)) { + throw UnsupportedError( + 'Ack cannot represent this codec output as JSON Schema.', + ); + } + return model.toJsonSchema(); + } + + return toJsonSchema(); + } + + static void _checkStandardJsonSchemaTarget( + StandardJsonSchemaOptions options, + ) { + if (options.target == JsonSchemaTarget.draft07) return; + throw UnsupportedError( + 'Ack only supports Standard JSON Schema target ' + '${JsonSchemaTarget.draft07}; got ${options.target}.', + ); + } + + static bool _hasRuntimeOnlyJsonBoundary(AckSchemaModel model) { + if (model.warnings.any( + (warning) => warning.code == 'ack_instance_json_boundary', + )) { + return true; + } + + return switch (model) { + AckArraySchemaModel(:final items) => + items != null && _hasRuntimeOnlyJsonBoundary(items), + AckObjectSchemaModel(:final properties, :final additionalProperties) => + (properties?.values.any(_hasRuntimeOnlyJsonBoundary) ?? false) || + (additionalProperties is AckAdditionalPropertiesSchema && + _hasRuntimeOnlyJsonBoundary(additionalProperties.schema)), + AckAnyOfSchemaModel(:final schemas) || + AckOneOfSchemaModel(:final schemas) || + AckAllOfSchemaModel( + :final schemas, + ) => schemas.any(_hasRuntimeOnlyJsonBoundary), + _ => false, + }; + } + /// The schema type category for this schema. @protected SchemaType get schemaType; diff --git a/packages/ack/lib/src/validation/schema_error.dart b/packages/ack/lib/src/validation/schema_error.dart index 168d251a..90e0387f 100644 --- a/packages/ack/lib/src/validation/schema_error.dart +++ b/packages/ack/lib/src/validation/schema_error.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:standard_schema/standard_schema.dart'; import '../constraints/constraint.dart'; import '../context.dart'; @@ -126,6 +127,58 @@ class SchemaNestedError extends SchemaError { } } +extension StandardIssueConversion on SchemaError { + /// Converts this Ack error tree into flat Standard Schema issues. + List toStandardIssues() => + _standardIssuesFor(this).toList(growable: false); +} + +Iterable _standardIssuesFor(SchemaError error) sync* { + switch (error) { + case SchemaNestedError(errors: final errors) when errors.isNotEmpty: + for (final nested in errors) { + yield* _standardIssuesFor(nested); + } + case SchemaConstraintsError(:final constraints) when constraints.isNotEmpty: + for (final constraint in constraints) { + yield StandardIssue( + message: constraint.message, + path: _standardPath(error.context), + ); + } + default: + yield StandardIssue( + message: error.message, + path: _standardPath(error.context), + ); + } +} + +List _standardPath(SchemaContext context) { + final reversed = []; + var cursor = context; + + while (cursor.parent != null) { + final parent = cursor.parent!; + final segment = cursor.pathSegment; + + if (segment != null && segment.isNotEmpty) { + reversed.add(_standardPathKey(parent, segment)); + } + + cursor = parent; + } + + return reversed.reversed.toList(growable: false); +} + +Object _standardPathKey(SchemaContext parent, String segment) { + if (parent.schema is ListSchema) { + return int.tryParse(segment) ?? segment; + } + return segment; +} + @immutable class SchemaValidationError extends SchemaError { SchemaValidationError({ diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index cb549cba..dc56db6a 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: meta: ^1.15.0 collection: ^1.18.0 + standard_schema: ^0.0.1-dev.0 dev_dependencies: test: ^1.25.15 diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart new file mode 100644 index 00000000..a3be8538 --- /dev/null +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -0,0 +1,127 @@ +import 'package:ack/ack.dart'; +import 'package:standard_schema/utils.dart'; +import 'package:test/test.dart'; + +enum _Role { admin, member } + +void main() { + group('Ack Standard Schema conformance', () { + test('exposes the Standard Schema contract through package:ack', () async { + final schema = Ack.string(); + + expect(schema, isA>()); + expect(schema.standard.vendor, 'ack'); + expect(schema.standard.version, 1); + + final result = await Future.value(schema.standard.validate('Ada')); + + expect(result, isA>()); + expect((result as StandardSuccess).value, 'Ada'); + }); + + test('maps nested Ack failures to flat Standard issues', () async { + final schema = Ack.object({ + 'user': Ack.object({'tags': Ack.list(Ack.string())}), + }); + + final result = await Future.value( + schema.standard.validate({ + 'user': { + 'tags': ['ok', 1], + }, + }), + ); + + expect(result, isA>()); + final failure = result as StandardFailure; + + expect(failure.issues, hasLength(1)); + expect(failure.issues.single.path, ['user', 'tags', 1]); + expect(getDotPath(failure.issues.single), 'user.tags.1'); + expect(failure.issues.single.message, contains('Expected string')); + }); + + test('maps constraint failures to individual Standard issues', () async { + final schema = Ack.string().minLength(3); + + final result = await Future.value(schema.standard.validate('a')); + + expect(result, isA>()); + final failure = result as StandardFailure; + + expect(failure.issues, hasLength(1)); + expect(failure.issues.single.path, isEmpty); + expect(failure.issues.single.message, contains('Minimum 3')); + }); + + test('converts Draft-7 input JSON Schema through AckSchemaModel', () { + final schema = Ack.object({ + 'name': Ack.string(), + 'roles': Ack.list(Ack.enumValues(_Role.values)), + }); + + expect( + schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + schema.toJsonSchema(), + ); + }); + + test('throws for unsupported JSON Schema targets', () { + final schema = Ack.string(); + + expect( + () => schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.openapi30), + ), + throwsUnsupportedError, + ); + expect( + () => schema.standard.jsonSchema.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft202012), + ), + throwsUnsupportedError, + ); + }); + + test('uses representable codec output schemas', () { + final schema = Ack.codec( + input: Ack.string(), + decode: int.parse, + encode: (value) => value.toString(), + output: Ack.integer(), + ); + + expect( + schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'string', 'x-transformed': true}, + ); + expect( + schema.standard.jsonSchema.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'integer'}, + ); + }); + + test('throws for codec output schemas that are runtime-only', () { + final schema = Ack.date(); + + expect( + schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + schema.toJsonSchema(), + ); + expect( + () => schema.standard.jsonSchema.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + throwsUnsupportedError, + ); + }); + }); +} diff --git a/packages/standard_schema/README.md b/packages/standard_schema/README.md index 64b1ed83..5939b519 100644 --- a/packages/standard_schema/README.md +++ b/packages/standard_schema/README.md @@ -14,6 +14,40 @@ depending on a vendor-specific schema tree. The package does not define a JSON schema model, parser, renderer, or warning system; those stay in vendor packages (for example, Ack's `AckSchemaModel` in `package:ack`). +## Compatibility checks + +In Dart, compatibility is nominal: a value is a Standard Schema when it +implements the shared interface from this package. + +```dart +if (schema is StandardSchema) { + final result = await Future.value(schema.standard.validate(value)); +} + +if (schema is StandardJsonSchema) { + final json = schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ); +} +``` + +The JSON Schema maps returned by converters are owned by the implementing +library. Consumers should treat them as JSON Schema for the requested target, +not as canonical byte-for-byte output shared by every vendor. + +## Dart mapping notes + +This package intentionally maps the upstream TypeScript contract into Dart +idioms: + +- `~standard` is exposed as the normal Dart getter `standard`. +- TypeScript's phantom `types` field is omitted because Dart generics carry + input and output types. +- A missing issue path is represented as an empty list, which is also the root + path. +- Path keys are permissive `Object` values. Utilities such as `getDotPath` + render only string and number keys and return `null` for other keys. + ## Implement a schema ```dart @@ -67,6 +101,8 @@ final class RequiredStringJsonSchema Converters return plain JSON Schema maps (`Map`). They may throw when a schema cannot be represented for the requested target. +The concrete JSON Schema output is owned by the implementing library; this +package only defines the converter contract. ## Implement both traits diff --git a/packages/standard_schema/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart index 073d0d2d..adbf64d4 100644 --- a/packages/standard_schema/test/standard_schema_test.dart +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -184,6 +184,50 @@ void main() { ); }); + test('allows open-ended JSON Schema target strings', () { + const customTarget = JsonSchemaTarget('draft-next'); + final converter = StandardJsonSchemaConverter( + input: (options) => {r'$schema': options.target}, + output: (options) => {'target': options.target}, + ); + + expect( + converter.input(const StandardJsonSchemaOptions(target: customTarget)), + {r'$schema': 'draft-next'}, + ); + expect( + converter.output(const StandardJsonSchemaOptions(target: customTarget)), + {'target': 'draft-next'}, + ); + }); + + test('allows converters to throw for unsupported JSON Schema targets', () { + Map convert(StandardJsonSchemaOptions options) { + if (options.target != JsonSchemaTarget.draft07) { + throw UnsupportedError('Unsupported target: ${options.target}'); + } + return {'type': 'string'}; + } + + final converter = StandardJsonSchemaConverter( + input: convert, + output: convert, + ); + + expect( + () => converter.input( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.openapi30), + ), + throwsUnsupportedError, + ); + expect( + converter.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'string'}, + ); + }); + test( 'allows a schema to implement validation and JSON Schema together', () { @@ -240,6 +284,12 @@ void main() { expect(() => issue.path.add('name'), throwsUnsupportedError); }); + test('preserves path keys that consumers cannot render as dot paths', () { + final issue = StandardIssue(message: 'Required', path: [#field]); + + expect(issue.path, [#field]); + }); + test('accepts iterable issue paths', () { Iterable path() sync* { yield 'user'; From 9b5681996650b366625160ee38f696b3a97a394c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 22 Jun 2026 11:07:14 -0400 Subject: [PATCH 11/13] refactor: apply review improvements to standard schema integration - Add multi-issue fan-out tests for the Ack->Standard error mapping (multi-constraint, sibling object fields, indexed list items) - Extract warning-code literals into named constants on AckSchemaModelWarning and use them in the builder + the load-bearing match in schema.dart, removing a fragile producer/consumer coupling - Document why non-codec Boundary!=Runtime schemas reuse the input JSON Schema on the output side; add enum output==input aliasing test - Simplify StandardTypedProps.version to a getter; fold a single-use local in _standardJsonSchemaOutput --- .../ack_schema_model_builder.dart | 10 ++-- .../ack_schema_model_warning.dart | 19 ++++++ packages/ack/lib/src/schemas/schema.dart | 15 ++++- .../standard_schema_conformance_test.dart | 60 +++++++++++++++++++ .../lib/src/standard_schema.dart | 2 +- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 24ea9474..ef85192a 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -92,7 +92,7 @@ final class _SchemaModelBuilder { wrapped = wrapped.withWarnings([ ...wrapped.warnings, AckSchemaModelWarning( - code: 'default_not_export_safe', + code: AckSchemaModelWarning.defaultNotExportSafe, message: 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', ), @@ -225,7 +225,7 @@ final class _SchemaModelBuilder { description: schema.description, warnings: const [ AckSchemaModelWarning( - code: 'ack_instance_json_boundary', + code: AckSchemaModelWarning.instanceJsonBoundary, message: 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', ), @@ -250,7 +250,7 @@ final class _SchemaModelBuilder { description: description, warnings: const [ AckSchemaModelWarning( - code: 'ack_any_json_boundary', + code: AckSchemaModelWarning.anyJsonBoundary, message: 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', ), @@ -326,7 +326,7 @@ final class _SchemaModelBuilder { return model.withWarnings([ ...model.warnings, AckSchemaModelWarning( - code: 'lazy_runtime_checks_not_export_safe', + code: AckSchemaModelWarning.lazyRuntimeChecksNotExportSafe, message: 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', context: { @@ -374,7 +374,7 @@ AckSchemaModel _applyDateTimeConstraint( return model.withWarnings([ ...model.warnings, AckSchemaModelWarning( - code: 'datetime_constraint_not_draft7', + code: AckSchemaModelWarning.datetimeConstraintNotDraft7, message: 'DateTime range constraints are not emitted because JSON Schema Draft-7 has no standard format range keywords.', context: { diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart index aabc58c8..6b7ccaf9 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_warning.dart @@ -3,6 +3,25 @@ import 'package:meta/meta.dart'; @immutable final class AckSchemaModelWarning { + /// A default value could not be represented in the exported JSON Schema. + static const String defaultNotExportSafe = 'default_not_export_safe'; + + /// An [InstanceSchema] has a runtime-only JSON boundary that cannot be + /// rendered as JSON Schema. Matched when deciding whether a codec output is + /// representable as Standard JSON Schema. + static const String instanceJsonBoundary = 'ack_instance_json_boundary'; + + /// An `Ack.any()` schema has no constrained JSON boundary. + static const String anyJsonBoundary = 'ack_any_json_boundary'; + + /// A [LazySchema]'s runtime checks cannot be export-verified. + static const String lazyRuntimeChecksNotExportSafe = + 'lazy_runtime_checks_not_export_safe'; + + /// A DateTime constraint cannot be expressed in JSON Schema Draft-7. + static const String datetimeConstraintNotDraft7 = + 'datetime_constraint_not_draft7'; + final String code; final String message; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index d6392e1e..27df5af6 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -11,6 +11,7 @@ import '../context.dart'; import '../helpers.dart'; import '../schema_model/ack_schema_model.dart'; import '../schema_model/ack_schema_model_builder.dart'; +import '../schema_model/ack_schema_model_warning.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; @@ -268,8 +269,8 @@ abstract class AckSchema _checkStandardJsonSchemaTarget(options); if (this case CodecSchema(outputSchema: final outputSchema)) { - final runtimeOutputSchema = outputSchema as AckSchema; - final model = runtimeOutputSchema.toSchemaModel(); + final model = + (outputSchema as AckSchema).toSchemaModel(); if (_hasRuntimeOnlyJsonBoundary(model)) { throw UnsupportedError( 'Ack cannot represent this codec output as JSON Schema.', @@ -278,6 +279,14 @@ abstract class AckSchema return model.toJsonSchema(); } + // [CodecSchema] is the only Ack schema that carries a distinct output + // schema, so it is the only case handled above. Every other schema — even + // those where `Boundary != Runtime` (e.g. [EnumSchema] String→enum, + // [DiscriminatedObjectSchema] JsonMap→model) — reuses the boundary schema + // here: their runtime value projects back onto the same JSON wire shape + // (an enum encodes to its `.name` String), so the input/boundary schema IS + // the faithful output projection. Do not route these through the codec + // branch above; they have no separate output schema and would wrongly throw. return toJsonSchema(); } @@ -293,7 +302,7 @@ abstract class AckSchema static bool _hasRuntimeOnlyJsonBoundary(AckSchemaModel model) { if (model.warnings.any( - (warning) => warning.code == 'ack_instance_json_boundary', + (warning) => warning.code == AckSchemaModelWarning.instanceJsonBoundary, )) { return true; } diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart index a3be8538..f77c8391 100644 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -54,6 +54,54 @@ void main() { expect(failure.issues.single.message, contains('Minimum 3')); }); + test('fans out every failing constraint into its own issue', () async { + final schema = Ack.string().minLength(5).matches(r'^\d+$'); + + final result = await Future.value(schema.standard.validate('ab')); + + final failure = result as StandardFailure; + expect(failure.issues, hasLength(2)); + expect(failure.issues.map((i) => i.path), everyElement(isEmpty)); + expect( + failure.issues.map((i) => i.message), + containsAll([contains('Minimum 5'), contains('match')]), + ); + }); + + test('fans out sibling object field failures into distinct paths', () async { + final schema = Ack.object({'a': Ack.string(), 'b': Ack.integer()}); + + final result = await Future.value( + schema.standard.validate({'a': 1, 'b': 'x'}), + ); + + final failure = result as StandardFailure; + expect(failure.issues, hasLength(2)); + expect( + failure.issues.map((i) => i.path), + containsAll([ + ['a'], + ['b'], + ]), + ); + }); + + test('fans out sibling list item failures into indexed paths', () async { + final schema = Ack.list(Ack.string()); + + final result = await Future.value(schema.standard.validate([1, 'ok', 2])); + + final failure = result as StandardFailure?>; + expect(failure.issues, hasLength(2)); + expect( + failure.issues.map((i) => i.path), + containsAll([ + [0], + [2], + ]), + ); + }); + test('converts Draft-7 input JSON Schema through AckSchemaModel', () { final schema = Ack.object({ 'name': Ack.string(), @@ -68,6 +116,18 @@ void main() { ); }); + test('aliases input on the output side for non-codec transforms', () { + // EnumSchema is String->enum (Boundary != Runtime) but not a codec, so + // its output JSON Schema intentionally reuses the boundary schema. + final schema = Ack.enumValues(_Role.values); + const options = StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07); + + expect( + schema.standard.jsonSchema.output(options), + schema.standard.jsonSchema.input(options), + ); + }); + test('throws for unsupported JSON Schema targets', () { final schema = Ack.string(); diff --git a/packages/standard_schema/lib/src/standard_schema.dart b/packages/standard_schema/lib/src/standard_schema.dart index e9a8fc65..40fb0a79 100644 --- a/packages/standard_schema/lib/src/standard_schema.dart +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -74,7 +74,7 @@ class StandardTypedProps { /// The version of the standard. Always `1` for this spec revision (Dart /// cannot pin the literal type the way TypeScript's `version: 1` does). - final int version = 1; + int get version => 1; } /// The properties of a [StandardSchema]. From 206a88a6698cfb870d2e8b67793b7ce60de725d5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 23 Jun 2026 15:40:27 -0400 Subject: [PATCH 12/13] fix(ack_firebase_ai): pin Firebase AI fixture version --- packages/ack_firebase_ai/pubspec.yaml | 4 +++- .../fixtures/firebase_ai_native_json_schema/manifest.json | 2 +- .../test/fixtures/firebase_ai_native_schema/manifest.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index 2c05cf7d..64d82001 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -17,7 +17,9 @@ dependencies: dev_dependencies: firebase_core: ^4.9.0 firebase_core_platform_interface: ^7.0.1 - firebase_ai: ^3.12.1 + # Native schema fixtures snapshot this SDK version. Update the fixtures when + # intentionally changing the pinned Firebase AI test dependency. + firebase_ai: 3.13.1 flutter_test: sdk: flutter lints: ^5.0.0 diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json index 6f309877..f8b7184a 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.2", + "sourceVersion": "3.13.1", "sourceClass": "JSONSchema", "fixtureCount": 30, "featureCoverage": { diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json index e611fe70..dcd019d7 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -2,7 +2,7 @@ "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", "sourcePackage": "firebase_ai", - "sourceVersion": "3.12.2", + "sourceVersion": "3.13.1", "sourceClass": "Schema", "fixtureCount": 30, "featureCoverage": { From 937f86528538992ccc94309fd3dd0047cd6b4ac9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 25 Jun 2026 13:10:00 -0400 Subject: [PATCH 13/13] fix: tighten standard schema output semantics --- packages/ack/CHANGELOG.md | 5 + packages/ack/lib/src/context.dart | 13 +- .../ack_schema_model_builder.dart | 116 +++++-- packages/ack/lib/src/schemas/list_schema.dart | 4 +- packages/ack/lib/src/schemas/schema.dart | 51 +--- .../ack/lib/src/validation/schema_error.dart | 14 +- .../standard_schema_conformance_test.dart | 285 +++++++++++++++--- packages/standard_schema/CHANGELOG.md | 10 +- packages/standard_schema/README.md | 29 +- .../example/standard_schema_example.dart | 14 +- .../lib/src/standard_schema.dart | 83 +++-- .../test/standard_schema_test.dart | 67 ++-- 12 files changed, 495 insertions(+), 196 deletions(-) diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 90679221..90d34b80 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -29,6 +29,11 @@ * Project discriminated schemas through union-owned discriminator branches. * Preserve defaults, const values, extension keywords, transformed metadata, composition, and JSON Schema constraints through the schema model boundary. +* Standard JSON Schema `output()` conversion now describes the value returned + by `standard.validate()` and throws when the runtime value is not faithfully + JSON-representable. +* Standard Schema issue paths now preserve list indexes as integer path + segments even when lists are wrapped by defaults or codecs. ### Migration diff --git a/packages/ack/lib/src/context.dart b/packages/ack/lib/src/context.dart index 118e27b1..c0195a6a 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -9,7 +9,13 @@ class SchemaContext { final Object? value; final AnyAckSchema schema; final SchemaContext? parent; - final String? pathSegment; + + /// Raw path key for this context. + /// + /// Object properties use string keys, list items use integer indexes, and + /// transparent wrapper branches use `''` so JSON Pointer rendering can skip + /// an implementation-only schema layer. + final Object? pathSegment; final SchemaOperation operation; const SchemaContext({ @@ -34,11 +40,12 @@ class SchemaContext { final parentPath = parent!.path; + final pathSegment = this.pathSegment; if (pathSegment == '') { return parentPath; } - final segment = pathSegment ?? name; + final segment = (pathSegment ?? name).toString(); final escapedSegment = _escapeJsonPointerSegment(segment); return parentPath == '#' @@ -53,7 +60,7 @@ class SchemaContext { required String name, required AnyAckSchema schema, required Object? value, - String? pathSegment, + Object? pathSegment, SchemaOperation? operation, }) { return SchemaContext( diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index ef85192a..5a279ede 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; +import '../common_types.dart'; import '../constraints/constraint.dart'; import '../constraints/datetime_constraint.dart'; import '../context.dart'; @@ -15,10 +16,21 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + AckSchemaModel toSchemaModel() => toStandardInputSchemaModel(); + + AckSchemaModel toStandardInputSchemaModel() => + _SchemaModelBuilder(_SchemaModelSide.input).build(this); + + AckSchemaModel toStandardOutputSchemaModel() => + _SchemaModelBuilder(_SchemaModelSide.output).build(this); } +enum _SchemaModelSide { input, output } + final class _SchemaModelBuilder { + _SchemaModelBuilder(this.side); + + final _SchemaModelSide side; final _definitions = {}; final _targets = {}; @@ -67,15 +79,11 @@ final class _SchemaModelBuilder { AckSchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { - final base = _build(schema.inner); - // Defaults wrap their inner without transforming the boundary value, so - // they should not advertise themselves as a transformed schema. - final extensions = schema is DefaultSchema - ? base.extensions - : {...base.extensions, 'x-transformed': true}; + final base = _build(_wrappedSchemaForSide(schema)); + final extensions = _wrapperExtensionsForSide(schema, base); final layered = base .withDescription(schema.description ?? base.description) - .withNullable(schema.isNullable || base.nullable) + .withNullable(_wrapperNullableForSide(schema, base)) .withExtensions(extensions); // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, // which `_build(schema.inner)` already applied. Re-running them here @@ -85,7 +93,7 @@ final class _SchemaModelBuilder { : _applyConstraints(layered, schema); if (schema is DefaultSchema) { - final exportDefault = _defaultExportValueOrNull(schema); + final exportDefault = _defaultExportValueOrNull(schema, side: side); if (exportDefault != null) { wrapped = wrapped.withDefaultValue(exportDefault); } else { @@ -131,6 +139,33 @@ final class _SchemaModelBuilder { return schema is LazySchema ? model : _applyConstraints(model, schema); } + AckSchema _wrappedSchemaForSide(WrapperSchema schema) { + if (side == _SchemaModelSide.output && schema is CodecSchema) { + return schema.outputSchema; + } + + return schema.inner; + } + + bool _wrapperNullableForSide(WrapperSchema schema, AckSchemaModel base) { + if (side == _SchemaModelSide.output && schema is DefaultSchema) { + return false; + } + + return schema.isNullable || base.nullable; + } + + Map _wrapperExtensionsForSide( + WrapperSchema schema, + AckSchemaModel base, + ) { + if (schema is DefaultSchema || side == _SchemaModelSide.output) { + return base.extensions; + } + + return {...base.extensions, 'x-transformed': true}; + } + AckSchemaModel _string(StringSchema schema) { return AckStringSchemaModel( description: schema.description, @@ -157,6 +192,12 @@ final class _SchemaModelBuilder { } AckSchemaModel _enum(EnumSchema schema) { + if (side == _SchemaModelSide.output) { + throw UnsupportedError( + 'Ack cannot represent Dart enum runtime output as JSON Schema.', + ); + } + return AckStringSchemaModel( description: schema.description, enumValues: [for (final value in schema.values) value.name], @@ -183,7 +224,7 @@ final class _SchemaModelBuilder { entry.key, () => _build(entry.value), ); - if (_isRequiredObjectProperty(entry.value)) { + if (_isRequiredObjectProperty(entry.value, side)) { required.add(entry.key); } } @@ -209,6 +250,12 @@ final class _SchemaModelBuilder { } AckSchemaModel _instance(InstanceSchema schema) { + if (side == _SchemaModelSide.output) { + throw UnsupportedError( + 'Ack cannot represent Ack.instance() runtime output as JSON Schema.', + ); + } + // InstanceSchema accepts arbitrary Dart instances of a runtime type with no // direct JSON representation. Adapters that flow through a codec see the // boundary schema instead; this is the fallback for a bare instance. @@ -259,6 +306,14 @@ final class _SchemaModelBuilder { } AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { + if (side == _SchemaModelSide.output && + schema is! DiscriminatedObjectSchema) { + throw UnsupportedError( + 'Ack cannot represent model-backed discriminated runtime output ' + 'as JSON Schema.', + ); + } + if (schema.schemas.isEmpty) { return AckObjectSchemaModel( properties: const {}, @@ -388,10 +443,15 @@ AckSchemaModel _applyDateTimeConstraint( /// Best-effort export of a [DefaultSchema] default value. /// -/// Encodes the runtime default through the wrapped schema so codec -/// transformations are applied, then verifies the result is JSON-safe before -/// returning it. Returns `null` when no JSON-safe representation is reachable. -Object? _defaultExportValueOrNull(DefaultSchema schema) { +/// Verifies a default can be exported on the selected schema-model side. +/// +/// Input-side defaults are encoded through the wrapped schema. Output-side +/// defaults are runtime values and are exported directly when JSON-safe. +/// Returns `null` when no JSON-safe representation is reachable. +Object? _defaultExportValueOrNull( + DefaultSchema schema, { + required _SchemaModelSide side, +}) { final resolved = schema.resolveDefaultWithContext( _defaultExportContext(schema), ); @@ -400,20 +460,36 @@ Object? _defaultExportValueOrNull(DefaultSchema schema) { final defaultValue = resolved.getOrNull(); if (defaultValue == null) return null; + if (side == _SchemaModelSide.output) { + return _jsonRoundTripOrNull(defaultValue); + } + final encoded = schema.inner.safeEncode(defaultValue); if (encoded.isFail) return null; return _jsonRoundTripOrNull(encoded.getOrNull()); } -bool _isRequiredObjectProperty(AckSchema schema) { - if (schema.isOptional) return false; - if (schema is DefaultSchema && - schema.resolveDefaultWithContext(_defaultExportContext(schema)).isOk) { - return false; +bool _isRequiredObjectProperty( + AckSchema schema, + _SchemaModelSide side, +) { + if (schema is DefaultSchema) { + final defaultResult = schema.resolveDefaultWithContext( + _defaultExportContext(schema), + ); + if (side == _SchemaModelSide.input) { + return !schema.isOptional && defaultResult.isFail; + } + + if (defaultResult.isOk) { + return defaultResult.getOrNull() != null; + } + + return true; } - return true; + return !schema.isOptional; } /// Throwaway [SchemaContext] used only to drive diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index 3bd083cd..8c7f6f5c 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -56,7 +56,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: '$i', + pathSegment: i, ); final r = parse ? itemSchema.parseWithContext(item, itemCtx) @@ -122,7 +122,7 @@ final class ListSchema name: '$i', schema: itemSchema, value: item, - pathSegment: '$i', + pathSegment: i, operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 27df5af6..4f35b404 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -9,9 +9,7 @@ import '../constraints/pattern_constraint.dart'; import '../constraints/validators.dart'; import '../context.dart'; import '../helpers.dart'; -import '../schema_model/ack_schema_model.dart'; import '../schema_model/ack_schema_model_builder.dart'; -import '../schema_model/ack_schema_model_warning.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; @@ -234,8 +232,8 @@ abstract class AckSchema bool get acceptsNull => isNullable; @override - StandardSchemaWithJsonSchemaProps get standard => - StandardSchemaWithJsonSchemaProps( + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( vendor: 'ack', validate: _validateStandard, jsonSchema: StandardJsonSchemaConverter( @@ -267,27 +265,7 @@ abstract class AckSchema StandardJsonSchemaOptions options, ) { _checkStandardJsonSchemaTarget(options); - - if (this case CodecSchema(outputSchema: final outputSchema)) { - final model = - (outputSchema as AckSchema).toSchemaModel(); - if (_hasRuntimeOnlyJsonBoundary(model)) { - throw UnsupportedError( - 'Ack cannot represent this codec output as JSON Schema.', - ); - } - return model.toJsonSchema(); - } - - // [CodecSchema] is the only Ack schema that carries a distinct output - // schema, so it is the only case handled above. Every other schema — even - // those where `Boundary != Runtime` (e.g. [EnumSchema] String→enum, - // [DiscriminatedObjectSchema] JsonMap→model) — reuses the boundary schema - // here: their runtime value projects back onto the same JSON wire shape - // (an enum encodes to its `.name` String), so the input/boundary schema IS - // the faithful output projection. Do not route these through the codec - // branch above; they have no separate output schema and would wrongly throw. - return toJsonSchema(); + return toStandardOutputSchemaModel().toJsonSchema(); } static void _checkStandardJsonSchemaTarget( @@ -300,29 +278,6 @@ abstract class AckSchema ); } - static bool _hasRuntimeOnlyJsonBoundary(AckSchemaModel model) { - if (model.warnings.any( - (warning) => warning.code == AckSchemaModelWarning.instanceJsonBoundary, - )) { - return true; - } - - return switch (model) { - AckArraySchemaModel(:final items) => - items != null && _hasRuntimeOnlyJsonBoundary(items), - AckObjectSchemaModel(:final properties, :final additionalProperties) => - (properties?.values.any(_hasRuntimeOnlyJsonBoundary) ?? false) || - (additionalProperties is AckAdditionalPropertiesSchema && - _hasRuntimeOnlyJsonBoundary(additionalProperties.schema)), - AckAnyOfSchemaModel(:final schemas) || - AckOneOfSchemaModel(:final schemas) || - AckAllOfSchemaModel( - :final schemas, - ) => schemas.any(_hasRuntimeOnlyJsonBoundary), - _ => false, - }; - } - /// The schema type category for this schema. @protected SchemaType get schemaType; diff --git a/packages/ack/lib/src/validation/schema_error.dart b/packages/ack/lib/src/validation/schema_error.dart index 90e0387f..6ca9499e 100644 --- a/packages/ack/lib/src/validation/schema_error.dart +++ b/packages/ack/lib/src/validation/schema_error.dart @@ -159,26 +159,18 @@ List _standardPath(SchemaContext context) { var cursor = context; while (cursor.parent != null) { - final parent = cursor.parent!; final segment = cursor.pathSegment; - if (segment != null && segment.isNotEmpty) { - reversed.add(_standardPathKey(parent, segment)); + if (segment != null && segment != '') { + reversed.add(segment); } - cursor = parent; + cursor = cursor.parent!; } return reversed.reversed.toList(growable: false); } -Object _standardPathKey(SchemaContext parent, String segment) { - if (parent.schema is ListSchema) { - return int.tryParse(segment) ?? segment; - } - return segment; -} - @immutable class SchemaValidationError extends SchemaError { SchemaValidationError({ diff --git a/packages/ack/test/validation/standard_schema_conformance_test.dart b/packages/ack/test/validation/standard_schema_conformance_test.dart index f77c8391..7e1467da 100644 --- a/packages/ack/test/validation/standard_schema_conformance_test.dart +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -4,6 +4,25 @@ import 'package:test/test.dart'; enum _Role { admin, member } +final class _Animal { + const _Animal(this.kind); + + final String kind; +} + +const _draft7Options = StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, +); + +CodecSchema _stringToIntCodec() { + return Ack.codec( + input: Ack.string(), + decode: int.parse, + encode: (value) => value.toString(), + output: Ack.integer(), + ); +} + void main() { group('Ack Standard Schema conformance', () { test('exposes the Standard Schema contract through package:ack', () async { @@ -68,23 +87,26 @@ void main() { ); }); - test('fans out sibling object field failures into distinct paths', () async { - final schema = Ack.object({'a': Ack.string(), 'b': Ack.integer()}); - - final result = await Future.value( - schema.standard.validate({'a': 1, 'b': 'x'}), - ); - - final failure = result as StandardFailure; - expect(failure.issues, hasLength(2)); - expect( - failure.issues.map((i) => i.path), - containsAll([ - ['a'], - ['b'], - ]), - ); - }); + test( + 'fans out sibling object field failures into distinct paths', + () async { + final schema = Ack.object({'a': Ack.string(), 'b': Ack.integer()}); + + final result = await Future.value( + schema.standard.validate({'a': 1, 'b': 'x'}), + ); + + final failure = result as StandardFailure; + expect(failure.issues, hasLength(2)); + expect( + failure.issues.map((i) => i.path), + containsAll([ + ['a'], + ['b'], + ]), + ); + }, + ); test('fans out sibling list item failures into indexed paths', () async { final schema = Ack.list(Ack.string()); @@ -109,22 +131,17 @@ void main() { }); expect( - schema.standard.jsonSchema.input( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), + schema.standard.jsonSchema.input(_draft7Options), schema.toJsonSchema(), ); }); - test('aliases input on the output side for non-codec transforms', () { - // EnumSchema is String->enum (Boundary != Runtime) but not a codec, so - // its output JSON Schema intentionally reuses the boundary schema. + test('throws for enum runtime output schemas', () { final schema = Ack.enumValues(_Role.values); - const options = StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07); expect( - schema.standard.jsonSchema.output(options), - schema.standard.jsonSchema.input(options), + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, ); }); @@ -146,24 +163,164 @@ void main() { }); test('uses representable codec output schemas', () { + final schema = _stringToIntCodec(); + + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'type': 'string', + 'x-transformed': true, + }); + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'integer', + }); + }); + + test('recursively projects nested codec output schemas', () { + final stringToInt = _stringToIntCodec(); + final objectSchema = Ack.object({'count': stringToInt}); + final listSchema = Ack.list(stringToInt); + + expect(objectSchema.standard.jsonSchema.output(_draft7Options), { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer'}, + }, + 'required': ['count'], + 'additionalProperties': false, + }); + expect(listSchema.standard.jsonSchema.output(_draft7Options), { + 'type': 'array', + 'items': {'type': 'integer'}, + }); + }); + + test( + 'describes defaulted nullable output as the runtime default shape', + () { + final schema = Ack.integer().nullable().withDefault(7); + + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'integer', + 'default': 7, + }); + + final result = schema.standard.validate(null) as StandardSuccess; + expect(result.value, 7); + }, + ); + + test( + 'marks materialized defaulted object fields as required on output', + () { + final schema = Ack.object({'count': Ack.integer().withDefault(7)}); + + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer', 'default': 7}, + }, + 'additionalProperties': false, + }); + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer', 'default': 7}, + }, + 'required': ['count'], + 'additionalProperties': false, + }); + + final result = + schema.standard.validate({}) as StandardSuccess; + expect(result.value, {'count': 7}); + }, + ); + + test('preserves representable outer codec output metadata', () { final schema = Ack.codec( input: Ack.string(), decode: int.parse, encode: (value) => value.toString(), - output: Ack.integer(), - ); + output: Ack.integer().min(1), + ).nullable().describe('Parsed count').withDefault(7); + + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'description': 'Parsed count', + 'default': 7, + 'type': 'integer', + 'minimum': 1, + }); + }); + + test('preserves nested defaulted codec output constraints', () { + final schema = Ack.object({ + 'count': Ack.codec( + input: Ack.string(), + decode: int.parse, + encode: (value) => value.toString(), + output: Ack.integer().min(1), + ).nullable().withDefault(7), + }); + + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer', 'minimum': 1, 'default': 7}, + }, + 'required': ['count'], + 'additionalProperties': false, + }); + }); + + test('throws when anyOf output contains a runtime-only branch', () { + final schema = Ack.anyOf([Ack.string(), Ack.enumValues(_Role.values)]); expect( - schema.standard.jsonSchema.input( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), - {'type': 'string', 'x-transformed': true}, + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, ); + }); + + test('exports representable lazy output schemas', () { + final target = Ack.object({'name': Ack.string()}); + final schema = Ack.lazy('LazyUser', () => target); + + final output = schema.standard.jsonSchema.output(_draft7Options); + final definitions = output['definitions']! as Map; + + expect(output['allOf'], [ + {r'$ref': '#/definitions/LazyUser'}, + ]); + expect(definitions['LazyUser'], { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + 'required': ['name'], + 'additionalProperties': false, + }); + }); + + test( + 'throws for lazy output schemas that resolve to runtime-only values', + () { + final schema = Ack.lazy( + 'LazyRole', + () => Ack.enumValues(_Role.values), + ); + + expect( + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, + ); + }, + ); + + test('throws for transform output schemas that are runtime-only', () { + final schema = Ack.string().transform(int.parse); + expect( - schema.standard.jsonSchema.output( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), - {'type': 'integer'}, + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, ); }); @@ -171,17 +328,61 @@ void main() { final schema = Ack.date(); expect( - schema.standard.jsonSchema.input( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), + schema.standard.jsonSchema.input(_draft7Options), schema.toJsonSchema(), ); expect( - () => schema.standard.jsonSchema.output( - const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), - ), + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, + ); + }); + + test('throws for bare instance runtime output schemas', () { + final schema = Ack.instance<_Animal>(); + + expect( + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, + ); + }); + + test('throws for model-backed discriminated runtime output schemas', () { + final schema = Ack.discriminated<_Animal>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Animal>( + decode: (_) => const _Animal('cat'), + encode: (_) => {'name': 'Milo'}, + ), + }, + ); + + expect( + () => schema.standard.jsonSchema.output(_draft7Options), throwsUnsupportedError, ); }); + + test('keeps list indexes numeric under default wrappers', () async { + final schema = Ack.list(Ack.string()).withDefault(const ['fallback']); + + final result = await Future.value(schema.standard.validate([1])); + + final failure = result as StandardFailure?>; + expect(failure.issues.single.path, [0]); + }); + + test('keeps list indexes numeric under codec wrappers', () async { + final schema = Ack.list(Ack.string()).codec>( + decode: (value) => value, + encode: (value) => value, + output: Ack.list(Ack.string()), + ); + + final result = await Future.value(schema.standard.validate([1])); + + final failure = result as StandardFailure?>; + expect(failure.issues.single.path, [0]); + }); }); } diff --git a/packages/standard_schema/CHANGELOG.md b/packages/standard_schema/CHANGELOG.md index 8b11520d..1befac28 100644 --- a/packages/standard_schema/CHANGELOG.md +++ b/packages/standard_schema/CHANGELOG.md @@ -4,17 +4,17 @@ Initial dev release reserving the `standard_schema` name on pub.dev. ### Added -- Add the `StandardTyped`, `StandardSchema`, and `StandardJsonSchema` +- Add the `StandardTypedV1`, `StandardSchemaV1`, and `StandardJsonSchemaV1` contracts, porting the two official Standard Schema interfaces, plus the - Dart-only `StandardSchemaWithJsonSchema` convenience for combined - implementers. + Dart-only `StandardSchemaWithJsonSchemaV1` convenience for combined + implementers. Unversioned names remain available as aliases. - Add standard results/issues, path segments, validation options, JSON Schema converter option types, and JSON Schema target constants. - Add the opt-in `utils.dart` library with `getDotPath` (renders an issue path in dot notation, e.g. `user.tags.1`) and `StandardSchemaError` (a throwable wrapping a failure's issues), porting `@standard-schema/utils`. -- Make the Standard Schema version marker fixed at `1`, matching the upstream - `version: 1` contract. +- Make the Standard Schema V1 props final with a fixed `version` marker of + `1`, matching the upstream `version: 1` contract. - Store failure issues, issue paths, and schema error issues as unmodifiable snapshots. - Add a package example covering validation, transformed output, JSON Schema diff --git a/packages/standard_schema/README.md b/packages/standard_schema/README.md index 5939b519..d3215ef6 100644 --- a/packages/standard_schema/README.md +++ b/packages/standard_schema/README.md @@ -5,9 +5,10 @@ Standard Schema contracts for Dart validators and converters. `standard_schema` is a Dart port of the contract family described by [standardschema.dev](https://standardschema.dev). It ports the two official interfaces — `StandardSchemaV1` and `StandardJSONSchemaV1` — as -`StandardSchema` and `StandardJsonSchema`, plus a Dart-only combined convenience -(`StandardSchemaWithJsonSchema`) for implementers that satisfy both with one -`standard` getter. +`StandardSchemaV1` and `StandardJsonSchemaV1`, plus a Dart-only combined +convenience (`StandardSchemaWithJsonSchemaV1`) for implementers that satisfy +both with one `standard` getter. Unversioned aliases are kept for convenience, +but public compatibility checks should prefer the V1 names. Libraries implement these small surfaces and consumers call them without depending on a vendor-specific schema tree. The package does not define a JSON @@ -20,11 +21,11 @@ In Dart, compatibility is nominal: a value is a Standard Schema when it implements the shared interface from this package. ```dart -if (schema is StandardSchema) { +if (schema is StandardSchemaV1) { final result = await Future.value(schema.standard.validate(value)); } -if (schema is StandardJsonSchema) { +if (schema is StandardJsonSchemaV1) { final json = schema.standard.jsonSchema.input( const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), ); @@ -53,11 +54,11 @@ idioms: ```dart import 'package:standard_schema/standard_schema.dart'; -final class RequiredStringSchema implements StandardSchema { +final class RequiredStringSchema implements StandardSchemaV1 { const RequiredStringSchema(); @override - StandardSchemaProps get standard => StandardSchemaProps( + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( vendor: 'example', validate: (value, [options]) { if (value is String && value.isNotEmpty) { @@ -78,12 +79,12 @@ final class RequiredStringSchema implements StandardSchema { import 'package:standard_schema/standard_schema.dart'; final class RequiredStringJsonSchema - implements StandardJsonSchema { + implements StandardJsonSchemaV1 { const RequiredStringJsonSchema(); @override - StandardJsonSchemaProps get standard => - StandardJsonSchemaProps( + StandardJsonSchemaPropsV1 get standard => + StandardJsonSchemaPropsV1( vendor: 'example', jsonSchema: StandardJsonSchemaConverter( input: (options) { @@ -106,7 +107,7 @@ package only defines the converter contract. ## Implement both traits -`StandardSchemaWithJsonSchema` is a Dart-only convenience — not a separate +`StandardSchemaWithJsonSchemaV1` is a Dart-only convenience — not a separate upstream interface. It models the structural intersection of the two official `~standard` Props when one getter must satisfy both traits. @@ -114,12 +115,12 @@ upstream interface. It models the structural intersection of the two official import 'package:standard_schema/standard_schema.dart'; final class RequiredStringSchemaWithJson - implements StandardSchemaWithJsonSchema { + implements StandardSchemaWithJsonSchemaV1 { const RequiredStringSchemaWithJson(); @override - StandardSchemaWithJsonSchemaProps get standard => - StandardSchemaWithJsonSchemaProps( + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( vendor: 'example', validate: (value, [options]) { if (value is String && value.isNotEmpty) { diff --git a/packages/standard_schema/example/standard_schema_example.dart b/packages/standard_schema/example/standard_schema_example.dart index c4478234..f8a43db6 100644 --- a/packages/standard_schema/example/standard_schema_example.dart +++ b/packages/standard_schema/example/standard_schema_example.dart @@ -3,11 +3,11 @@ import 'dart:async'; import 'package:standard_schema/standard_schema.dart'; import 'package:standard_schema/utils.dart'; -final class RequiredStringSchema implements StandardSchema { +final class RequiredStringSchema implements StandardSchemaV1 { const RequiredStringSchema(); @override - StandardSchemaProps get standard => StandardSchemaProps( + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( vendor: 'example', validate: (value, [options]) { if (value is String && value.isNotEmpty) { @@ -24,11 +24,11 @@ final class RequiredStringSchema implements StandardSchema { ); } -final class StringToIntSchema implements StandardSchema { +final class StringToIntSchema implements StandardSchemaV1 { const StringToIntSchema(); @override - StandardSchemaProps get standard => StandardSchemaProps( + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( vendor: 'example', validate: (value, [options]) { if (value is String) { @@ -46,12 +46,12 @@ final class StringToIntSchema implements StandardSchema { } final class RequiredStringWithJsonSchema - implements StandardSchemaWithJsonSchema { + implements StandardSchemaWithJsonSchemaV1 { const RequiredStringWithJsonSchema(); @override - StandardSchemaWithJsonSchemaProps get standard => - StandardSchemaWithJsonSchemaProps( + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( vendor: 'example', validate: const RequiredStringSchema().standard.validate, jsonSchema: StandardJsonSchemaConverter( diff --git a/packages/standard_schema/lib/src/standard_schema.dart b/packages/standard_schema/lib/src/standard_schema.dart index 40fb0a79..88ff7117 100644 --- a/packages/standard_schema/lib/src/standard_schema.dart +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -2,22 +2,22 @@ import 'dart:async'; /// An entity that exposes Standard Schema family metadata. /// -/// This is the Dart spelling of upstream `StandardTypedV1`: the upstream -/// `~standard` key is exposed as [standard], and the TypeScript-only phantom -/// `types` field is omitted because Dart generics carry input/output types. -abstract interface class StandardTyped { +/// The Dart spelling of upstream `StandardTypedV1`: the upstream `~standard` +/// key is exposed as [standard], and the TypeScript-only phantom `types` field +/// is omitted because Dart generics carry input/output types. +abstract interface class StandardTypedV1 { /// The standard typed properties. - StandardTypedProps get standard; + StandardTypedPropsV1 get standard; } /// A schema that validates unknown values. /// /// This is the Dart spelling of upstream `StandardSchemaV1`. -abstract interface class StandardSchema - implements StandardTyped { +abstract interface class StandardSchemaV1 + implements StandardTypedV1 { /// The standard schema properties. @override - StandardSchemaProps get standard; + StandardSchemaPropsV1 get standard; } /// An entity that can convert its input/output sides to JSON Schema. @@ -25,11 +25,11 @@ abstract interface class StandardSchema /// This is the Dart spelling of upstream `StandardJSONSchemaV1`. It is /// intentionally separate from [StandardSchema]; an object may implement one /// or both traits. -abstract interface class StandardJsonSchema - implements StandardTyped { +abstract interface class StandardJsonSchemaV1 + implements StandardTypedV1 { /// The standard JSON Schema properties. @override - StandardJsonSchemaProps get standard; + StandardJsonSchemaPropsV1 get standard; } /// Dart-only convenience for entities that implement both standard validation @@ -40,15 +40,28 @@ abstract interface class StandardJsonSchema /// A TypeScript schema implementing both has one `~standard` that structurally /// satisfies both Props. This interface models that intersection for Dart, /// where one getter cannot return two unrelated types. -abstract interface class StandardSchemaWithJsonSchema +abstract interface class StandardSchemaWithJsonSchemaV1 implements - StandardSchema, - StandardJsonSchema { + StandardSchemaV1, + StandardJsonSchemaV1 { /// The combined standard properties. @override - StandardSchemaWithJsonSchemaProps get standard; + StandardSchemaWithJsonSchemaPropsV1 get standard; } +/// Backward-compatible convenience alias for [StandardTypedV1]. +typedef StandardTyped = StandardTypedV1; + +/// Backward-compatible convenience alias for [StandardSchemaV1]. +typedef StandardSchema = StandardSchemaV1; + +/// Backward-compatible convenience alias for [StandardJsonSchemaV1]. +typedef StandardJsonSchema = StandardJsonSchemaV1; + +/// Backward-compatible convenience alias for [StandardSchemaWithJsonSchemaV1]. +typedef StandardSchemaWithJsonSchema = + StandardSchemaWithJsonSchemaV1; + /// Validates an unknown value, synchronously or asynchronously. /// /// Mirrors the spec's `validate(value, options?) => Result | Promise`. @@ -66,8 +79,8 @@ typedef StandardJsonSchemaConvert = Map Function(StandardJsonSchemaOptions options); /// The properties shared by every standard trait. -class StandardTypedProps { - const StandardTypedProps({required this.vendor}); +final class StandardTypedPropsV1 { + const StandardTypedPropsV1({required this.vendor}); /// The vendor name of the schema library. final String vendor; @@ -78,18 +91,18 @@ class StandardTypedProps { } /// The properties of a [StandardSchema]. -class StandardSchemaProps - extends StandardTypedProps { - const StandardSchemaProps({required super.vendor, required this.validate}); +final class StandardSchemaPropsV1 + extends StandardTypedPropsV1 { + const StandardSchemaPropsV1({required super.vendor, required this.validate}); /// Validates an unknown input value. final StandardValidate validate; } /// The properties of a [StandardJsonSchema]. -class StandardJsonSchemaProps - extends StandardTypedProps { - const StandardJsonSchemaProps({ +final class StandardJsonSchemaPropsV1 + extends StandardTypedPropsV1 { + const StandardJsonSchemaPropsV1({ required super.vendor, required this.jsonSchema, }); @@ -99,10 +112,10 @@ class StandardJsonSchemaProps } /// The properties of a [StandardSchemaWithJsonSchema]. -class StandardSchemaWithJsonSchemaProps - extends StandardSchemaProps - implements StandardJsonSchemaProps { - const StandardSchemaWithJsonSchemaProps({ +final class StandardSchemaWithJsonSchemaPropsV1 + extends StandardSchemaPropsV1 + implements StandardJsonSchemaPropsV1 { + const StandardSchemaWithJsonSchemaPropsV1({ required super.vendor, required super.validate, required this.jsonSchema, @@ -113,6 +126,22 @@ class StandardSchemaWithJsonSchemaProps final StandardJsonSchemaConverter jsonSchema; } +/// Backward-compatible convenience alias for [StandardTypedPropsV1]. +typedef StandardTypedProps = StandardTypedPropsV1; + +/// Backward-compatible convenience alias for [StandardSchemaPropsV1]. +typedef StandardSchemaProps = + StandardSchemaPropsV1; + +/// Backward-compatible convenience alias for [StandardJsonSchemaPropsV1]. +typedef StandardJsonSchemaProps = + StandardJsonSchemaPropsV1; + +/// Backward-compatible convenience alias for +/// [StandardSchemaWithJsonSchemaPropsV1]. +typedef StandardSchemaWithJsonSchemaProps = + StandardSchemaWithJsonSchemaPropsV1; + /// Optional parameters passed to [StandardValidate]. final class StandardValidateOptions { const StandardValidateOptions({this.libraryOptions}); diff --git a/packages/standard_schema/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart index adbf64d4..3d2b0016 100644 --- a/packages/standard_schema/test/standard_schema_test.dart +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -9,13 +9,13 @@ Future> _resolve( return result; } -final class _ValidationOnlySchema implements StandardSchema { +final class _ValidationOnlySchema implements StandardSchemaV1 { const _ValidationOnlySchema({this.async = false}); final bool async; @override - StandardSchemaProps get standard => StandardSchemaProps( + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( vendor: 'fake', validate: (value, [options]) { if (value == 'ok') { @@ -29,30 +29,32 @@ final class _ValidationOnlySchema implements StandardSchema { ); } -final class _JsonSchemaOnlySchema implements StandardJsonSchema { +final class _JsonSchemaOnlySchema implements StandardJsonSchemaV1 { const _JsonSchemaOnlySchema(); @override - StandardJsonSchemaProps get standard => StandardJsonSchemaProps( - vendor: 'fake-json', - jsonSchema: StandardJsonSchemaConverter( - input: (options) => { - r'$schema': options.target, - 'type': 'string', - if (options.libraryOptions case final options?) 'x-options': options, - }, - output: (options) => {'type': 'integer'}, - ), - ); + StandardJsonSchemaPropsV1 get standard => + StandardJsonSchemaPropsV1( + vendor: 'fake-json', + jsonSchema: StandardJsonSchemaConverter( + input: (options) => { + r'$schema': options.target, + 'type': 'string', + if (options.libraryOptions case final options?) + 'x-options': options, + }, + output: (options) => {'type': 'integer'}, + ), + ); } final class _CombinedSchema - implements StandardSchemaWithJsonSchema { + implements StandardSchemaWithJsonSchemaV1 { const _CombinedSchema(); @override - StandardSchemaWithJsonSchemaProps get standard => - StandardSchemaWithJsonSchemaProps( + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( vendor: 'fake-combined', validate: (value, [options]) => value == 'ok' ? const StandardSuccess(1) @@ -144,6 +146,37 @@ void main() { expect(failure.issues.single.path, ['items', 1]); }); + test('exposes canonical V1 names and unversioned aliases', () { + const schema = _ValidationOnlySchema(); + const jsonSchema = _JsonSchemaOnlySchema(); + const combined = _CombinedSchema(); + + expect(schema, isA>()); + expect(schema, isA>()); + expect(schema.standard, isA>()); + expect(schema.standard, isA>()); + expect(schema.standard.version, 1); + + expect(jsonSchema, isA>()); + expect(jsonSchema, isA>()); + expect( + jsonSchema.standard, + isA>(), + ); + expect(jsonSchema.standard, isA>()); + + expect(combined, isA>()); + expect(combined, isA>()); + expect( + combined.standard, + isA>(), + ); + expect( + combined.standard, + isA>(), + ); + }); + test('allows async validation and validate options', () async { const schema = _ValidationOnlySchema(async: true);