diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index ac935673..90d34b80 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -20,12 +20,20 @@ * `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 * 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/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..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 24ea9474..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,14 +93,14 @@ 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 { 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.', ), @@ -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. @@ -225,7 +272,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 +297,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.', ), @@ -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 {}, @@ -326,7 +381,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 +429,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: { @@ -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/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/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 313c4f83..4f35b404 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; @@ -229,6 +231,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? 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); + 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; diff --git a/packages/ack/lib/src/validation/schema_error.dart b/packages/ack/lib/src/validation/schema_error.dart index 168d251a..6ca9499e 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,50 @@ 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 segment = cursor.pathSegment; + + if (segment != null && segment != '') { + reversed.add(segment); + } + + cursor = cursor.parent!; + } + + return reversed.reversed.toList(growable: false); +} + @immutable class SchemaValidationError extends SchemaError { SchemaValidationError({ diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 84ce8d43..30b2abc9 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..7e1467da --- /dev/null +++ b/packages/ack/test/validation/standard_schema_conformance_test.dart @@ -0,0 +1,388 @@ +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; +} + +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('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': {'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().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/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index e3773ab7..c07621e8 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 <3.13.0' + # 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": { 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..1befac28 --- /dev/null +++ b/packages/standard_schema/CHANGELOG.md @@ -0,0 +1,21 @@ +## 0.0.1-dev.0 + +Initial dev release reserving the `standard_schema` name on pub.dev. + +### Added + +- Add the `StandardTypedV1`, `StandardSchemaV1`, and `StandardJsonSchemaV1` + contracts, porting the two official Standard Schema interfaces, plus the + 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 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 + conversion, and dot-path rendering. diff --git a/packages/standard_schema/LICENSE b/packages/standard_schema/LICENSE new file mode 100644 index 00000000..c936b029 --- /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. \ No newline at end of file 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..635132e2 --- /dev/null +++ b/packages/standard_schema/pubspec.yaml @@ -0,0 +1,15 @@ +name: standard_schema +description: Standard Schema contracts for Dart validators and converters +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 + +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 70218fc1..196796c0 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: @@ -58,6 +59,7 @@ melos: - ack_generator - ack_firebase_ai - ack_json_schema_builder + - standard_schema - ack_example - ack_annotations