diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f43782cb..10685071 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,8 @@ - `packages/ack_generator`: build_runner generator + unit/integration tests. - `packages/ack_firebase_ai`: Firebase AI schema adapter. - `packages/ack_json_schema_builder`: JSON Schema adapter. +- `packages/standard_schema`: shared Standard Schema validation and JSON + Schema converter contracts. - `example`: sample usage. ## Environment and setup diff --git a/.github/workflows/release-standard-schema.yml b/.github/workflows/release-standard-schema.yml new file mode 100644 index 00000000..397af814 --- /dev/null +++ b/.github/workflows/release-standard-schema.yml @@ -0,0 +1,18 @@ +name: Publish standard_schema to pub.dev + +on: + push: + tags: + # Stable tags, e.g. standard_schema-v1.2.3 + - 'standard_schema-v[0-9]*.[0-9]*.[0-9]*' + # Pre-release tags, e.g. standard_schema-v0.0.1-dev.1 + - 'standard_schema-v[0-9]*.[0-9]*.[0-9]*-*' + +jobs: + publish: + uses: btwld/dart-actions/.github/workflows/publish.yml@main + permissions: + id-token: write + with: + packages_folder_path: "packages" + packages: standard_schema diff --git a/PUBLISHING.md b/PUBLISHING.md index e7e744a7..66d33e37 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,10 +1,20 @@ # Publishing Guide -This document explains how to version and publish the Ack packages to pub.dev. +This document explains how to version and publish the repository's packages to +pub.dev. ## Overview -The Ack project uses GitHub Releases to manage versioning and publishing. This approach provides: +The repository has two release tracks: + +- The Ack package family (`ack`, `ack_annotations`, `ack_generator`, + `ack_firebase_ai`, and `ack_json_schema_builder`) is versioned together and + released from `v*` tags. +- `standard_schema` is versioned independently and released from + `standard_schema-v*` tags. Publish a required `standard_schema` version + before publishing an Ack release that depends on it. + +GitHub Releases and tag workflows provide: - Centralized release management through GitHub's UI - Explicit version/changelog control in this repository @@ -19,10 +29,12 @@ Before creating a release: 1. Ensure all changes are committed and pushed to the `main` branch 2. Verify that all tests pass by running `dart run melos run test` (include `dart run melos run validate-jsonschema` and `dart run 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 versions following [Semantic Versioning](https://semver.org/). + Apply one version consistently to the Ack package family; version + `standard_schema` independently. 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 `dart run melos version`. -### 2. Create a GitHub Release +### 2. Create an Ack GitHub Release 1. Go to the [Releases page](https://github.com/btwld/ack/releases) in the repository 2. Click "Draft a new release" @@ -62,9 +74,14 @@ This release introduces [brief description of major changes]. 7. Click "Publish release" +For `standard_schema`, use the same process with a tag such as +`standard_schema-v0.0.1`. Its version and changelog are independent of +the Ack package family. + ### 3. Automated Steps -When the `v*` tag is pushed, the GitHub Actions workflow will automatically: +When a `v*` or `standard_schema-v*` tag is pushed, the corresponding GitHub +Actions workflow will automatically: 1. Run package tests (Dart/Flutter, depending on package type) 2. Run `dart pub publish --dry-run` for each package @@ -100,13 +117,18 @@ git push --follow-tags If you need to publish packages manually: ```bash -# Dry-run each package (validation only) +# Dry-run the independently versioned contract package first. +(cd packages/standard_schema && dart pub publish --dry-run) || exit 1 + +# Dry-run the synchronized Ack package family. 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 -# Actual publish (no dry-run) -dart run melos run publish +# Actual publish (no dry-run). Publish standard_schema before a dependent Ack +# release; use Melos package selection or publish from each package directory. +(cd packages/standard_schema && dart pub publish) +dart run melos publish --no-dry-run --yes --scope="ack*" ``` ## Troubleshooting @@ -130,7 +152,8 @@ If manual publishing fails: ## Version Numbering -The Ack project follows [Semantic Versioning](https://semver.org/): +Every published package follows [Semantic Versioning](https://semver.org/). +The Ack package family shares a version; `standard_schema` does not: - **Major version (x.0.0)**: Incompatible API changes - **Minor version (0.x.0)**: Backwards-compatible functionality additions diff --git a/README.md b/README.md index 594990ed..d2ec565d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ For AI agents: start at [`/llms.txt`](https://docs.page/btwld/ack/llms.txt). This repository is a monorepo containing: +- **[standard_schema](./packages/standard_schema)**: Vendor-neutral Standard + Schema validation and JSON Schema converter contracts for Dart - **[ack](./packages/ack)**: Core validation library with a fluent schema-building API, codecs, and JSON Schema export - **[ack_annotations](./packages/ack_annotations)**: The `@AckType()` annotation that marks schemas for code generation - **[ack_generator](./packages/ack_generator)**: Code generator that turns `@AckType()` schemas into type-safe extension types @@ -179,6 +181,25 @@ csv.encode(['a', 'b', 'c']); // 'a,b,c' Use `.transform(...)` for one-way (parse-only) conversions. See the [Codecs guide](https://docs.page/btwld/ack/core-concepts/codecs). +## Standard Schema interoperability + +Ack schemas implement the vendor-neutral contracts from +[`standard_schema`](./packages/standard_schema), which `package:ack/ack.dart` +re-exports. Use `schema.standard.validate(value)` for interoperable validation, +or convert the accepted input and produced output separately: + +```dart +final options = StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, +); + +final inputSchema = userSchema.standard.jsonSchema.input(options); +final outputSchema = userSchema.standard.jsonSchema.output(options); +``` + +Conversion throws when the requested target or value type cannot be represented +soundly as JSON Schema. + ## Documentation - Human docs: [docs.page/btwld/ack](https://docs.page/btwld/ack) diff --git a/llms.txt b/llms.txt index 7c4e756c..fd50f091 100644 --- a/llms.txt +++ b/llms.txt @@ -13,6 +13,7 @@ schema variables or getters with `@AckType()` and run `ack_generator`. 3. `ack_generator`: generates extension types for annotated top-level schemas 4. `ack_firebase_ai`: converts Ack schemas to Firebase AI structured-output schemas 5. `ack_json_schema_builder`: converts Ack schemas to `json_schema_builder` schemas +6. `standard_schema`: vendor-neutral Standard Schema validation and JSON Schema converter contracts ## Core runtime usage @@ -46,6 +47,21 @@ back. `parse`/`safeParse` decode; `encode`/`safeEncode` encode. - `schema.transform(fn)` is one-way (parse only); encoding it fails. - A codec exports the JSON Schema of its boundary (input) schema. +## Standard Schema interoperability + +Every `AckSchema` implements the shared contracts from `standard_schema`, which +`package:ack/ack.dart` re-exports: + +- `schema.standard.validate(value)` returns `StandardSuccess` or + `StandardFailure` and produces the same runtime value as `safeParse`. +- `schema.standard.jsonSchema.input(options)` describes the boundary type; + `output(options)` describes the produced runtime type. +- Ack currently supports `JsonSchemaTarget.draft07`. Conversion throws for an + unsupported target or a type that is not soundly JSON-representable. +- `schema.toSchemaModel()` and `schema.toJsonSchema()` remain Ack's legacy + boundary/export APIs for adapters; they may preserve warning-backed + approximations that strict Standard JSON Schema conversion rejects. + ## AckType generation `@AckType()` is supported only on: diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 0dd17178..7c8700b1 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,5 +1,20 @@ ## 1.1.0 +### Added + +* `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 + +* Standard JSON Schema `input()` describes the boundary type, including valid + default/null behavior without leaking codec runtime constraints; `output()` + describes the runtime type returned by validation. Conversion throws when + either type is not soundly JSON-representable. +* Standard Schema issue paths preserve list indexes as integer path segments, + including through defaults and codecs. + ### Fixed * Keep `safeParse` non-throwing when refinements or constraints throw @@ -16,8 +31,9 @@ ### Behavior changes -No public API changed (verified with `dart_apitool` against 1.0.1); the following -now reject inputs that previously passed or misbehaved silently. +Aside from the additive Standard Schema interoperability API, existing +validation APIs remain source-compatible; the following now reject inputs that +previously passed or misbehaved silently. * Validate numeric `multipleOf`, IPv6, and RFC 3339 date-time values strictly. Announced leap seconds are preserved by `Ack.string().datetime()` but rejected diff --git a/packages/ack/README.md b/packages/ack/README.md index 199a4f14..72151f70 100644 --- a/packages/ack/README.md +++ b/packages/ack/README.md @@ -48,6 +48,27 @@ if (result.isOk) { Use `.optional()` when a field may be omitted entirely. Chain `.nullable()` if a present field may hold `null`, or combine both for an optional-and-nullable value. +## Standard Schema + +Every Ack schema implements the `standard_schema` validation and JSON Schema +converter contracts, re-exported by `package:ack/ack.dart`: + +```dart +final standardResult = userSchema.standard.validate({ + 'name': 'Jane Doe', + 'email': 'jane@example.com', +}); +final options = StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, +); +final inputSchema = userSchema.standard.jsonSchema.input(options); +final outputSchema = userSchema.standard.jsonSchema.output(options); +``` + +Input conversion describes the boundary type, while output conversion describes +the produced runtime type. Conversion throws when a type is not soundly +JSON-representable or the requested target is unsupported. + ## Documentation - [Full documentation](https://docs.page/btwld/ack) @@ -55,6 +76,8 @@ Use `.optional()` when a field may be omitted entirely. Chain `.nullable()` if a ## Related Packages +- [standard_schema](https://pub.dev/packages/standard_schema) — Shared + Standard Schema validation and JSON Schema converter contracts - [ack_generator](https://pub.dev/packages/ack_generator) — Code generator for typed wrappers from `@AckType()` schemas - [ack_firebase_ai](https://pub.dev/packages/ack_firebase_ai) — Firebase AI (Gemini) schema converter - [ack_json_schema_builder](https://pub.dev/packages/ack_json_schema_builder) — JSON Schema converter 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/context.dart b/packages/ack/lib/src/context.dart index 118e27b1..dde94a79 100644 --- a/packages/ack/lib/src/context.dart +++ b/packages/ack/lib/src/context.dart @@ -9,7 +9,15 @@ class SchemaContext { final Object? value; final AnyAckSchema schema; final SchemaContext? parent; + + /// String segment used by the legacy JSON Pointer-style [path]. final String? pathSegment; + + /// Raw property key represented by this context. + /// + /// Object properties use string keys, list items use integer indexes, and + /// transparent wrapper branches use `''`. Defaults to [pathSegment]. + final Object? pathKey; final SchemaOperation operation; const SchemaContext({ @@ -18,8 +26,9 @@ class SchemaContext { required this.value, this.parent, this.pathSegment, + Object? pathKey, this.operation = SchemaOperation.parse, - }); + }) : pathKey = pathKey ?? pathSegment; /// Escapes a JSON Pointer segment per RFC 6901. static String _escapeJsonPointerSegment(String segment) { @@ -54,6 +63,7 @@ class SchemaContext { required AnyAckSchema schema, required Object? value, String? pathSegment, + Object? pathKey, SchemaOperation? operation, }) { return SchemaContext( @@ -62,6 +72,7 @@ class SchemaContext { value: value, parent: this, pathSegment: pathSegment, + pathKey: pathKey, operation: operation ?? this.operation, ); } 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 d408c0c5..29a3c498 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'; @@ -17,12 +18,28 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + AckSchemaModel toSchemaModel() => + _SchemaModelBuilder(_SchemaModelSide.boundary).build(this); + + AckSchemaModel toStandardInputSchemaModel() => + _SchemaModelBuilder(_SchemaModelSide.input).build(this); + + AckSchemaModel toStandardOutputSchemaModel() => + _SchemaModelBuilder(_SchemaModelSide.output).build(this); } +enum _SchemaModelSide { boundary, input, output } + +typedef _ResolvedDefault = ({bool isValid, Object? value}); + final class _SchemaModelBuilder { + _SchemaModelBuilder(this.side); + + final _SchemaModelSide side; final _definitions = {}; final _targets = {}; + final _resolvedDefaults = + Map, _ResolvedDefault>.identity(); Map _mergeRootDefinitions(Object? existingDefinitions) { final lazyDefinitions = { @@ -68,20 +85,18 @@ 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 // would emit duplicate warnings (e.g. datetime range under a default). - var wrapped = schema is DefaultSchema + var wrapped = + schema is DefaultSchema || + (side == _SchemaModelSide.input && schema is CodecSchema) ? layered : _applyConstraints(layered, schema); @@ -93,7 +108,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.', ), @@ -132,6 +147,38 @@ 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.boundary) { + return schema.isNullable || base.nullable; + } + + if (schema is DefaultSchema) { + return side == _SchemaModelSide.input && + _resolvedDefaultFor(schema).isValid; + } + + return schema.isNullable; + } + + 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, @@ -158,6 +205,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], @@ -174,6 +227,13 @@ final class _SchemaModelBuilder { } AckSchemaModel _object(ObjectSchema schema) { + if (side != _SchemaModelSide.boundary && schema.additionalProperties) { + throw UnsupportedError( + 'Ack cannot represent unvalidated additional property values as ' + 'Standard JSON Schema.', + ); + } + final properties = {}; final required = []; final ordering = []; @@ -210,6 +270,12 @@ final class _SchemaModelBuilder { } AckSchemaModel _instance(InstanceSchema schema) { + if (side != _SchemaModelSide.boundary) { + throw UnsupportedError( + 'Ack cannot represent Ack.instance() as Standard 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. @@ -226,7 +292,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.', ), @@ -251,7 +317,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.', ), @@ -260,6 +326,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 {}, @@ -327,7 +401,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: { @@ -338,6 +412,47 @@ final class _SchemaModelBuilder { ]); } + _ResolvedDefault _resolvedDefaultFor(DefaultSchema schema) { + return _resolvedDefaults.putIfAbsent(schema, () { + final result = schema.resolveDefaultWithContext( + _defaultExportContext(schema), + ); + return (isValid: result.isOk, value: result.getOrNull()); + }); + } + + /// Best-effort export of a [DefaultSchema] default value. + /// + /// 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) { + final resolved = _resolvedDefaultFor(schema); + if (!resolved.isValid || resolved.value == null) return null; + + if (side == _SchemaModelSide.output) { + return _jsonRoundTripOrNull(resolved.value); + } + + final encoded = schema.inner.safeEncode(resolved.value); + if (encoded.isFail) return null; + + return _jsonRoundTripOrNull(encoded.getOrNull()); + } + + bool _isRequiredObjectProperty(AckSchema schema) { + if (schema is DefaultSchema) { + final resolved = _resolvedDefaultFor(schema); + if (side != _SchemaModelSide.output) { + return !schema.isOptional && !resolved.isValid; + } + + return !resolved.isValid || resolved.value != null; + } + + return !schema.isOptional; + } + AckSchemaModel build(AckSchema schema) { final root = _build(schema); if (_definitions.isEmpty) return root; @@ -418,7 +533,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: { @@ -430,36 +545,6 @@ 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) { - final resolved = schema.resolveDefaultWithContext( - _defaultExportContext(schema), - ); - if (resolved.isFail) return null; - - final defaultValue = resolved.getOrNull(); - if (defaultValue == null) return null; - - 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; - } - - return true; -} - /// Throwaway [SchemaContext] used only to drive /// [DefaultSchema.resolveDefaultWithContext]. Errors produced through this /// context are never surfaced — both callers consume only `.isOk` / 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..5367dff0 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,23 @@ 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'; + + /// A bare `Ack.instance()` has no faithful JSON boundary. + static const String instanceJsonBoundary = 'ack_instance_json_boundary'; + + /// An `Ack.any()` schema represents the full JSON-safe value domain. + static const String anyJsonBoundary = 'ack_any_json_boundary'; + + /// An `Ack.lazy(...)` schema'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/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index a0848e0d..b55a5c78 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -57,6 +57,7 @@ final class ListSchema schema: itemSchema, value: item, pathSegment: '$i', + pathKey: i, ); final r = parse ? itemSchema.parseWithContext(item, itemCtx) @@ -123,6 +124,7 @@ final class ListSchema schema: itemSchema, value: item, pathSegment: '$i', + pathKey: i, operation: SchemaOperation.encode, ); try { diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 7e6e3f63..3707063a 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'; @@ -62,7 +63,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; @@ -270,6 +272,53 @@ abstract class AckSchema { @protected bool get acceptsNull => isNullable; + @override + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( + vendor: 'ack', + validate: _validateStandard, + jsonSchema: StandardJsonSchemaConverter( + input: _standardJsonSchemaInput, + output: _standardJsonSchemaOutput, + ), + ); + + StandardResult _validateStandard( + Object? value, [ + StandardValidateOptions? _, + ]) { + return switch (safeParse(value)) { + Ok(value: final value) => StandardSuccess(value), + Fail(error: final error) => StandardFailure( + _standardIssuesFor(error).toList(growable: false), + ), + }; + } + + Map _standardJsonSchemaInput( + StandardJsonSchemaOptions options, + ) { + _checkStandardJsonSchemaTarget(options); + return toStandardInputSchemaModel().toJsonSchema(); + } + + Map _standardJsonSchemaOutput( + StandardJsonSchemaOptions options, + ) { + _checkStandardJsonSchemaTarget(options); + return toStandardOutputSchemaModel().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}.', + ); + } + /// The schema type category for this schema. @protected SchemaType get schemaType; @@ -521,6 +570,40 @@ abstract class AckSchema { } } +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 segment = cursor.pathKey; + if (segment != null && segment != '') reversed.add(segment); + cursor = cursor.parent!; + } + + return reversed.reversed.toList(growable: false); +} + /// Builds a type-mismatch error without throwing when [actualValue] is an /// unsupported Dart runtime object outside ACK's JSON-ish schema categories. SchemaError _buildTypeMismatch({ diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index b94181ea..5e163a67 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_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..c21f56e7 --- /dev/null +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -0,0 +1,512 @@ +import 'package:ack/ack.dart'; +import 'package:standard_schema/utils.dart'; +import 'package:test/test.dart'; + +enum _Role { admin, member } + +final class _Animal { + const _Animal(this.kind); + + final String kind; +} + +final class _MinimumIntConstraint extends Constraint + with Validator, JsonSchemaSpec { + const _MinimumIntConstraint() + : super(constraintKey: 'minimum_int', description: 'At least 1.'); + + @override + bool isValid(int value) => value >= 1; + + @override + String buildMessage(int value) => 'Expected at least 1.'; + + @override + Map toJsonSchema() => const {'minimum': 1}; +} + +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 { + 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('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(), + 'roles': Ack.list(Ack.enumValues(_Role.values)), + }); + + expect( + schema.standard.jsonSchema.input(_draft7Options), + schema.toJsonSchema(), + ); + }); + + test('models valid defaults as nullable Standard input only', () { + final schema = Ack.integer().withDefault(7); + + expect(schema.standard.validate(null), isA>()); + expect(schema.toJsonSchema(), {'type': 'integer', 'default': 7}); + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'default': 7, + 'anyOf': [ + {'type': 'integer'}, + {'type': 'null'}, + ], + }); + }); + + test('keeps invalid defaults out of the Standard input domain', () { + final schema = Ack.integer().min(10).withDefault(5); + + expect(schema.standard.validate(null), isA>()); + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'type': 'integer', + 'minimum': 10, + }); + }); + + test('resolves each default once per Standard conversion', () { + var validations = 0; + final schema = Ack.object({ + 'count': Ack.string() + .transform(int.parse) + .refine((value) { + validations++; + return true; + }) + .withDefault(7), + }); + + schema.standard.jsonSchema.input(_draft7Options); + + expect(validations, 1); + }); + + test('does not leak codec runtime constraints into input schemas', () { + final schema = _stringToIntCodec().withConstraint( + const _MinimumIntConstraint(), + ); + + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'type': 'string', + 'x-transformed': true, + }); + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'integer', + 'minimum': 1, + }); + }); + + test('uses codec null policy instead of nested schema nullability', () { + final schema = Ack.string() + .nullable() + .codec( + decode: int.parse, + encode: (value) => value.toString(), + output: Ack.integer().nullable(), + ) + .nullable(value: false); + + expect(schema.standard.validate(null), isA>()); + expect(schema.standard.jsonSchema.input(_draft7Options), { + 'type': 'string', + 'x-transformed': true, + }); + expect(schema.standard.jsonSchema.output(_draft7Options), { + 'type': 'integer', + }); + }); + + test('rejects bare instance Standard input schemas', () { + final schema = Ack.instance<_Animal>(); + + expect( + () => schema.standard.jsonSchema.input(_draft7Options), + throwsUnsupportedError, + ); + expect(schema.toSchemaModel().warnings, isNotEmpty); + }); + + test('rejects passthrough object Standard JSON Schemas', () { + final schema = Ack.object(const {}, additionalProperties: true); + final result = schema.standard.validate({ + 'createdAt': DateTime.utc(2026), + }); + + expect(result, isA>()); + expect( + () => schema.standard.jsonSchema.input(_draft7Options), + throwsUnsupportedError, + ); + expect( + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, + ); + }); + + test('throws for enum runtime output schemas', () { + final schema = Ack.enumValues(_Role.values); + + expect( + () => schema.standard.jsonSchema.output(_draft7Options), + throwsUnsupportedError, + ); + }); + + 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 = _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': { + 'default': 7, + 'anyOf': [ + {'type': 'integer'}, + {'type': 'null'}, + ], + }, + }, + '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().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.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(_draft7Options), + throwsUnsupportedError, + ); + }); + + test('throws for codec output schemas that are runtime-only', () { + final schema = Ack.date(); + + expect( + schema.standard.jsonSchema.input(_draft7Options), + schema.toJsonSchema(), + ); + expect( + () => 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/.pubignore b/packages/standard_schema/.pubignore new file mode 100644 index 00000000..b86a5825 --- /dev/null +++ b/packages/standard_schema/.pubignore @@ -0,0 +1,3 @@ +.context/ +coverage/ +*.iml diff --git a/packages/standard_schema/CHANGELOG.md b/packages/standard_schema/CHANGELOG.md new file mode 100644 index 00000000..7bacc828 --- /dev/null +++ b/packages/standard_schema/CHANGELOG.md @@ -0,0 +1,29 @@ +## 0.0.1 + +### Added + +- Add canonical `StandardTypedV1`, `StandardSchemaV1`, and + `StandardJsonSchemaV1` names. The dev.0 names remain available as aliases. +- Add `StandardPathSegment` and the opt-in `utils.dart` library with + `getDotPath` and `StandardSchemaError`. +- Add a complete package example covering validation, transformed output, + JSON Schema conversion, and dot-path rendering. + +### Changed + +- Fix the V1 props marker at `version == 1`; constructors no longer accept an + arbitrary version. +- Make props implementations final and snapshot failure issues, issue paths, + and schema-error issues into unmodifiable lists. + +## 0.0.1-dev.0 + +Initial dev release reserving the `standard_schema` name on pub.dev. + +### Added + +- Add the unversioned Standard Schema validation and JSON Schema converter + contracts, shared props, validation results/issues, converter options, and + JSON Schema target constants. +- Add a Dart-only combined interface for implementers that expose validation + and JSON Schema conversion from one `standard` getter. diff --git a/packages/standard_schema/LICENSE b/packages/standard_schema/LICENSE new file mode 100644 index 00000000..59677679 --- /dev/null +++ b/packages/standard_schema/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. diff --git a/packages/standard_schema/README.md b/packages/standard_schema/README.md new file mode 100644 index 00000000..d3215ef6 --- /dev/null +++ b/packages/standard_schema/README.md @@ -0,0 +1,176 @@ +# standard_schema + +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 +`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 +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 StandardSchemaV1) { + final result = await Future.value(schema.standard.validate(value)); +} + +if (schema is StandardJsonSchemaV1) { + 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 +import 'package:standard_schema/standard_schema.dart'; + +final class RequiredStringSchema implements StandardSchemaV1 { + const RequiredStringSchema(); + + @override + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( + vendor: 'example', + validate: (value, [options]) { + if (value is String && value.isNotEmpty) { + return StandardSuccess(value); + } + + return StandardFailure([ + StandardIssue(message: 'Expected a non-empty string'), + ]); + }, + ); +} +``` + +## Expose JSON Schema conversion + +```dart +import 'package:standard_schema/standard_schema.dart'; + +final class RequiredStringJsonSchema + implements StandardJsonSchemaV1 { + const RequiredStringJsonSchema(); + + @override + StandardJsonSchemaPropsV1 get standard => + StandardJsonSchemaPropsV1( + 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. +The concrete JSON Schema output is owned by the implementing library; this +package only defines the converter contract. + +## Implement both traits + +`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. + +```dart +import 'package:standard_schema/standard_schema.dart'; + +final class RequiredStringSchemaWithJson + implements StandardSchemaWithJsonSchemaV1 { + const RequiredStringSchemaWithJson(); + + @override + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( + vendor: 'example', + validate: (value, [options]) { + if (value is String && value.isNotEmpty) { + return StandardSuccess(value); + } + + return 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'}, + ), + ); +} +``` + +## 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 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 = await Future.value(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/standard_schema/analysis_options.yaml b/packages/standard_schema/analysis_options.yaml new file mode 100644 index 00000000..d04adaf9 --- /dev/null +++ b/packages/standard_schema/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/standard_schema/example/standard_schema_example.dart b/packages/standard_schema/example/standard_schema_example.dart new file mode 100644 index 00000000..f8a43db6 --- /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 StandardSchemaV1 { + const RequiredStringSchema(); + + @override + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( + 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 StandardSchemaV1 { + const StringToIntSchema(); + + @override + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( + 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 StandardSchemaWithJsonSchemaV1 { + const RequiredStringWithJsonSchema(); + + @override + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( + 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 new file mode 100644 index 00000000..88ff7117 --- /dev/null +++ b/packages/standard_schema/lib/src/standard_schema.dart @@ -0,0 +1,245 @@ +import 'dart:async'; + +/// An entity that exposes Standard Schema family metadata. +/// +/// 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. + StandardTypedPropsV1 get standard; +} + +/// A schema that validates unknown values. +/// +/// This is the Dart spelling of upstream `StandardSchemaV1`. +abstract interface class StandardSchemaV1 + implements StandardTypedV1 { + /// The standard schema properties. + @override + StandardSchemaPropsV1 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 StandardJsonSchemaV1 + implements StandardTypedV1 { + /// The standard JSON Schema properties. + @override + StandardJsonSchemaPropsV1 get standard; +} + +/// 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 StandardSchemaWithJsonSchemaV1 + implements + StandardSchemaV1, + StandardJsonSchemaV1 { + /// The combined standard properties. + @override + 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`. +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 shared by every standard trait. +final class StandardTypedPropsV1 { + const StandardTypedPropsV1({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). + int get version => 1; +} + +/// The properties of a [StandardSchema]. +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]. +final class StandardJsonSchemaPropsV1 + extends StandardTypedPropsV1 { + const StandardJsonSchemaPropsV1({ + required super.vendor, + required this.jsonSchema, + }); + + /// The JSON Schema tier converter. + final StandardJsonSchemaConverter jsonSchema; +} + +/// The properties of a [StandardSchemaWithJsonSchema]. +final class StandardSchemaWithJsonSchemaPropsV1 + extends StandardSchemaPropsV1 + implements StandardJsonSchemaPropsV1 { + const StandardSchemaWithJsonSchemaPropsV1({ + required super.vendor, + required super.validate, + required this.jsonSchema, + }); + + /// The JSON Schema tier converter. + @override + 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}); + + /// 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 { + StandardFailure(Iterable issues) + : issues = List.unmodifiable(issues); + + /// The issues describing why validation failed. + final List issues; +} + +/// A single validation issue. +final class StandardIssue { + StandardIssue({required this.message, Iterable path = const []}) + : path = List.unmodifiable(path); + + /// The error message of the issue. + final String message; + + /// 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 + /// [StandardPathSegment.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] +/// describes the value accepted at the boundary, while [output] describes the +/// value produced at runtime. +final class StandardJsonSchemaConverter { + const StandardJsonSchemaConverter({ + required this.input, + required this.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}); + + /// The target JSON Schema dialect. See [JsonSchemaTarget]. + final JsonSchemaTarget target; + + /// Vendor-specific parameters, if any. + final Map? libraryOptions; +} + +/// 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 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'); + + /// 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/standard_schema/lib/src/utils.dart b/packages/standard_schema/lib/src/utils.dart new file mode 100644 index 00000000..a70f5c12 --- /dev/null +++ b/packages/standard_schema/lib/src/utils.dart @@ -0,0 +1,51 @@ +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`. +String? getDotPath(StandardIssue issue) { + if (issue.path.isEmpty) return null; + final dotPath = StringBuffer(); + for (final segment in issue.path) { + final key = segment is StandardPathSegment ? segment.key : segment; + if (key is String || key is num) { + if (dotPath.isNotEmpty) dotPath.write('.'); + dotPath.write(key); + } 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(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; + + /// The error message, taken from the first issue. + final String message; + + @override + String toString() => 'StandardSchemaError: $message'; +} diff --git a/packages/standard_schema/lib/standard_schema.dart b/packages/standard_schema/lib/standard_schema.dart new file mode 100644 index 00000000..77774fbb --- /dev/null +++ b/packages/standard_schema/lib/standard_schema.dart @@ -0,0 +1,4 @@ +/// Standard Schema contracts for Dart validators and converters. +library; + +export 'src/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/standard_schema/pubspec.yaml b/packages/standard_schema/pubspec.yaml new file mode 100644 index 00000000..87f8318b --- /dev/null +++ b/packages/standard_schema/pubspec.yaml @@ -0,0 +1,15 @@ +name: standard_schema +description: Standard Schema contracts for Dart validators and JSON Schema converters. +version: 0.0.1 +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' + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.15 diff --git a/packages/standard_schema/test/standard_schema_test.dart b/packages/standard_schema/test/standard_schema_test.dart new file mode 100644 index 00000000..3d2b0016 --- /dev/null +++ b/packages/standard_schema/test/standard_schema_test.dart @@ -0,0 +1,354 @@ +import 'dart:async'; + +import 'package:standard_schema/standard_schema.dart'; +import 'package:test/test.dart'; + +Future> _resolve( + FutureOr> result, +) async { + return result; +} + +final class _ValidationOnlySchema implements StandardSchemaV1 { + const _ValidationOnlySchema({this.async = false}); + + final bool async; + + @override + StandardSchemaPropsV1 get standard => StandardSchemaPropsV1( + vendor: 'fake', + validate: (value, [options]) { + if (value == 'ok') { + final result = const StandardSuccess(1); + return async ? Future>.value(result) : result; + } + return StandardFailure([ + StandardIssue(message: 'Not ok', path: ['items', 1]), + ]); + }, + ); +} + +final class _JsonSchemaOnlySchema implements StandardJsonSchemaV1 { + const _JsonSchemaOnlySchema(); + + @override + 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 StandardSchemaWithJsonSchemaV1 { + const _CombinedSchema(); + + @override + StandardSchemaWithJsonSchemaPropsV1 get standard => + StandardSchemaWithJsonSchemaPropsV1( + vendor: 'fake-combined', + validate: (value, [options]) => value == 'ok' + ? const StandardSuccess(1) + : StandardFailure([StandardIssue(message: 'Not ok')]), + jsonSchema: StandardJsonSchemaConverter( + input: (options) => {'type': 'string'}, + output: (options) => {'type': 'integer'}, + ), + ); +} + +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(); + + 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')); + + 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('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); + + final result = await _resolve( + schema.standard.validate( + 'ok', + const StandardValidateOptions(libraryOptions: {'mode': 'strict'}), + ), + ); + + expect(result, isA>()); + }); + + test('models JSON Schema converters as a separate trait', () { + const schema = _JsonSchemaOnlySchema(); + + expect(schema.standard.vendor, 'fake-json'); + expect(schema.standard.version, 1); + expect(schema, isNot(isA>())); + expect( + schema.standard.jsonSchema.input( + const StandardJsonSchemaOptions( + target: JsonSchemaTarget.draft07, + libraryOptions: {'dialect': 'draft7'}, + ), + ), + { + r'$schema': JsonSchemaTarget.draft07, + 'type': 'string', + 'x-options': {'dialect': 'draft7'}, + }, + ); + expect( + schema.standard.jsonSchema.output( + const StandardJsonSchemaOptions(target: JsonSchemaTarget.draft07), + ), + {'type': 'integer'}, + ); + }); + + 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', + () { + 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'}, + ); + }, + ); + + 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('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); + + path.add('email'); + + expect(issue.path, ['user']); + 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'; + 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', + 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 new file mode 100644 index 00000000..0621171c --- /dev/null +++ b/packages/standard_schema/test/utils_test.dart @@ -0,0 +1,110 @@ +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(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(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(StandardIssue(message: 'm', path: ['a', 0, 'b'])), + 'a.0.b', + ); + expect( + 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( + StandardIssue( + message: 'm', + path: [ + StandardPathSegment(key: 'items'), + StandardPathSegment(key: 1), + 'name', + ], + ), + ), + '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', () { + final issues = [ + StandardIssue(message: 'first', path: ['a']), + StandardIssue(message: 'second'), + ]; + final error = StandardSchemaError(issues); + + expect(error, isA()); + 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('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); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index c1b6c13a..9c914a82 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/standard_schema - example dependencies: @@ -61,6 +62,7 @@ melos: - ack_generator - ack_firebase_ai - ack_json_schema_builder + - standard_schema - ack_example - ack_annotations diff --git a/scripts/api_check.dart b/scripts/api_check.dart index 6bbfdbe9..e5bbf754 100755 --- a/scripts/api_check.dart +++ b/scripts/api_check.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'src/workspace_packages.dart'; -final ackPackages = publishableAckPackages; +const availablePackages = publishablePackages; const dartApiToolVersion = '0.23.0'; Future main(List args) async { @@ -17,9 +17,9 @@ Future main(List args) async { // Check if first argument is a version (starts with v or is a number) final firstArg = args[0]; if (firstArg.startsWith('v') || RegExp(r'^\d+\.\d+').hasMatch(firstArg)) { - // First argument is a version, check all packages + // A shared version applies to the synchronized Ack package family. version = firstArg; - } else if (ackPackages.contains(firstArg)) { + } else if (availablePackages.contains(firstArg)) { // First argument is a package name packageName = firstArg; if (args.length > 1) { @@ -27,7 +27,7 @@ Future main(List args) async { } } else { print( - '❌ Invalid package name. Available packages: ${ackPackages.join(', ')}', + '❌ Invalid package name. Available packages: ${availablePackages.join(', ')}', ); printUsage(); exit(1); @@ -39,7 +39,7 @@ Future main(List args) async { if (packageName != null) { version = await getLatestVersion(packageName); } else { - print('❌ Please specify a version when checking all packages'); + print('❌ Please specify a version when checking the Ack package family'); printUsage(); exit(1); } @@ -65,7 +65,9 @@ Future main(List args) async { } // Check packages - final packagesToCheck = packageName != null ? [packageName] : ackPackages; + final packagesToCheck = packageName != null + ? [packageName] + : publishableAckPackages; final reports = []; var hasFailures = false; @@ -197,8 +199,8 @@ void printUsage() { print('Usage: dart scripts/api_check.dart [PACKAGE] [VERSION]'); print(''); print('Arguments:'); - print(' PACKAGE Package to check (${ackPackages.join('|')})'); - print(' If not provided, checks all packages'); + print(' PACKAGE Package to check (${availablePackages.join('|')})'); + print(' If not provided, checks the Ack release-family packages'); print(' VERSION Version to compare against (e.g., v0.2.0 or 0.2.0)'); print( ' If not provided with single package, uses latest from pub.dev', @@ -212,7 +214,7 @@ void printUsage() { ' dart scripts/api_check.dart ack v0.2.0 # Check ack against v0.2.0', ); print( - ' dart scripts/api_check.dart v0.2.0 # Check all packages against v0.2.0', + ' dart scripts/api_check.dart v0.2.0 # Check the Ack family against v0.2.0', ); print(''); print('Melos usage:'); diff --git a/scripts/src/workspace_packages.dart b/scripts/src/workspace_packages.dart index 908a3138..20e4e632 100644 --- a/scripts/src/workspace_packages.dart +++ b/scripts/src/workspace_packages.dart @@ -1,3 +1,6 @@ +/// The independently versioned Standard Schema contract package. +const standardSchemaPackage = 'standard_schema'; + /// The publishable ack packages in the melos workspace, in publish order. const publishableAckPackages = [ 'ack', @@ -6,3 +9,9 @@ const publishableAckPackages = [ 'ack_firebase_ai', 'ack_json_schema_builder', ]; + +/// Every publishable package in dependency order. +const publishablePackages = [ + standardSchemaPackage, + ...publishableAckPackages, +]; diff --git a/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart index 75cebb65..357715dc 100644 --- a/test/scripts/api_check_test.dart +++ b/test/scripts/api_check_test.dart @@ -3,6 +3,35 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { + test( + 'accepts the independently versioned standard_schema package', + () async { + final fakeBin = Directory.systemTemp.createTempSync('ack-api-check-'); + final workingDirectory = Directory.systemTemp.createTempSync( + 'ack-api-report-', + ); + addTearDown(() => fakeBin.deleteSync(recursive: true)); + addTearDown(() => workingDirectory.deleteSync(recursive: true)); + + _writeExecutable(fakeBin, 'dart', '#!/bin/sh\nexit 0\n'); + final scriptPath = File('scripts/api_check.dart').absolute.path; + + final result = await Process.run( + Platform.resolvedExecutable, + [scriptPath, 'standard_schema', '0.0.1-dev.0'], + workingDirectory: workingDirectory.path, + environment: { + ...Platform.environment, + 'PATH': '${fakeBin.path}:/usr/bin:/bin', + }, + ); + + expect(result.stderr, isNot(contains('Invalid package name'))); + expect(result.stdout, contains('Checking standard_schema package')); + }, + skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, + ); + test( 'activation failures stop API checks with a non-zero exit code', () async {