From dc153444757373c188fdfd7a22c6122dcaff28c5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 18:18:46 -0400 Subject: [PATCH 1/9] feat(ack): add Ack.lazy for recursive schemas Introduces LazySchema with a memoized builder for schema graphs that reference themselves (e.g. tree-shaped objects). Discriminated unions reject lazy branches with a lazy-specific error since the discriminator property cannot be statically analyzed through a deferred reference. JSON Schema export raises a clear UnsupportedError until $ref/$defs emission lands. --- packages/ack/lib/src/ack.dart | 24 +++- .../ack_schema_model_builder.dart | 4 + .../schemas/discriminated_object_schema.dart | 8 ++ packages/ack/lib/src/schemas/lazy_schema.dart | 111 ++++++++++++++++++ packages/ack/lib/src/schemas/schema.dart | 1 + packages/ack/lib/src/schemas/schema_type.dart | 1 + .../discriminated_object_schema_test.dart | 58 +++++++++ .../ack/test/schemas/lazy_schema_test.dart | 101 ++++++++++++++++ 8 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 packages/ack/lib/src/schemas/lazy_schema.dart create mode 100644 packages/ack/test/schemas/lazy_schema_test.dart diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 561983bd..463ab6b6 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -67,10 +67,9 @@ final class Ack { /// (e.g. adding constraints, copying with new flags). Prefer [enumCodec] /// when downstream code expects every value-shape to be a `CodecSchema`. static CodecSchema enumCodec(List values) => - enumValues(values).codec( - decode: (value) => value, - encode: (value) => value, - ); + enumValues( + values, + ).codec(decode: (value) => value, encode: (value) => value); /// Creates a string schema that only accepts one of the given [values]. static StringSchema enumString(List values) => @@ -86,6 +85,23 @@ final class Ack { /// to accept `null`. static AnySchema any() => const AnySchema(); + /// Creates a schema reference that is resolved lazily on first use. + /// + /// The [builder] is called once and memoized. Two `Ack.lazy` instances are + /// equal only when their `builder` closure is the same reference -- pulling + /// the closure into a `final` variable lets two calls share equality. + /// + /// `toJsonSchema()` and `toSchemaModel()` currently throw for schema graphs + /// containing `Ack.lazy`; recursive `$defs`/`$ref` export support is tracked + /// separately. `Ack.lazy` also cannot be used directly as a discriminated + /// union branch. + static LazySchema lazy< + Boundary extends Object, + Runtime extends Object + >(String name, AckSchema Function() builder) { + return LazySchema(name, builder); + } + /// Creates a schema for a specific Dart instance type [T], with [T] as /// both boundary and runtime type. static InstanceSchema instance() => InstanceSchema(); 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 727882e7..59c37e3e 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 @@ -73,6 +73,10 @@ AckSchemaModel _build(AckSchema schema) { AnySchema() => _any(schema), InstanceSchema() => _instance(schema), DiscriminatedObjectSchema() => _discriminated(schema), + LazySchema() => throw UnsupportedError( + 'JSON Schema export of recursive schemas (Ack.lazy) is not supported. ' + 'Use a non-recursive schema, or wait for \$ref/\$defs export support.', + ), _ => throw UnsupportedError( 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', ), diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index f23d9f77..05ac0bcc 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -41,6 +41,14 @@ final class DiscriminatedObjectSchema for (final entry in schemas.entries) { final label = entry.key; final base = unwrapDiscriminatedBranchSchema(entry.value); + if (base is LazySchema) { + throw ArgumentError.value( + entry.value, + 'schemas["$label"]', + 'Discriminated branches cannot be Ack.lazy(...) - recursive ' + 'discriminator property references cannot be analyzed.', + ); + } if (base is! ObjectSchema) { throw ArgumentError.value( entry.value, diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart new file mode 100644 index 00000000..0e3ad399 --- /dev/null +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -0,0 +1,111 @@ +part of 'schema.dart'; + +/// Defers resolving another schema until parse, validation, or encode time. +/// +/// This enables recursive schema graphs where a child schema needs to refer +/// back to an outer schema that is assigned after construction. +@immutable +final class LazySchema + extends AckSchema + with FluentSchema> { + LazySchema( + this.name, + this._builder, { + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, + }); + + /// Human-readable name for this deferred schema reference. + final String name; + + final AckSchema Function() _builder; + + late final AckSchema _target = _builder(); + + @override + SchemaType get schemaType => SchemaType.lazy; + + @override + @protected + SchemaResult parseWithContext(Object? value, SchemaContext context) { + final nullResult = handleNullInput(value, context); + if (nullResult != null) return nullResult; + + final result = _target.parseWithContext(value, context); + if (result.isFail) return SchemaResult.fail(result.getError()); + + final runtime = result.getOrNull(); + if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); + } + + @override + @protected + SchemaResult validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) { + final nullResult = handleNullInput(value, context); + if (nullResult != null) return nullResult; + + final result = _target.validateRuntimeWithContext(value, context); + if (result.isFail) return SchemaResult.fail(result.getError()); + + final runtime = result.getOrNull(); + if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); + } + + @override + @protected + SchemaResult encodeWithContext( + Runtime value, + SchemaContext context, + ) { + final validated = validateRuntimeWithContext(value, context); + if (validated.isFail) return SchemaResult.fail(validated.getError()); + + final runtime = validated.getOrNull(); + if (runtime == null) return SchemaResult.ok(null); + return _target.encodeWithContext(runtime, context); + } + + @override + LazySchema copyWith({ + bool? isNullable, + bool? isOptional, + String? description, + List>? constraints, + List>? refinements, + }) { + return LazySchema( + name, + _builder, + isNullable: isNullable ?? this.isNullable, + isOptional: isOptional ?? this.isOptional, + description: description ?? this.description, + constraints: constraints ?? this.constraints, + refinements: refinements ?? this.refinements, + ); + } + + @override + Map toMap() => {...super.toMap(), 'name': name}; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! LazySchema) return false; + return baseFieldsEqual(other) && + name == other.name && + identical(_builder, other._builder); + } + + @override + int get hashCode { + return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder)); + } +} diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 2efee0ed..313c4f83 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -21,6 +21,7 @@ part 'discriminated_object_schema.dart'; part 'enum_schema.dart'; part 'fluent_schema.dart'; part 'instance_schema.dart'; +part 'lazy_schema.dart'; part 'list_schema.dart'; part 'num_schema.dart'; part 'object_schema.dart'; diff --git a/packages/ack/lib/src/schemas/schema_type.dart b/packages/ack/lib/src/schemas/schema_type.dart index 9a6c7f88..5199cd15 100644 --- a/packages/ack/lib/src/schemas/schema_type.dart +++ b/packages/ack/lib/src/schemas/schema_type.dart @@ -16,6 +16,7 @@ enum SchemaType { any('any'), anyOf('anyOf'), enum_('enum'), + lazy('lazy'), discriminated('discriminated'); const SchemaType(this.typeName); diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index 410cfb68..67f9c626 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -150,6 +150,64 @@ void main() { ); }); + test('rejects lazy branches', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + expect( + () => Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'category': Ack.lazy( + 'Category', + () => categorySchema, + ), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Discriminated branches cannot be Ack.lazy(...)'), + ), + ), + ); + }); + + test('rejects wrapped lazy branches with the lazy-specific error', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + expect( + () => Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'category': Ack.lazy( + 'Category', + () => categorySchema, + ).withDefault(const {'name': 'root', 'children': []}), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Discriminated branches cannot be Ack.lazy(...)'), + ), + ), + ); + }); + test('defensively copies schemas', () { final schemas = {'cat': catSchema}; final schema = Ack.discriminated>( diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart new file mode 100644 index 00000000..ea414545 --- /dev/null +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -0,0 +1,101 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + test('parses and encodes a recursive object graph', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + final json = { + 'name': 'root', + 'children': [ + { + 'name': 'first', + 'children': [ + {'name': 'leaf', 'children': []}, + ], + }, + ], + }; + + final parsed = categorySchema.parse(json); + expect(parsed, equals(json)); + + final encoded = categorySchema.encode(parsed); + expect(encoded, equals(json)); + expect(categorySchema.encode(categorySchema.parse(json)), equals(json)); + }); + + test('throws a clear error when exporting recursive schemas', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + const message = + 'JSON Schema export of recursive schemas (Ack.lazy) is not supported'; + + expect( + categorySchema.toSchemaModel, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(message), + ), + ), + ); + expect( + categorySchema.toJsonSchema, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(message), + ), + ), + ); + }); + + test('memoizes the builder result', () { + var calls = 0; + late final ObjectSchema categorySchema; + final lazy = Ack.lazy('Category', () { + calls++; + return categorySchema; + }); + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list(lazy), + }); + + final json = { + 'name': 'root', + 'children': [ + {'name': 'leaf', 'children': []}, + ], + }; + + final parsed = categorySchema.parse(json); + expect(parsed, equals(json)); + expect(categorySchema.encode(parsed), equals(json)); + expect(calls, 1); + }); + + test('uses closure identity for equality', () { + final target = Ack.object({'name': Ack.string()}); + final first = Ack.lazy('Category', () => target); + final second = Ack.lazy('Category', () => target); + + expect(first, equals(first)); + expect(first, isNot(equals(second))); + }); +} From 6e27084e065a77c9dc9db0e6177784df2483918f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 18:38:45 -0400 Subject: [PATCH 2/9] fix(ack): address PR #110 review and union-owned encode regression Copilot review feedback on PR #110: - LazySchema.encodeWithContext no longer re-validates the entire subtree before delegating to the target; it applies own constraints/refinements and lets the target's own encode handle target-side validation. Avoids O(N^2) work on deeply nested recursive graphs and prevents duplicate refinement side effects. - Reworded the JSON Schema export UnsupportedError and the discriminated branch ArgumentError so they describe "schemas containing Ack.lazy" / "deferred reference" instead of implying recursion specifically. Union-owned discriminator (caught review-time, ships with PR #110): - effectiveDiscriminatedObjectBranch now attaches withDefault to the synthesized literal so encode emits the discriminator even when the branch's runtime encoder omits it (the behavior flutter_codec needs for keyCodec, widgetCodec, constraintsCodec, ...). - ack_schema_model_builder._discriminated wraps each exported branch with _withRequiredDiscriminator so the JSON Schema marks the discriminator as required and strips the synthetic encode-time default from the output. Tests: - Regression test locking in single-pass lazy refinement counts. - Codec-backed branch whose encoder omits the discriminator. - Runtime omits the discriminator. - JSON Schema export keeps discriminator required and default-free. --- .../ack_schema_model_builder.dart | 50 +++++++++++++++++-- .../schemas/discriminated_object_schema.dart | 4 +- packages/ack/lib/src/schemas/lazy_schema.dart | 9 ++-- .../src/utils/discriminated_branch_utils.dart | 8 ++- .../discriminated_object_schema_test.dart | 43 ++++++++++++++++ .../ack/test/schemas/lazy_schema_test.dart | 45 ++++++++++++++++- 6 files changed, 146 insertions(+), 13 deletions(-) diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 59c37e3e..48818c43 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 @@ -74,8 +74,8 @@ AckSchemaModel _build(AckSchema schema) { InstanceSchema() => _instance(schema), DiscriminatedObjectSchema() => _discriminated(schema), LazySchema() => throw UnsupportedError( - 'JSON Schema export of recursive schemas (Ack.lazy) is not supported. ' - 'Use a non-recursive schema, or wait for \$ref/\$defs export support.', + 'JSON Schema export of schemas containing Ack.lazy is not supported yet. ' + 'Inline the deferred schema, or wait for \$ref/\$defs export support.', ), _ => throw UnsupportedError( 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', @@ -230,7 +230,9 @@ AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { 'Discriminated branch "${entry.key}" must export as an object schema model.', ); } - branches.add(converted); + branches.add( + _withRequiredDiscriminator(converted, schema.discriminatorKey), + ); } return AckAnyOfSchemaModel( @@ -243,6 +245,48 @@ AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { ); } +/// Strips the synthetic [DefaultSchema] default from the discriminator +/// property in an exported branch model and marks the property as required. +/// +/// `effectiveDiscriminatedObjectBranch` attaches `.withDefault(...)` to the +/// synthesized literal so encode can inject the discriminator when the branch +/// runtime omits it; that default is an encode-time convenience, not part of +/// the published JSON Schema. +AckObjectSchemaModel _withRequiredDiscriminator( + AckObjectSchemaModel model, + String discriminatorKey, +) { + final properties = model.properties; + if (properties == null || !properties.containsKey(discriminatorKey)) { + return model; + } + + final discriminator = properties[discriminatorKey]!; + final normalizedProperties = discriminator.defaultValue == null + ? properties + : {...properties, discriminatorKey: discriminator.withDefaultValue(null)}; + final required = model.required ?? const []; + final alreadyRequired = required.contains(discriminatorKey); + if (alreadyRequired && identical(normalizedProperties, properties)) { + return model; + } + + return AckObjectSchemaModel( + properties: normalizedProperties, + required: alreadyRequired ? required : [discriminatorKey, ...required], + propertyOrdering: model.propertyOrdering, + minProperties: model.minProperties, + maxProperties: model.maxProperties, + additionalProperties: model.additionalProperties, + title: model.title, + description: model.description, + nullable: model.nullable, + defaultValue: model.defaultValue, + warnings: model.warnings, + extensions: model.extensions, + ); +} + AckSchemaModel _applyConstraints(AckSchemaModel model, AckSchema schema) { var next = model; for (final constraint in schema.constraints) { diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 05ac0bcc..b3387675 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -45,8 +45,8 @@ final class DiscriminatedObjectSchema throw ArgumentError.value( entry.value, 'schemas["$label"]', - 'Discriminated branches cannot be Ack.lazy(...) - recursive ' - 'discriminator property references cannot be analyzed.', + 'Discriminated branches cannot be Ack.lazy(...) - the discriminator ' + 'property cannot be analyzed through a deferred reference.', ); } if (base is! ObjectSchema) { diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 0e3ad399..c4962ad8 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -65,12 +65,9 @@ final class LazySchema Runtime value, SchemaContext context, ) { - final validated = validateRuntimeWithContext(value, context); - if (validated.isFail) return SchemaResult.fail(validated.getError()); - - final runtime = validated.getOrNull(); - if (runtime == null) return SchemaResult.ok(null); - return _target.encodeWithContext(runtime, context); + final ownChecked = applyConstraintsAndRefinements(value, context); + if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError()); + return _target.encodeWithContext(ownChecked.getOrThrow()!, context); } @override diff --git a/packages/ack/lib/src/utils/discriminated_branch_utils.dart b/packages/ack/lib/src/utils/discriminated_branch_utils.dart index 157ee7fe..7ebc1638 100644 --- a/packages/ack/lib/src/utils/discriminated_branch_utils.dart +++ b/packages/ack/lib/src/utils/discriminated_branch_utils.dart @@ -70,8 +70,14 @@ ObjectSchema effectiveDiscriminatedObjectBranch({ ); } + final discriminatorSchema = existingDiscriminator == null + ? _discriminatorLiteralSchema( + discriminatorValue, + ).withDefault(discriminatorValue) + : _discriminatorLiteralSchema(discriminatorValue); + final properties = { - discriminatorKey: _discriminatorLiteralSchema(discriminatorValue), + discriminatorKey: discriminatorSchema, for (final entry in objectSchema.properties.entries) if (entry.key != discriminatorKey) entry.key: entry.value, }; diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index 67f9c626..51b3632c 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -94,6 +94,49 @@ void main() { expect(result.getOrThrow(), {'type': 'dog', 'bark': false}); }); + test('encodes a branch whose runtime omits the discriminator', () { + final result = unionOwnedSchema.safeEncode({'bark': false}); + + expect(result.isOk, isTrue); + expect(result.getOrThrow(), {'type': 'dog', 'bark': false}); + }); + + test('encodes a codec-backed branch whose encoder omits the ' + 'discriminator', () { + final codecUnion = Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.codec( + input: Ack.object({'meow': Ack.boolean()}), + decode: (map) => map, + encode: (value) => + {'meow': value['meow']}, // intentionally omits 'type' + ), + }, + ); + + final result = codecUnion.safeEncode({'meow': true}); + + expect(result.isOk, isTrue); + expect(result.getOrThrow(), {'type': 'cat', 'meow': true}); + }); + + test('JSON Schema marks the synthesized discriminator required and ' + 'strips the encode-time default', () { + final jsonSchema = unionOwnedSchema.toJsonSchema(); + final branches = (jsonSchema['anyOf'] as List).cast(); + + for (final branch in branches) { + final required = (branch['required'] as List).cast(); + expect(required, contains('type')); + + final properties = (branch['properties'] as Map) + .cast(); + final typeProp = (properties['type'] as Map).cast(); + expect(typeProp.containsKey('default'), isFalse); + } + }); + test('parse against the wrong branch fails on the literal', () { final result = unionOwnedSchema.safeParse({ 'type': 'cat', diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index ea414545..8a0e34c8 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -41,7 +41,7 @@ void main() { }); const message = - 'JSON Schema export of recursive schemas (Ack.lazy) is not supported'; + 'JSON Schema export of schemas containing Ack.lazy is not supported'; expect( categorySchema.toSchemaModel, @@ -98,4 +98,47 @@ void main() { expect(first, equals(first)); expect(first, isNot(equals(second))); }); + + test('encode runs lazy refinement once per nested node (no double ' + 'validation)', () { + var calls = 0; + late final ObjectSchema categorySchema; + final lazy = Ack.lazy('Category', () => categorySchema) + .refine((value) { + calls++; + return true; + }); + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list(lazy), + }); + + final json = { + 'name': 'root', + 'children': [ + { + 'name': 'a', + 'children': [ + { + 'name': 'b', + 'children': [ + {'name': 'c', 'children': []}, + ], + }, + ], + }, + ], + }; + + final parsed = categorySchema.parse(json); + calls = 0; + final encoded = categorySchema.encode(parsed); + expect(encoded, equals(json)); + + // Parent ObjectSchema/ListSchema validate-then-encode passes already drive + // a fixed number of refinement runs per lazy edge. The double-validation + // bug added an extra recursive validate inside LazySchema.encodeWithContext + // on top of that, inflating this count. Locks in the fixed call profile. + expect(calls, 15); + }); } From 9f3c0a3214e25f2bf15094556e11728657c267e0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 18:45:13 -0400 Subject: [PATCH 3/9] style(ack): dart format discriminated_object_schema_test --- .../ack/test/schemas/discriminated_object_schema_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index 51b3632c..eac60c57 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -109,8 +109,9 @@ void main() { 'cat': Ack.codec( input: Ack.object({'meow': Ack.boolean()}), decode: (map) => map, - encode: (value) => - {'meow': value['meow']}, // intentionally omits 'type' + encode: (value) => { + 'meow': value['meow'], + }, // intentionally omits 'type' ), }, ); From 5b7c0a903e09ac63872d74b39bba3c7f7503443e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 19:50:17 -0400 Subject: [PATCH 4/9] feat(ack): export Ack.lazy via $defs/$ref in JSON Schema --- packages/ack/lib/src/ack.dart | 7 +- .../src/schema_model/ack_schema_model.dart | 46 +- .../ack_schema_model_builder.dart | 467 ++++++++++-------- packages/ack/lib/src/schemas/lazy_schema.dart | 9 + .../ack/test/schemas/lazy_schema_test.dart | 121 ++++- 5 files changed, 426 insertions(+), 224 deletions(-) diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 463ab6b6..6a409e44 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -91,10 +91,9 @@ final class Ack { /// equal only when their `builder` closure is the same reference -- pulling /// the closure into a `final` variable lets two calls share equality. /// - /// `toJsonSchema()` and `toSchemaModel()` currently throw for schema graphs - /// containing `Ack.lazy`; recursive `$defs`/`$ref` export support is tracked - /// separately. `Ack.lazy` also cannot be used directly as a discriminated - /// union branch. + /// `toJsonSchema()` and `toSchemaModel()` export lazy references through + /// recursive `$defs`/`$ref` JSON Schema definitions. `Ack.lazy` cannot be + /// used directly as a discriminated union branch. static LazySchema lazy< Boundary extends Object, Runtime extends Object diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 6aecfccd..3fb2efe1 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -83,7 +83,13 @@ final class _AckSchemaModelCommon { /// Returns the non-hoistable portion (extensions plus type-specific /// keywords flow through here) to embed inside the inner branch. - Map toEmbeddedJson() => {...extensions}; + Map toEmbeddedJson() { + final embedded = {...extensions}; + embedded.remove(r'$defs'); + return embedded; + } + + Object? get defs => extensions[r'$defs']; } @immutable @@ -239,6 +245,10 @@ sealed class AckSchemaModel { keywords, commonHandled, ), + AckRefSchemaModel schema => schema._withJsonSchemaKeywords( + keywords, + commonHandled, + ), }; } @@ -253,6 +263,7 @@ sealed class AckSchemaModel { // branch so consumers see them next to the `type` they constrain. return { ..._common.toHoistedJson(), + if (_common.defs != null) r'$defs': _common.defs, 'anyOf': [ {...typeJson, ..._common.toEmbeddedJson()}, _nullSchemaJson, @@ -272,6 +283,7 @@ sealed class AckSchemaModel { // composed union. return { ..._common.toHoistedJson(), + if (_common.defs != null) r'$defs': _common.defs, 'anyOf': [ {..._common.toEmbeddedJson(), keyword: branches}, _nullSchemaJson, @@ -316,6 +328,38 @@ sealed class AckSchemaModel { } } +final class AckRefSchemaModel extends AckSchemaModel { + const AckRefSchemaModel({ + required this.refName, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }); + + AckRefSchemaModel._(_AckSchemaModelCommon common, {required this.refName}) + : super._(common); + + final String refName; + + @override + Map toJsonSchema() => + finishTypeJson({r'$ref': '#/\$defs/$refName'}); + + @override + AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckRefSchemaModel._(common, refName: refName); + + AckSchemaModel _withJsonSchemaKeywords( + Map keywords, + Set commonHandled, + ) { + return _withUnhandledKeywords(keywords, commonHandled); + } +} + final class AckStringSchemaModel extends AckSchemaModel { const AckStringSchemaModel({ this.format, 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 48818c43..2fe9e38a 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 @@ -13,236 +13,295 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _build(this); + AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); } -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 layered = base - .withDescription(schema.description ?? base.description) - .withNullable(schema.isNullable || base.nullable) - .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 - ? layered - : _applyConstraints(layered, schema); - - if (schema is DefaultSchema) { - final exportDefault = _defaultExportValueOrNull(schema); - if (exportDefault != null) { - wrapped = wrapped.withDefaultValue(exportDefault); - } else { - wrapped = wrapped.withWarnings([ - ...wrapped.warnings, - AckSchemaModelWarning( - code: 'default_not_export_safe', - message: - 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', - ), - ]); +final class _SchemaModelBuilder { + final _defs = {}; + final _targets = {}; + + AckSchemaModel build(AckSchema schema) { + final root = _build(schema); + if (_defs.isEmpty) return root; + + final defs = { + for (final entry in _defs.entries) + if (entry.value case final model?) entry.key: model.toJsonSchema(), + }; + return root.withExtensions({...root.extensions, r'$defs': defs}); + } + + 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 layered = base + .withDescription(schema.description ?? base.description) + .withNullable(schema.isNullable || base.nullable) + .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 + ? layered + : _applyConstraints(layered, schema); + + if (schema is DefaultSchema) { + final exportDefault = _defaultExportValueOrNull(schema); + if (exportDefault != null) { + wrapped = wrapped.withDefaultValue(exportDefault); + } else { + wrapped = wrapped.withWarnings([ + ...wrapped.warnings, + AckSchemaModelWarning( + code: 'default_not_export_safe', + message: + 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', + ), + ]); + } } + + return wrapped; } - return wrapped; + final model = switch (schema) { + StringSchema() => _string(schema), + IntegerSchema() => _integer(schema), + DoubleSchema() => _number( + description: schema.description, + nullable: schema.isNullable, + ), + NumberSchema() => _number( + description: schema.description, + nullable: schema.isNullable, + ), + BooleanSchema() => _boolean(schema), + EnumSchema() => _enum(schema), + ListSchema() => _array(schema), + ObjectSchema() => _object(schema), + AnyOfSchema() => _anyOf(schema), + AnySchema() => _any(schema), + InstanceSchema() => _instance(schema), + DiscriminatedObjectSchema() => _discriminated(schema), + LazySchema() => _lazy(schema), + _ => throw UnsupportedError( + 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', + ), + }; + + return schema is LazySchema ? model : _applyConstraints(model, schema); } - final model = switch (schema) { - StringSchema() => _string(schema), - IntegerSchema() => _integer(schema), - DoubleSchema() => _number( + AckSchemaModel _string(StringSchema schema) { + return AckStringSchemaModel( description: schema.description, nullable: schema.isNullable, - ), - NumberSchema() => _number( + ); + } + + AckSchemaModel _integer(IntegerSchema schema) { + return AckIntegerSchemaModel( description: schema.description, nullable: schema.isNullable, - ), - BooleanSchema() => _boolean(schema), - EnumSchema() => _enum(schema), - ListSchema() => _array(schema), - ObjectSchema() => _object(schema), - AnyOfSchema() => _anyOf(schema), - AnySchema() => _any(schema), - InstanceSchema() => _instance(schema), - DiscriminatedObjectSchema() => _discriminated(schema), - LazySchema() => throw UnsupportedError( - 'JSON Schema export of schemas containing Ack.lazy is not supported yet. ' - 'Inline the deferred schema, or wait for \$ref/\$defs export support.', - ), - _ => throw UnsupportedError( - 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', - ), - }; + ); + } - return _applyConstraints(model, schema); -} + AckSchemaModel _number({String? description, required bool nullable}) { + return AckNumberSchemaModel(description: description, nullable: nullable); + } -AckSchemaModel _string(StringSchema schema) { - return AckStringSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _boolean(BooleanSchema schema) { + return AckBooleanSchemaModel( + description: schema.description, + nullable: schema.isNullable, + ); + } -AckSchemaModel _integer(IntegerSchema schema) { - return AckIntegerSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _enum(EnumSchema schema) { + return AckStringSchemaModel( + description: schema.description, + enumValues: [for (final value in schema.values) value.name], + nullable: schema.isNullable, + ); + } -AckSchemaModel _number({String? description, required bool nullable}) { - return AckNumberSchemaModel(description: description, nullable: nullable); -} + AckSchemaModel _array(ListSchema schema) { + return AckArraySchemaModel( + description: schema.description, + nullable: schema.isNullable, + items: _build(schema.itemSchema), + ); + } -AckSchemaModel _boolean(BooleanSchema schema) { - return AckBooleanSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _object(ObjectSchema schema) { + final properties = {}; + final required = []; + final ordering = []; -AckSchemaModel _enum(EnumSchema schema) { - return AckStringSchemaModel( - description: schema.description, - enumValues: [for (final value in schema.values) value.name], - nullable: schema.isNullable, - ); -} + for (final entry in schema.properties.entries) { + ordering.add(entry.key); + properties[entry.key] = wrapPropertyConversion( + entry.key, + () => _build(entry.value), + ); + if (_isRequiredObjectProperty(entry.value)) { + required.add(entry.key); + } + } -AckSchemaModel _array(ListSchema schema) { - return AckArraySchemaModel( - description: schema.description, - nullable: schema.isNullable, - items: _build(schema.itemSchema), - ); -} + return AckObjectSchemaModel( + description: schema.description, + nullable: schema.isNullable, + properties: properties.isEmpty ? null : properties, + required: required.isEmpty ? null : required, + propertyOrdering: ordering.isEmpty ? null : ordering, + additionalProperties: schema.additionalProperties + ? const AckAdditionalPropertiesAllowed() + : const AckAdditionalPropertiesDisallowed(), + ); + } -AckSchemaModel _object(ObjectSchema schema) { - final properties = {}; - final required = []; - final ordering = []; + AckSchemaModel _anyOf(AnyOfSchema schema) { + return AckAnyOfSchemaModel( + schemas: schema.schemas.map(_build).toList(growable: false), + nullable: schema.isNullable, + description: schema.description, + ); + } - for (final entry in schema.properties.entries) { - ordering.add(entry.key); - properties[entry.key] = wrapPropertyConversion( - entry.key, - () => _build(entry.value), + AckSchemaModel _instance(InstanceSchema schema) { + // InstanceSchema accepts arbitrary Dart instances of a runtime type with no + // direct JSON representation. Adapters that flow through a codec see the + // boundary schema instead; this is the fallback for a bare instance. + return AckAnyOfSchemaModel( + schemas: [ + AckStringSchemaModel(description: schema.description), + AckNumberSchemaModel(description: schema.description), + AckIntegerSchemaModel(description: schema.description), + AckBooleanSchemaModel(description: schema.description), + AckObjectSchemaModel(description: schema.description), + AckArraySchemaModel(description: schema.description), + ], + nullable: schema.isNullable, + description: schema.description, + warnings: const [ + AckSchemaModelWarning( + code: 'ack_instance_json_boundary', + message: + 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', + ), + ], ); - if (_isRequiredObjectProperty(entry.value)) { - required.add(entry.key); - } } - return AckObjectSchemaModel( - description: schema.description, - nullable: schema.isNullable, - properties: properties.isEmpty ? null : properties, - required: required.isEmpty ? null : required, - propertyOrdering: ordering.isEmpty ? null : ordering, - additionalProperties: schema.additionalProperties - ? const AckAdditionalPropertiesAllowed() - : const AckAdditionalPropertiesDisallowed(), - ); -} + AckSchemaModel _any(AnySchema schema) { + final description = schema.description; + final primitiveBranches = [ + AckStringSchemaModel(description: description), + AckNumberSchemaModel(description: description), + AckIntegerSchemaModel(description: description), + AckBooleanSchemaModel(description: description), + AckObjectSchemaModel(description: description), + AckArraySchemaModel(description: description), + ]; + + return AckAnyOfSchemaModel( + schemas: primitiveBranches, + nullable: schema.isNullable, + description: description, + warnings: const [ + AckSchemaModelWarning( + code: 'ack_any_json_boundary', + message: + 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', + ), + ], + ); + } -AckSchemaModel _anyOf(AnyOfSchema schema) { - return AckAnyOfSchemaModel( - schemas: schema.schemas.map(_build).toList(growable: false), - nullable: schema.isNullable, - description: schema.description, - ); -} + AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { + if (schema.schemas.isEmpty) { + return AckObjectSchemaModel( + properties: const {}, + required: const [], + nullable: schema.isNullable, + description: schema.description, + ); + } -AckSchemaModel _instance(InstanceSchema schema) { - // InstanceSchema accepts arbitrary Dart instances of a runtime type with no - // direct JSON representation. Adapters that flow through a codec see the - // boundary schema instead; this is the fallback for a bare instance. - return AckAnyOfSchemaModel( - schemas: [ - AckStringSchemaModel(description: schema.description), - AckNumberSchemaModel(description: schema.description), - AckIntegerSchemaModel(description: schema.description), - AckBooleanSchemaModel(description: schema.description), - AckObjectSchemaModel(description: schema.description), - AckArraySchemaModel(description: schema.description), - ], - nullable: schema.isNullable, - description: schema.description, - warnings: const [ - AckSchemaModelWarning( - code: 'ack_instance_json_boundary', - message: - 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', - ), - ], - ); -} + final branches = []; + for (final entry in schema.schemas.entries) { + final converted = _build(schema.effectiveBranch(entry.key)); + if (converted is! AckObjectSchemaModel) { + throw ArgumentError( + 'Discriminated branch "${entry.key}" must export as an object schema model.', + ); + } + branches.add( + _withRequiredDiscriminator(converted, schema.discriminatorKey), + ); + } -AckSchemaModel _any(AnySchema schema) { - final description = schema.description; - final primitiveBranches = [ - AckStringSchemaModel(description: description), - AckNumberSchemaModel(description: description), - AckIntegerSchemaModel(description: description), - AckBooleanSchemaModel(description: description), - AckObjectSchemaModel(description: description), - AckArraySchemaModel(description: description), - ]; - - return AckAnyOfSchemaModel( - schemas: primitiveBranches, - nullable: schema.isNullable, - description: description, - warnings: const [ - AckSchemaModelWarning( - code: 'ack_any_json_boundary', - message: - 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', + return AckAnyOfSchemaModel( + schemas: branches, + discriminator: AckSchemaDiscriminatorModel( + propertyName: schema.discriminatorKey, ), - ], - ); -} - -AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { - if (schema.schemas.isEmpty) { - return AckObjectSchemaModel( - properties: const {}, - required: const [], - nullable: schema.isNullable, description: schema.description, + nullable: schema.isNullable, ); } - final branches = []; - for (final entry in schema.schemas.entries) { - final converted = _build(schema.effectiveBranch(entry.key)); - if (converted is! AckObjectSchemaModel) { - throw ArgumentError( - 'Discriminated branch "${entry.key}" must export as an object schema model.', - ); + AckSchemaModel _lazy(LazySchema schema) { + final name = schema.name; + final target = schema.target; + final priorTarget = _targets[name]; + if (priorTarget != null) { + if (!identical(priorTarget, target)) { + throw ArgumentError( + 'Two Ack.lazy entries share name "$name" but resolve to different ' + 'schemas. Use unique names per recursive target.', + ); + } + return _lazyRef(schema); } - branches.add( - _withRequiredDiscriminator(converted, schema.discriminatorKey), - ); + + _targets[name] = target; + _defs[name] = null; + _defs[name] = _build(target); + return _lazyRef(schema); } - return AckAnyOfSchemaModel( - schemas: branches, - discriminator: AckSchemaDiscriminatorModel( - propertyName: schema.discriminatorKey, - ), - description: schema.description, - nullable: schema.isNullable, - ); + AckSchemaModel _lazyRef(LazySchema schema) { + var model = AckRefSchemaModel( + refName: schema.name, + description: schema.description, + nullable: schema.isNullable, + ); + final constraintCount = schema.runtimeConstraintCount; + final refinementCount = schema.runtimeRefinementCount; + if (constraintCount == 0 && refinementCount == 0) { + return model; + } + + return model.withWarnings([ + ...model.warnings, + AckSchemaModelWarning( + code: 'lazy_runtime_checks_not_export_safe', + message: + 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', + context: { + 'constraintCount': constraintCount, + 'refinementCount': refinementCount, + }, + ), + ]); + } } /// Strips the synthetic [DefaultSchema] default from the discriminator @@ -287,7 +346,10 @@ AckObjectSchemaModel _withRequiredDiscriminator( ); } -AckSchemaModel _applyConstraints(AckSchemaModel model, AckSchema schema) { +AckSchemaModel _applyConstraints( + AckSchemaModel model, + AckSchema schema, +) { var next = model; for (final constraint in schema.constraints) { if (constraint is DateTimeConstraint) { @@ -296,8 +358,7 @@ AckSchemaModel _applyConstraints(AckSchemaModel model, AckSchema schema) { } if (constraint is JsonSchemaSpec) { - final spec = constraint as JsonSchemaSpec; - next = next.withJsonSchemaKeywords(spec.toJsonSchema()); + next = next.withJsonSchemaKeywords(constraint.toJsonSchema()); } } @@ -328,7 +389,7 @@ AckSchemaModel _applyDateTimeConstraint( /// 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) { +Object? _defaultExportValueOrNull(DefaultSchema schema) { final resolved = schema.resolveDefaultWithContext( _defaultExportContext(schema), ); @@ -343,7 +404,7 @@ Object? _defaultExportValueOrNull(DefaultSchema schema) { return _jsonRoundTripOrNull(encoded.getOrNull()); } -bool _isRequiredObjectProperty(AckSchema schema) { +bool _isRequiredObjectProperty(AckSchema schema) { if (schema.isOptional) return false; if (schema is DefaultSchema && schema.resolveDefaultWithContext(_defaultExportContext(schema)).isOk) { @@ -357,10 +418,10 @@ bool _isRequiredObjectProperty(AckSchema schema) { /// [DefaultSchema.resolveDefaultWithContext]. Errors produced through this /// context are never surfaced — both callers consume only `.isOk` / /// `.getOrNull()` — so the rooted error path is intentional. -SchemaContext _defaultExportContext(DefaultSchema schema) { +SchemaContext _defaultExportContext(DefaultSchema schema) { return SchemaContext( name: schema.schemaTypeName, - schema: schema, + schema: schema as AnyAckSchema, value: null, ); } diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index c4962ad8..ff935d4c 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -25,6 +25,15 @@ final class LazySchema late final AckSchema _target = _builder(); + @internal + AckSchema get target => _target; + + @internal + int get runtimeConstraintCount => _constraints.length; + + @internal + int get runtimeRefinementCount => _refinements.length; + @override SchemaType get schemaType => SchemaType.lazy; diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 8a0e34c8..6dacfc40 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -31,7 +31,7 @@ void main() { expect(categorySchema.encode(categorySchema.parse(json)), equals(json)); }); - test('throws a clear error when exporting recursive schemas', () { + test('exports recursive object graph via defs and refs', () { late final ObjectSchema categorySchema; categorySchema = Ack.object({ 'name': Ack.string(), @@ -40,31 +40,120 @@ void main() { ), }); - const message = - 'JSON Schema export of schemas containing Ack.lazy is not supported'; + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final children = properties['children']! as Map; + final defs = jsonSchema[r'$defs']! as Map; + final categoryDef = defs['Category']! as Map; + final categoryProperties = categoryDef['properties']! as Map; + final nestedChildren = categoryProperties['children']! as Map; + + expect(children['items'], {r'$ref': r'#/$defs/Category'}); + expect(nestedChildren['items'], {r'$ref': r'#/$defs/Category'}); + expect(defs.keys, ['Category']); + }); + + test('deduplicates lazies with the same name and same target', () { + final target = Ack.object({'name': Ack.string()}); + final first = Ack.lazy('Category', () => target); + final second = Ack.lazy('Category', () => target); + final schema = Ack.object({'first': first, 'second': second}); + + final jsonSchema = schema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final defs = jsonSchema[r'$defs']! as Map; + + expect(defs.keys, ['Category']); + expect(properties['first'], {r'$ref': r'#/$defs/Category'}); + expect(properties['second'], {r'$ref': r'#/$defs/Category'}); + }); + + test('rejects lazies with the same name and different targets', () { + final firstTarget = Ack.object({'name': Ack.string()}); + final secondTarget = Ack.object({'title': Ack.string()}); + final schema = Ack.object({ + 'first': Ack.lazy('Category', () => firstTarget), + 'second': Ack.lazy('Category', () => secondTarget), + }); expect( - categorySchema.toSchemaModel, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains(message), - ), - ), - ); - expect( - categorySchema.toJsonSchema, + schema.toJsonSchema, throwsA( - isA().having( + isA().having( (error) => error.message, 'message', - contains(message), + contains('share name "Category"'), ), ), ); }); + test('exports nullable lazy references with metadata', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'parent': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Parent category').nullable(), + }); + + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + + expect(properties['parent'], { + 'description': 'Parent category', + 'anyOf': [ + {r'$ref': r'#/$defs/Category'}, + {'type': 'null'}, + ], + }); + expect(jsonSchema[r'$defs'], isNotNull); + }); + + test('warns when lazy runtime checks cannot be exported', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy( + 'Category', + () => categorySchema, + ).refine((value) => true), + }); + + final model = categorySchema.toSchemaModel() as AckObjectSchemaModel; + final child = model.properties!['child']!; + + expect(child.toJsonSchema(), {r'$ref': r'#/$defs/Category'}); + expect(child.warnings, hasLength(1)); + expect(child.warnings.single.code, 'lazy_runtime_checks_not_export_safe'); + expect(child.warnings.single.context, { + 'constraintCount': 0, + 'refinementCount': 1, + }); + }); + + test('does not add defs to non-lazy schemas', () { + final schema = Ack.object({ + 'name': Ack.string().minLength(2), + 'children': Ack.list(Ack.string()), + }); + + expect(schema.toJsonSchema(), { + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 2}, + 'children': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': ['name', 'children'], + 'additionalProperties': false, + }); + expect(schema.toJsonSchema(), isNot(contains(r'$defs'))); + }); + test('memoizes the builder result', () { var calls = 0; late final ObjectSchema categorySchema; From 536dc90fbe6424e63bb44cb3ce17964384b09570 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 19:59:31 -0400 Subject: [PATCH 5/9] fix(ack): use Draft-7 definitions for lazy refs --- packages/ack/lib/src/ack.dart | 4 +-- .../src/schema_model/ack_schema_model.dart | 10 +++---- .../ack_schema_model_builder.dart | 2 +- .../ack/test/schemas/lazy_schema_test.dart | 30 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 6a409e44..4db4c6aa 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -92,8 +92,8 @@ final class Ack { /// the closure into a `final` variable lets two calls share equality. /// /// `toJsonSchema()` and `toSchemaModel()` export lazy references through - /// recursive `$defs`/`$ref` JSON Schema definitions. `Ack.lazy` cannot be - /// used directly as a discriminated union branch. + /// recursive `definitions`/`$ref` JSON Schema definitions. `Ack.lazy` cannot + /// be used directly as a discriminated union branch. static LazySchema lazy< Boundary extends Object, Runtime extends Object diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 3fb2efe1..990330c3 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -85,11 +85,11 @@ final class _AckSchemaModelCommon { /// keywords flow through here) to embed inside the inner branch. Map toEmbeddedJson() { final embedded = {...extensions}; - embedded.remove(r'$defs'); + embedded.remove('definitions'); return embedded; } - Object? get defs => extensions[r'$defs']; + Object? get definitions => extensions['definitions']; } @immutable @@ -263,7 +263,7 @@ sealed class AckSchemaModel { // branch so consumers see them next to the `type` they constrain. return { ..._common.toHoistedJson(), - if (_common.defs != null) r'$defs': _common.defs, + if (_common.definitions != null) 'definitions': _common.definitions, 'anyOf': [ {...typeJson, ..._common.toEmbeddedJson()}, _nullSchemaJson, @@ -283,7 +283,7 @@ sealed class AckSchemaModel { // composed union. return { ..._common.toHoistedJson(), - if (_common.defs != null) r'$defs': _common.defs, + if (_common.definitions != null) 'definitions': _common.definitions, 'anyOf': [ {..._common.toEmbeddedJson(), keyword: branches}, _nullSchemaJson, @@ -346,7 +346,7 @@ final class AckRefSchemaModel extends AckSchemaModel { @override Map toJsonSchema() => - finishTypeJson({r'$ref': '#/\$defs/$refName'}); + finishTypeJson({r'$ref': '#/definitions/$refName'}); @override AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => 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 2fe9e38a..ce3a462a 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 @@ -28,7 +28,7 @@ final class _SchemaModelBuilder { for (final entry in _defs.entries) if (entry.value case final model?) entry.key: model.toJsonSchema(), }; - return root.withExtensions({...root.extensions, r'$defs': defs}); + return root.withExtensions({...root.extensions, 'definitions': defs}); } AckSchemaModel _build(AckSchema schema) { diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 6dacfc40..0f1fd2e2 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -31,7 +31,7 @@ void main() { expect(categorySchema.encode(categorySchema.parse(json)), equals(json)); }); - test('exports recursive object graph via defs and refs', () { + test('exports recursive object graph via definitions and refs', () { late final ObjectSchema categorySchema; categorySchema = Ack.object({ 'name': Ack.string(), @@ -43,14 +43,14 @@ void main() { final jsonSchema = categorySchema.toJsonSchema(); final properties = jsonSchema['properties']! as Map; final children = properties['children']! as Map; - final defs = jsonSchema[r'$defs']! as Map; - final categoryDef = defs['Category']! as Map; + final definitions = jsonSchema['definitions']! as Map; + final categoryDef = definitions['Category']! as Map; final categoryProperties = categoryDef['properties']! as Map; final nestedChildren = categoryProperties['children']! as Map; - expect(children['items'], {r'$ref': r'#/$defs/Category'}); - expect(nestedChildren['items'], {r'$ref': r'#/$defs/Category'}); - expect(defs.keys, ['Category']); + expect(children['items'], {r'$ref': '#/definitions/Category'}); + expect(nestedChildren['items'], {r'$ref': '#/definitions/Category'}); + expect(definitions.keys, ['Category']); }); test('deduplicates lazies with the same name and same target', () { @@ -61,11 +61,11 @@ void main() { final jsonSchema = schema.toJsonSchema(); final properties = jsonSchema['properties']! as Map; - final defs = jsonSchema[r'$defs']! as Map; + final definitions = jsonSchema['definitions']! as Map; - expect(defs.keys, ['Category']); - expect(properties['first'], {r'$ref': r'#/$defs/Category'}); - expect(properties['second'], {r'$ref': r'#/$defs/Category'}); + expect(definitions.keys, ['Category']); + expect(properties['first'], {r'$ref': '#/definitions/Category'}); + expect(properties['second'], {r'$ref': '#/definitions/Category'}); }); test('rejects lazies with the same name and different targets', () { @@ -104,11 +104,11 @@ void main() { expect(properties['parent'], { 'description': 'Parent category', 'anyOf': [ - {r'$ref': r'#/$defs/Category'}, + {r'$ref': '#/definitions/Category'}, {'type': 'null'}, ], }); - expect(jsonSchema[r'$defs'], isNotNull); + expect(jsonSchema['definitions'], isNotNull); }); test('warns when lazy runtime checks cannot be exported', () { @@ -124,7 +124,7 @@ void main() { final model = categorySchema.toSchemaModel() as AckObjectSchemaModel; final child = model.properties!['child']!; - expect(child.toJsonSchema(), {r'$ref': r'#/$defs/Category'}); + expect(child.toJsonSchema(), {r'$ref': '#/definitions/Category'}); expect(child.warnings, hasLength(1)); expect(child.warnings.single.code, 'lazy_runtime_checks_not_export_safe'); expect(child.warnings.single.context, { @@ -133,7 +133,7 @@ void main() { }); }); - test('does not add defs to non-lazy schemas', () { + test('does not add definitions to non-lazy schemas', () { final schema = Ack.object({ 'name': Ack.string().minLength(2), 'children': Ack.list(Ack.string()), @@ -151,7 +151,7 @@ void main() { 'required': ['name', 'children'], 'additionalProperties': false, }); - expect(schema.toJsonSchema(), isNot(contains(r'$defs'))); + expect(schema.toJsonSchema(), isNot(contains('definitions'))); }); test('memoizes the builder result', () { From 0c253836c738caaca824f1e1d978f29d18666bb7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 20:04:58 -0400 Subject: [PATCH 6/9] fix(ack): align lazy JSON Schema refs and docs --- docs/api-reference/index.mdx | 14 ++++++++++++++ docs/core-concepts/schemas.mdx | 19 +++++++++++++++++++ docs/guides/json-schema-integration.mdx | 7 +++++++ .../src/schema_model/ack_schema_model.dart | 6 +++++- .../ack_schema_model_builder.dart | 17 ++++++++++------- .../ack/test/schemas/lazy_schema_test.dart | 14 ++++++++++++++ 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index c540b3c1..8e0bed5c 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -30,6 +30,9 @@ Entry point for creating schemas. See [Schema Types](../core-concepts/schemas.md - `Ack.anyOf(List schemas)`: Creates an `AnyOfSchema` for union types. - `Ack.any()`: Creates an `AnySchema` that accepts any non-null JSON-safe value. Chain `.nullable()` to allow `null`. +- `Ack.lazy(String name, AckSchema Function() builder)`: Creates a memoized + deferred schema reference for recursive schema graphs. JSON Schema export + renders Draft-7 `definitions` / `$ref` entries using `name`. - `Ack.discriminated(...)`: Creates a discriminated union schema. Branches may be plain `ObjectSchema` or transformed schemas whose base is an `ObjectSchema`. The union owns the discriminator: branches normally @@ -224,6 +227,17 @@ Schema for polymorphic validation based on a string discriminator property. - Generated subtype `parse()` and `safeParse()` methods validate through the union's effective branch. +### `Ack.lazy(...)` + +Schema reference for recursive object graphs. + +- Created using `Ack.lazy(name, builder)`. +- The builder is resolved once and memoized. +- `toJsonSchema()` and `toSchemaModel()` export Draft-7 `definitions` / `$ref` + entries using the lazy `name`. +- Lazy schemas cannot be used directly as discriminated-union branches because + the branch discriminator must be analyzable at construction time. + ## Code Generation Annotations Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to diff --git a/docs/core-concepts/schemas.mdx b/docs/core-concepts/schemas.mdx index 6652739e..88871a8d 100644 --- a/docs/core-concepts/schemas.mdx +++ b/docs/core-concepts/schemas.mdx @@ -184,6 +184,25 @@ final shapeSchema = Ack.discriminated( The union owns the discriminator and injects the exact branch literal at parse/export boundaries. Branch schemas usually omit the discriminator field. +### Recursive Schemas + +Use `Ack.lazy(...)` when a schema needs to refer to itself: + +```dart +late final ObjectSchema categorySchema; + +categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), +}); +``` + +The lazy builder is resolved once and memoized. JSON Schema export renders the +reference through Draft-7 `definitions` / `$ref`, so recursive children are +referenced instead of inlined forever. + ### Any Accept any non-null JSON-safe value without validation (use sparingly): diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index 6aeab456..f6937c25 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -172,6 +172,7 @@ Ack attempts to map its [built-in constraints](../core-concepts/validation.mdx) | [`Ack.boolean()`](../core-concepts/schemas.mdx#boolean) | `type: boolean` | Type | | [`Ack.list(...)`](../core-concepts/schemas.mdx#list) | `type: array`, `items: {...}` | Type | | [`Ack.object(...)`](../core-concepts/schemas.mdx#object) | `type: object`, `properties: {...}`, `required: [...]` | Type | +| [`Ack.lazy(...)`](../core-concepts/schemas.mdx#recursive-schemas) | `definitions`, `$ref` | Recursive type | ## Shape Stability Notes @@ -186,6 +187,8 @@ nullability rules: branches. Each branch contains the exact required discriminator `const`. - `Ack.discriminated(...).nullable()` wraps that `anyOf` union with a second `{ "type": "null" }` branch. +- `Ack.lazy(name, ...)` is emitted as a Draft-7 `definitions` entry and local + `$ref` values such as `{ "$ref": "#/definitions/Category" }`. This means nullable enums are represented as: @@ -229,6 +232,10 @@ shape should implement that as explicit adapter rendering. - **`Ack.any()`:** Runtime validation accepts non-null JSON-safe values. JSON-like adapter exports represent those JSON-compatible values and attach an `ack_any_json_boundary` warning to the `AckSchemaModel`. +- **`Ack.lazy()` runtime checks:** Recursive structure is exported with + Draft-7 `definitions` / `$ref`. Constraints and refinements added directly + to the lazy reference are runtime-only and are reported as schema-model + warnings rather than emitted beside `$ref`. - **Date/time range constraints:** Draft-7 has no standard `formatMinimum` or `formatMaximum` keywords. ACK validates `.min()` and `.max()` at runtime and records schema-model warnings instead of rendering non-standard keywords. diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 990330c3..3cfc9cf7 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -346,7 +346,7 @@ final class AckRefSchemaModel extends AckSchemaModel { @override Map toJsonSchema() => - finishTypeJson({r'$ref': '#/definitions/$refName'}); + finishTypeJson({r'$ref': '#/definitions/${_jsonPointerToken(refName)}'}); @override AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => @@ -1159,3 +1159,7 @@ num? _readNumKeyword(Map keywords, String key) { final value = keywords[key]; return value is num ? value : null; } + +String _jsonPointerToken(String value) { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} 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 ce3a462a..6fdcf148 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 @@ -17,18 +17,21 @@ extension AckSchemaModelExtension< } final class _SchemaModelBuilder { - final _defs = {}; + final _definitions = {}; final _targets = {}; AckSchemaModel build(AckSchema schema) { final root = _build(schema); - if (_defs.isEmpty) return root; + if (_definitions.isEmpty) return root; - final defs = { - for (final entry in _defs.entries) + final definitions = { + for (final entry in _definitions.entries) if (entry.value case final model?) entry.key: model.toJsonSchema(), }; - return root.withExtensions({...root.extensions, 'definitions': defs}); + return root.withExtensions({ + ...root.extensions, + 'definitions': definitions, + }); } AckSchemaModel _build(AckSchema schema) { @@ -272,8 +275,8 @@ final class _SchemaModelBuilder { } _targets[name] = target; - _defs[name] = null; - _defs[name] = _build(target); + _definitions[name] = null; + _definitions[name] = _build(target); return _lazyRef(schema); } diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 0f1fd2e2..a248b35a 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -68,6 +68,20 @@ void main() { expect(properties['second'], {r'$ref': '#/definitions/Category'}); }); + test('escapes lazy names in JSON Pointer refs', () { + final target = Ack.object({'name': Ack.string()}); + final schema = Ack.object({ + 'node': Ack.lazy('Tree/Node~1', () => target), + }); + + final jsonSchema = schema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final definitions = jsonSchema['definitions']! as Map; + + expect(definitions.keys, ['Tree/Node~1']); + expect(properties['node'], {r'$ref': '#/definitions/Tree~1Node~01'}); + }); + test('rejects lazies with the same name and different targets', () { final firstTarget = Ack.object({'name': Ack.string()}); final secondTarget = Ack.object({'title': Ack.string()}); From 366ea3e8a39b67a54ce4bd152e1ffe7be4aea244 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 27 May 2026 20:18:09 -0400 Subject: [PATCH 7/9] fix(ack): harden lazy JSON Schema refs --- docs/api-reference/index.mdx | 4 +- docs/guides/json-schema-integration.mdx | 3 + packages/ack/lib/src/ack.dart | 4 +- .../src/schema_model/ack_schema_model.dart | 14 ++- .../ack_schema_model_builder.dart | 50 +++++++++- packages/ack/lib/src/schemas/lazy_schema.dart | 3 +- .../ack/test/schemas/lazy_schema_test.dart | 91 +++++++++++++++++++ .../ack_schema_recursive_lazy_ref.json | 58 ++++++++++++ .../manifest.json | 24 ++++- ...irebase_ai_response_json_schema_cases.dart | 21 +++++ .../test/to_json_schema_builder_test.dart | 30 ++++++ 11 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 8e0bed5c..90338520 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -235,8 +235,8 @@ Schema reference for recursive object graphs. - The builder is resolved once and memoized. - `toJsonSchema()` and `toSchemaModel()` export Draft-7 `definitions` / `$ref` entries using the lazy `name`. -- Lazy schemas cannot be used directly as discriminated-union branches because - the branch discriminator must be analyzable at construction time. +- Bare or wrapped lazy schemas cannot be used as discriminated-union branches + because the branch discriminator must be analyzable at construction time. ## Code Generation Annotations diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index f6937c25..d7297586 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -189,6 +189,9 @@ nullability rules: `{ "type": "null" }` branch. - `Ack.lazy(name, ...)` is emitted as a Draft-7 `definitions` entry and local `$ref` values such as `{ "$ref": "#/definitions/Category" }`. +- Non-null lazy refs that carry metadata such as `description` use `allOf` + around the `$ref` so Draft-7 validators do not ignore that metadata as a + `$ref` sibling. Nullable lazy refs keep metadata beside the top-level `anyOf`. This means nullable enums are represented as: diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 4db4c6aa..27e4a801 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -92,8 +92,8 @@ final class Ack { /// the closure into a `final` variable lets two calls share equality. /// /// `toJsonSchema()` and `toSchemaModel()` export lazy references through - /// recursive `definitions`/`$ref` JSON Schema definitions. `Ack.lazy` cannot - /// be used directly as a discriminated union branch. + /// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped + /// `Ack.lazy` schemas cannot be used as discriminated union branches. static LazySchema lazy< Boundary extends Object, Runtime extends Object diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 3cfc9cf7..25893fee 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -345,8 +345,18 @@ final class AckRefSchemaModel extends AckSchemaModel { final String refName; @override - Map toJsonSchema() => - finishTypeJson({r'$ref': '#/definitions/${_jsonPointerToken(refName)}'}); + Map toJsonSchema() { + final refJson = {r'$ref': '#/definitions/${_jsonPointerToken(refName)}'}; + if (nullable) return finishTypeJson(refJson); + + final commonJson = _common.toJson(); + if (commonJson.isEmpty) return refJson; + + return { + ...commonJson, + 'allOf': [refJson], + }; + } @override AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => 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 6fdcf148..02466095 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 @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; + import '../constraints/constraint.dart'; import '../constraints/datetime_constraint.dart'; import '../context.dart'; @@ -24,16 +26,54 @@ final class _SchemaModelBuilder { final root = _build(schema); if (_definitions.isEmpty) return root; - final definitions = { - for (final entry in _definitions.entries) - if (entry.value case final model?) entry.key: model.toJsonSchema(), - }; return root.withExtensions({ ...root.extensions, - 'definitions': definitions, + 'definitions': _mergeRootDefinitions(root.extensions['definitions']), }); } + Map _mergeRootDefinitions(Object? existingDefinitions) { + final lazyDefinitions = { + for (final entry in _definitions.entries) + if (entry.value case final model?) entry.key: model.toJsonSchema(), + }; + if (existingDefinitions == null) return lazyDefinitions; + if (existingDefinitions is! Map) { + throw ArgumentError( + 'Root JSON Schema definitions must be a map when Ack.lazy definitions ' + 'are exported.', + ); + } + + final merged = {}; + for (final entry in existingDefinitions.entries) { + final key = entry.key; + if (key is! String) { + throw ArgumentError( + 'Root JSON Schema definitions keys must be strings when Ack.lazy ' + 'definitions are exported.', + ); + } + merged[key] = entry.value; + } + + const equality = DeepCollectionEquality(); + for (final entry in lazyDefinitions.entries) { + if (merged.containsKey(entry.key)) { + if (!equality.equals(merged[entry.key], entry.value)) { + throw ArgumentError( + 'Ack.lazy definition "${entry.key}" collides with an existing root ' + 'JSON Schema definition. Use a unique lazy name or rename the ' + 'existing definition.', + ); + } + continue; + } + merged[entry.key] = entry.value; + } + return merged; + } + AckSchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { final base = _build(schema.inner); diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index ff935d4c..ba7617dc 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -1,6 +1,7 @@ part of 'schema.dart'; -/// Defers resolving another schema until parse, validation, or encode time. +/// Defers resolving another schema until parse, validation, encode, or export +/// time. /// /// This enables recursive schema graphs where a child schema needs to refer /// back to an outer schema that is assigned after construction. diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index a248b35a..1f11b9eb 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -82,6 +82,54 @@ void main() { expect(properties['node'], {r'$ref': '#/definitions/Tree~1Node~01'}); }); + test('merges custom root definitions with lazy definitions', () { + late final AckSchema categorySchema; + categorySchema = + Ack.object({ + 'name': Ack.string(), + 'slug': Ack.string(), + 'child': Ack.lazy('Category', () => categorySchema), + }).withConstraint( + const _TestJsonSchemaKeywordConstraint({ + 'definitions': { + 'Slug': {'type': 'string', 'pattern': r'^[a-z0-9-]+$'}, + }, + }), + ); + + final jsonSchema = categorySchema.toJsonSchema(); + final definitions = jsonSchema['definitions']! as Map; + + expect(definitions['Slug'], {'type': 'string', 'pattern': r'^[a-z0-9-]+$'}); + expect(definitions['Category'], isA()); + }); + + test('rejects custom root definitions that collide with lazy names', () { + late final AckSchema categorySchema; + categorySchema = + Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy('Category', () => categorySchema), + }).withConstraint( + const _TestJsonSchemaKeywordConstraint({ + 'definitions': { + 'Category': {'type': 'string'}, + }, + }), + ); + + expect( + categorySchema.toJsonSchema, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('collides with an existing root JSON Schema definition'), + ), + ), + ); + }); + test('rejects lazies with the same name and different targets', () { final firstTarget = Ack.object({'name': Ack.string()}); final secondTarget = Ack.object({'title': Ack.string()}); @@ -102,6 +150,28 @@ void main() { ); }); + test('wraps non-null lazy metadata without ref siblings', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Child category'), + }); + + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + + expect(properties['child'], { + 'description': 'Child category', + 'allOf': [ + {r'$ref': '#/definitions/Category'}, + ], + }); + expect(properties['child'], isNot(contains(r'$ref'))); + }); + test('exports nullable lazy references with metadata', () { late final ObjectSchema categorySchema; categorySchema = Ack.object({ @@ -245,3 +315,24 @@ void main() { expect(calls, 15); }); } + +final class _TestJsonSchemaKeywordConstraint + extends Constraint + with Validator, JsonSchemaSpec { + const _TestJsonSchemaKeywordConstraint(this.keywords) + : super( + constraintKey: 'test_schema_model_keywords', + description: 'Adds test-only JSON Schema keywords.', + ); + + final Map keywords; + + @override + bool isValid(T value) => true; + + @override + String buildMessage(T value) => 'ok'; + + @override + Map toJsonSchema() => keywords; +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json new file mode 100644 index 00000000..bb4d73cb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/Category" + } + }, + "featured": { + "description": "Featured category", + "allOf": [ + { + "$ref": "#/definitions/Category" + } + ] + } + }, + "required": [ + "name", + "children", + "featured" + ], + "additionalProperties": false, + "definitions": { + "Category": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/Category" + } + }, + "featured": { + "description": "Featured category", + "allOf": [ + { + "$ref": "#/definitions/Category" + } + ] + } + }, + "required": [ + "name", + "children", + "featured" + ], + "additionalProperties": false + } + } +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json index 5d975684..1ab14e6c 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json @@ -1,8 +1,11 @@ { "description": "Golden fixtures for ACK to Firebase AI responseJsonSchema conversion.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", - "fixtureCount": 29, + "fixtureCount": 30, "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], "additionalProperties": [ "ack_schema_object_passthrough", "ack_schema_object_properties_requiredness" @@ -11,6 +14,7 @@ "schema_model_object_schema_additional_properties" ], "allOf": [ + "ack_schema_recursive_lazy_ref", "schema_model_allof" ], "any": [ @@ -27,6 +31,7 @@ ], "array": [ "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", "schema_model_array_without_item_schema" ], "boolean": [ @@ -53,6 +58,9 @@ "schema_model_anyof_common_fields_explicit_null", "schema_model_nullable_default_extensions" ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], "description": [ "schema_model_string_common_options" ], @@ -146,6 +154,7 @@ "object": [ "ack_schema_object_passthrough", "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", "schema_model_object_schema_additional_properties" ], "oneOf": [ @@ -395,6 +404,19 @@ ], "fixture": "ack_schema_discriminated_union.json" }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "fixture": "ack_schema_recursive_lazy_ref.json" + }, { "id": "schema_model_string_common_options", "name": "string model common and string-only options", diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart index e9cd43f7..2edc2bf1 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart @@ -240,6 +240,12 @@ List firebaseAiResponseJsonSchemaCases() => [ }, ), ), + AckSchemaResponseJsonSchemaCase( + id: 'ack_schema_recursive_lazy_ref', + name: 'recursive lazy reference', + features: const ['object', 'array', 'definitions', r'$ref', 'allOf'], + schema: _recursiveCategorySchema(), + ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_string_common_options', name: 'string model common and string-only options', @@ -400,3 +406,18 @@ List firebaseAiResponseJsonSchemaCases() => [ ), ), ]; + +ObjectSchema _recursiveCategorySchema() { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + 'featured': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Featured category'), + }); + return categorySchema; +} diff --git a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart index 9218dcfa..4f019276 100644 --- a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart @@ -150,6 +150,36 @@ void main() { final result = schema.toJsonSchemaBuilder(); expect(result, isNotNull); }); + + test('preserves recursive lazy refs and metadata refs', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + 'featured': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Featured category'), + }); + + final result = categorySchema.toJsonSchemaBuilder(); + final definitions = result.value['definitions'] as Map; + final properties = result.value['properties'] as Map; + final children = _schemaFrom(properties['children']); + final childItems = _schemaFrom(children.value['items']); + final featured = _schemaFrom(properties['featured']); + final featuredAllOf = (featured.value['allOf'] as List) + .map(_schemaFrom) + .toList(growable: false); + + expect(definitions.keys, contains('Category')); + expect(childItems.value[r'$ref'], '#/definitions/Category'); + expect(featured.value['description'], 'Featured category'); + expect(featured.value, isNot(contains(r'$ref'))); + expect(featuredAllOf.single.value[r'$ref'], '#/definitions/Category'); + }); }); group('Metadata preservation', () { From 5958c4102505554124e449b1d0d16026e56cc5bc Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 28 May 2026 13:04:14 -0400 Subject: [PATCH 8/9] fix(ack): preserve discriminator encode validation --- .../ack_schema_model_builder.dart | 46 +------------------ .../src/utils/discriminated_branch_utils.dart | 8 +--- .../discriminated_object_schema_test.dart | 30 +----------- 3 files changed, 3 insertions(+), 81 deletions(-) diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 02466095..1f635fda 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -285,9 +285,7 @@ final class _SchemaModelBuilder { 'Discriminated branch "${entry.key}" must export as an object schema model.', ); } - branches.add( - _withRequiredDiscriminator(converted, schema.discriminatorKey), - ); + branches.add(converted); } return AckAnyOfSchemaModel( @@ -347,48 +345,6 @@ final class _SchemaModelBuilder { } } -/// Strips the synthetic [DefaultSchema] default from the discriminator -/// property in an exported branch model and marks the property as required. -/// -/// `effectiveDiscriminatedObjectBranch` attaches `.withDefault(...)` to the -/// synthesized literal so encode can inject the discriminator when the branch -/// runtime omits it; that default is an encode-time convenience, not part of -/// the published JSON Schema. -AckObjectSchemaModel _withRequiredDiscriminator( - AckObjectSchemaModel model, - String discriminatorKey, -) { - final properties = model.properties; - if (properties == null || !properties.containsKey(discriminatorKey)) { - return model; - } - - final discriminator = properties[discriminatorKey]!; - final normalizedProperties = discriminator.defaultValue == null - ? properties - : {...properties, discriminatorKey: discriminator.withDefaultValue(null)}; - final required = model.required ?? const []; - final alreadyRequired = required.contains(discriminatorKey); - if (alreadyRequired && identical(normalizedProperties, properties)) { - return model; - } - - return AckObjectSchemaModel( - properties: normalizedProperties, - required: alreadyRequired ? required : [discriminatorKey, ...required], - propertyOrdering: model.propertyOrdering, - minProperties: model.minProperties, - maxProperties: model.maxProperties, - additionalProperties: model.additionalProperties, - title: model.title, - description: model.description, - nullable: model.nullable, - defaultValue: model.defaultValue, - warnings: model.warnings, - extensions: model.extensions, - ); -} - AckSchemaModel _applyConstraints( AckSchemaModel model, AckSchema schema, diff --git a/packages/ack/lib/src/utils/discriminated_branch_utils.dart b/packages/ack/lib/src/utils/discriminated_branch_utils.dart index 7ebc1638..157ee7fe 100644 --- a/packages/ack/lib/src/utils/discriminated_branch_utils.dart +++ b/packages/ack/lib/src/utils/discriminated_branch_utils.dart @@ -70,14 +70,8 @@ ObjectSchema effectiveDiscriminatedObjectBranch({ ); } - final discriminatorSchema = existingDiscriminator == null - ? _discriminatorLiteralSchema( - discriminatorValue, - ).withDefault(discriminatorValue) - : _discriminatorLiteralSchema(discriminatorValue); - final properties = { - discriminatorKey: discriminatorSchema, + discriminatorKey: _discriminatorLiteralSchema(discriminatorValue), for (final entry in objectSchema.properties.entries) if (entry.key != discriminatorKey) entry.key: entry.value, }; diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index f66c7d25..693e1437 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -119,36 +119,8 @@ void main() { expect(result.getOrThrow(), {'type': 'dog', 'bark': false}); }); - test('encodes a branch whose runtime omits the discriminator', () { - final result = unionOwnedSchema.safeEncode({'bark': false}); - - expect(result.isOk, isTrue); - expect(result.getOrThrow(), {'type': 'dog', 'bark': false}); - }); - - test('encodes a codec-backed branch whose encoder omits the ' - 'discriminator', () { - final codecUnion = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'cat': Ack.codec( - input: Ack.object({'meow': Ack.boolean()}), - decode: (map) => map, - encode: (value) => { - 'meow': value['meow'], - }, // intentionally omits 'type' - ), - }, - ); - - final result = codecUnion.safeEncode({'meow': true}); - - expect(result.isOk, isTrue); - expect(result.getOrThrow(), {'type': 'cat', 'meow': true}); - }); - test('JSON Schema marks the synthesized discriminator required and ' - 'strips the encode-time default', () { + 'does not export a discriminator default', () { final jsonSchema = unionOwnedSchema.toJsonSchema(); final branches = (jsonSchema['anyOf'] as List).cast(); From 8f435fb8dee08fa10a201494a06c71217ec738a1 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 28 May 2026 14:19:35 -0400 Subject: [PATCH 9/9] test(ack_firebase_ai): add Firebase native schema fixtures --- packages/ack_firebase_ai/CHANGELOG.md | 2 + packages/ack_firebase_ai/README.md | 6 +- ...base_ai_schema_capability_matrix_test.dart | 178 +++++ ...ack_schema_anyof_nullable_composition.json | 16 + .../ack_schema_array_constraints.json | 9 + ...ack_schema_date_transform_constraints.json | 4 + .../ack_schema_datetime_transform.json | 4 + .../ack_schema_discriminated_union.json | 44 ++ ...schema_duration_transform_constraints.json | 5 + .../ack_schema_enum_values_default.json | 8 + .../ack_schema_generic_transform.json | 3 + .../ack_schema_integer_constraints.json | 5 + .../ack_schema_nullable_boolean_default.json | 6 + .../ack_schema_number_constraints.json | 5 + ...schema_object_properties_requiredness.json | 35 + .../ack_schema_recursive_lazy_ref.json | 46 ++ .../ack_schema_string_constraints.json | 4 + .../ack_schema_string_literal.json | 7 + .../ack_schema_uri_transform.json | 4 + .../manifest.json | 626 ++++++++++++++++ ...del_anyof_common_fields_explicit_null.json | 16 + .../schema_model_integer_const_format.json | 3 + ...ema_model_nullable_default_extensions.json | 6 + .../schema_model_number_const_format.json | 3 + ...l_object_schema_additional_properties.json | 11 + .../schema_model_oneof_discriminator.json | 43 ++ ...hema_model_oneof_nullable_composition.json | 21 + .../schema_model_string_common_options.json | 6 + ...ack_schema_anyof_nullable_composition.json | 12 + .../ack_schema_array_constraints.json | 9 + ...ack_schema_date_transform_constraints.json | 4 + .../ack_schema_datetime_transform.json | 4 + .../ack_schema_discriminated_union.json | 44 ++ ...schema_duration_transform_constraints.json | 5 + .../ack_schema_enum_values_default.json | 8 + .../ack_schema_generic_transform.json | 3 + .../ack_schema_integer_constraints.json | 5 + .../ack_schema_nullable_boolean_default.json | 4 + .../ack_schema_number_constraints.json | 5 + ...schema_object_properties_requiredness.json | 41 ++ .../ack_schema_string_constraints.json | 4 + .../ack_schema_string_literal.json | 7 + .../ack_schema_uri_transform.json | 4 + .../firebase_ai_native_schema/manifest.json | 626 ++++++++++++++++ ...del_anyof_common_fields_explicit_null.json | 12 + .../schema_model_integer_const_format.json | 4 + ...ema_model_nullable_default_extensions.json | 4 + .../schema_model_number_const_format.json | 4 + ...l_object_schema_additional_properties.json | 14 + .../schema_model_oneof_discriminator.json | 43 ++ ...hema_model_oneof_nullable_composition.json | 17 + .../schema_model_string_common_options.json | 6 + .../firebase_ai_native_schema_cases.dart | 692 ++++++++++++++++++ ...irebase_ai_native_schema_fixture_test.dart | 308 ++++++++ ...base_ai_response_json_schema_fixtures.dart | 75 +- pubspec.yaml | 2 +- 56 files changed, 3088 insertions(+), 4 deletions(-) create mode 100644 packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json create mode 100644 packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json create mode 100644 packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart create mode 100644 packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index 3ca1c20c..bdc35acc 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -9,6 +9,8 @@ backends, Firebase app credentials, location, and model override. - Added committed Firebase AI `responseJsonSchema` golden fixtures and a fixture generator so converter coverage runs without Firebase credentials. +- Added Firebase SDK `Schema.toJson()` and `JSONSchema.toJson()` native + fixture corpora with adapter capability classifications. ### Changed - Target Firebase AI `^3.12.1` and models that support JSON Schema diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index 2f280c78..ccbe9460 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -178,8 +178,10 @@ FIREBASE_AI_LOCATION=global The committed fixture tests are the normal adapter contract and do not require Firebase credentials. They compare ACK conversion output against JSON fixtures -under `test/fixtures/firebase_ai_response_json_schema/` and verify the same -maps serialize through Firebase AI's `GenerationConfig.responseJsonSchema`. +under `test/fixtures/firebase_ai_response_json_schema/`, capture Firebase SDK +`Schema.toJson()` and `JSONSchema.toJson()` native fixture output, and verify +the same maps serialize through Firebase AI's +`GenerationConfig.responseJsonSchema`. Regenerate the fixtures after an intentional schema-output change: diff --git a/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart b/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart new file mode 100644 index 00000000..c7fe4301 --- /dev/null +++ b/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'support/firebase_ai_native_schema_cases.dart'; +import 'support/firebase_ai_response_json_schema_cases.dart'; + +void main() { + group('Firebase AI schema capability matrix', () { + test('tracks every ACK adapter case for both Firebase native families', () { + final ackCases = firebaseAiResponseJsonSchemaCases(); + + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + final nativeCases = firebaseAiNativeSchemaCases(family); + + expect(nativeCases.map((schemaCase) => schemaCase.id), [ + for (final schemaCase in ackCases) schemaCase.id, + ]); + expect( + nativeCases.map((schemaCase) => schemaCase.comparison).toSet(), + containsAll([ + FirebaseAiSchemaComparison.adapterTransformNeeded.jsonValue, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ]), + ); + } + }); + + test('records known provider-specific classifications', () { + final schemaCases = _casesById( + firebaseAiNativeSchemaCases(FirebaseAiNativeSchemaFixtureFamily.schema), + ); + final jsonSchemaCases = _casesById( + firebaseAiNativeSchemaCases( + FirebaseAiNativeSchemaFixtureFamily.jsonSchema, + ), + ); + + expect( + schemaCases['ack_schema_string_literal']!.comparison, + FirebaseAiSchemaComparison.adapterTransformNeeded.jsonValue, + ); + expect( + schemaCases['ack_schema_recursive_lazy_ref']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + expect( + jsonSchemaCases['ack_schema_recursive_lazy_ref']!.comparison, + FirebaseAiSchemaComparison.backendLimited.jsonValue, + ); + expect( + jsonSchemaCases['schema_model_allof']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + expect( + jsonSchemaCases['schema_model_boolean_const']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + }); + + test( + 'generated native fixtures are intentionally comparable to ACK output', + () { + final ackFixtures = _AckFixtureSet.load(); + + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + for (final nativeCase in firebaseAiNativeSchemaCases(family)) { + final ackJson = ackFixtures.byId(nativeCase.id); + + switch (nativeCase.comparisonEnum) { + case FirebaseAiSchemaComparison.exact: + expect(nativeCase.buildJsonSchema(), ackJson); + case FirebaseAiSchemaComparison.equivalent: + case FirebaseAiSchemaComparison.adapterTransformNeeded: + case FirebaseAiSchemaComparison.backendLimited: + if (nativeCase.isGenerated) { + expect(nativeCase.buildJsonSchema(), isNot(ackJson)); + } + case FirebaseAiSchemaComparison.unsupportedByFirebaseSchema: + expect(nativeCase.isGenerated, isFalse); + } + } + } + }, + ); + }); +} + +Map _casesById( + List cases, +) { + return {for (final schemaCase in cases) schemaCase.id: schemaCase}; +} + +final class _AckFixtureSet { + const _AckFixtureSet(this._fixtures); + + final Map> _fixtures; + + Map byId(String id) { + final fixture = _fixtures[id]; + if (fixture == null) fail('Missing ACK fixture for $id.'); + return fixture; + } + + static _AckFixtureSet load() { + final packageRoot = _findPackageRoot(); + final fixtureDir = Directory( + '${packageRoot.path}/test/fixtures/firebase_ai_response_json_schema', + ); + final manifestFile = File('${fixtureDir.path}/manifest.json'); + final manifest = _jsonObject( + jsonDecode(manifestFile.readAsStringSync()), + manifestFile.path, + ); + final fixtures = >{}; + + for (final entry in _jsonList(manifest['fixtures'], 'fixtures')) { + final manifestFixture = _jsonObject(entry, 'fixtures[]'); + final id = _jsonString(manifestFixture['id'], 'fixtures[].id'); + final fixtureName = _jsonString( + manifestFixture['fixture'], + 'fixtures[].fixture', + ); + final fixtureFile = File('${fixtureDir.path}/$fixtureName'); + fixtures[id] = _jsonObject( + jsonDecode(fixtureFile.readAsStringSync()), + fixtureFile.path, + ); + } + + return _AckFixtureSet(fixtures); + } +} + +Directory _findPackageRoot() { + var current = Directory.current.absolute; + while (true) { + final pubspec = File('${current.path}/pubspec.yaml'); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return current; + } + + final nested = Directory('${current.path}/packages/ack_firebase_ai'); + final nestedPubspec = File('${nested.path}/pubspec.yaml'); + if (nestedPubspec.existsSync() && + nestedPubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return nested; + } + + final parent = current.parent; + if (parent.path == current.path) { + fail( + 'Could not find packages/ack_firebase_ai from ${Directory.current}.', + ); + } + current = parent; + } +} + +Map _jsonObject(Object? value, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + fail('Expected $path to be a JSON object, got ${value.runtimeType}.'); +} + +List _jsonList(Object? value, String path) { + if (value is List) return value; + if (value is List) return value.cast(); + fail('Expected $path to be a JSON array, got ${value.runtimeType}.'); +} + +String _jsonString(Object? value, String path) { + if (value is String) return value; + fail('Expected $path to be a string, got ${value.runtimeType}.'); +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json new file mode 100644 index 00000000..11fbd65b --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json @@ -0,0 +1,16 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ] + }, + { + "type": [ + "integer", + "null" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json new file mode 100644 index 00000000..cda0f957 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json @@ -0,0 +1,9 @@ +{ + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 3 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json new file mode 100644 index 00000000..c1a190b8 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "date" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json new file mode 100644 index 00000000..fd9a76a4 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "date-time" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json new file mode 100644 index 00000000..fc1a674d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json @@ -0,0 +1,44 @@ +{ + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "circle" + ] + }, + "radius": { + "type": "number", + "minimum": 0.0 + } + }, + "required": [ + "type", + "radius" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "square" + ] + }, + "side": { + "type": "number", + "minimum": 0.0 + } + }, + "required": [ + "type", + "side" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json new file mode 100644 index 00000000..2b34ee71 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "integer", + "minimum": 1000.0, + "maximum": 2000.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json new file mode 100644 index 00000000..3d437a83 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json @@ -0,0 +1,8 @@ +{ + "type": "string", + "format": "enum", + "enum": [ + "admin", + "member" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json new file mode 100644 index 00000000..28ac6e86 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json @@ -0,0 +1,3 @@ +{ + "type": "string" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json new file mode 100644 index 00000000..d1dca2e6 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "integer", + "minimum": 1.0, + "maximum": 10.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json new file mode 100644 index 00000000..c0575545 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json @@ -0,0 +1,6 @@ +{ + "type": [ + "boolean", + "null" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json new file mode 100644 index 00000000..44b6d85d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "number", + "minimum": 0.5, + "maximum": 9.5 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json new file mode 100644 index 00000000..97c26e16 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "description": "User payload", + "properties": { + "name": { + "type": "string", + "description": "Full name" + }, + "age": { + "type": "integer", + "minimum": 0.0, + "maximum": 120.0 + }, + "role": { + "type": "string", + "format": "enum", + "enum": [ + "admin", + "member" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 5 + } + }, + "required": [ + "name", + "role" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json new file mode 100644 index 00000000..90ee1cec --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json @@ -0,0 +1,46 @@ +{ + "type": "object", + "$defs": { + "Category": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Category" + } + }, + "featured": { + "$ref": "#/$defs/Category" + } + }, + "required": [ + "name", + "children", + "featured" + ] + } + }, + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Category" + } + }, + "featured": { + "$ref": "#/$defs/Category" + } + }, + "required": [ + "name", + "children", + "featured" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json new file mode 100644 index 00000000..bf021142 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "description": "Code" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json new file mode 100644 index 00000000..3dc153b9 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json @@ -0,0 +1,7 @@ +{ + "type": "string", + "format": "enum", + "enum": [ + "ready" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json new file mode 100644 index 00000000..3caf8ad3 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "uri" +} 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 new file mode 100644 index 00000000..7670dec3 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -0,0 +1,626 @@ +{ + "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", + "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", + "sourcePackage": "firebase_ai", + "sourceVersion": "3.12.1", + "sourceClass": "JSONSchema", + "fixtureCount": 30, + "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], + "additionalProperties": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "additionalPropertiesSchema": [ + "schema_model_object_schema_additional_properties" + ], + "allOf": [ + "ack_schema_recursive_lazy_ref", + "schema_model_allof" + ], + "any": [ + "ack_schema_any_json_compatible_branches" + ], + "anyOf": [ + "ack_schema_any_json_compatible_branches", + "ack_schema_anyof_nullable_composition", + "ack_schema_discriminated_union", + "ack_schema_nullable_boolean_default", + "ack_schema_object_passthrough", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "array": [ + "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", + "schema_model_array_without_item_schema" + ], + "boolean": [ + "ack_schema_nullable_boolean_default", + "schema_model_boolean_const" + ], + "const": [ + "ack_schema_discriminated_union", + "ack_schema_string_literal", + "schema_model_boolean_const", + "schema_model_integer_const_format", + "schema_model_nullable_default_extensions", + "schema_model_number_const_format", + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition", + "schema_model_string_common_options" + ], + "default": [ + "ack_schema_array_constraints", + "ack_schema_enum_values_default", + "ack_schema_integer_constraints", + "ack_schema_nullable_boolean_default", + "ack_schema_number_constraints", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], + "description": [ + "schema_model_string_common_options" + ], + "enum": [ + "ack_schema_enum_values_default" + ], + "exclusiveMaximum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "exclusiveMinimum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "extension": [ + "ack_schema_generic_transform", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "format": [ + "ack_schema_array_constraints", + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_uri_transform", + "schema_model_integer_const_format", + "schema_model_number_const_format", + "schema_model_string_common_options" + ], + "integer": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "schema_model_integer_const_format" + ], + "items": [ + "ack_schema_array_constraints" + ], + "maxItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "maxLength": [ + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "maxProperties": [ + "schema_model_object_schema_additional_properties" + ], + "maximum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "minItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "minLength": [ + "ack_schema_generic_transform", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "minProperties": [ + "schema_model_object_schema_additional_properties" + ], + "minimum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "multipleOf": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "null": [ + "schema_model_anyof_common_fields_explicit_null", + "schema_model_null", + "schema_model_oneof_nullable_composition" + ], + "nullable": [ + "ack_schema_anyof_nullable_composition", + "ack_schema_nullable_boolean_default", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_oneof_nullable_composition" + ], + "number": [ + "ack_schema_number_constraints", + "schema_model_number_const_format" + ], + "object": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", + "schema_model_object_schema_additional_properties" + ], + "oneOf": [ + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition" + ], + "optional": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "pattern": [ + "ack_schema_array_constraints", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "properties": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "required": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "string": [ + "ack_schema_enum_values_default", + "ack_schema_string_constraints", + "ack_schema_string_literal", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "title": [ + "schema_model_null", + "schema_model_string_common_options" + ], + "transform": [ + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_duration_transform_constraints", + "ack_schema_generic_transform", + "ack_schema_uri_transform" + ], + "unionOwnedDiscriminator": [ + "ack_schema_discriminated_union" + ], + "uniqueItems": [ + "ack_schema_array_constraints" + ] + }, + "fixtures": [ + { + "id": "ack_schema_string_constraints", + "name": "string constraints", + "source": "ack_schema", + "features": [ + "string", + "minLength", + "maxLength", + "pattern" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_constraints.json" + }, + { + "id": "ack_schema_string_literal", + "name": "string literal", + "source": "ack_schema", + "features": [ + "string", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_literal.json" + }, + { + "id": "ack_schema_enum_values_default", + "name": "Dart enum values and enum default", + "source": "ack_schema", + "features": [ + "string", + "enum", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_enum_values_default.json" + }, + { + "id": "ack_schema_integer_constraints", + "name": "integer constraints", + "source": "ack_schema", + "features": [ + "integer", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_integer_constraints.json" + }, + { + "id": "ack_schema_number_constraints", + "name": "number constraints", + "source": "ack_schema", + "features": [ + "number", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_number_constraints.json" + }, + { + "id": "ack_schema_nullable_boolean_default", + "name": "nullable boolean default", + "source": "ack_schema", + "features": [ + "boolean", + "nullable", + "anyOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_nullable_boolean_default.json" + }, + { + "id": "ack_schema_array_constraints", + "name": "array constraints", + "source": "ack_schema", + "features": [ + "array", + "items", + "format", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_array_constraints.json" + }, + { + "id": "ack_schema_object_properties_requiredness", + "name": "object properties and requiredness", + "source": "ack_schema", + "features": [ + "object", + "properties", + "required", + "optional", + "additionalProperties" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_object_properties_requiredness.json" + }, + { + "id": "ack_schema_object_passthrough", + "name": "object passthrough", + "source": "ack_schema", + "features": [ + "object", + "additionalProperties", + "anyOf", + "optional" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema cannot represent Ack.any() or open additional properties." + }, + { + "id": "ack_schema_anyof_nullable_composition", + "name": "anyOf nullable composition", + "source": "ack_schema", + "features": [ + "anyOf", + "nullable" + ], + "status": "generated", + "comparison": "equivalent", + "fixture": "ack_schema_anyof_nullable_composition.json" + }, + { + "id": "ack_schema_generic_transform", + "name": "generic transform", + "source": "ack_schema", + "features": [ + "transform", + "extension", + "minLength" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_generic_transform.json" + }, + { + "id": "ack_schema_any_json_compatible_branches", + "name": "any JSON-compatible branches", + "source": "ack_schema", + "features": [ + "any", + "anyOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no unconstrained JSON value representation." + }, + { + "id": "ack_schema_date_transform_constraints", + "name": "date transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_date_transform_constraints.json" + }, + { + "id": "ack_schema_datetime_transform", + "name": "datetime transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_datetime_transform.json" + }, + { + "id": "ack_schema_uri_transform", + "name": "uri transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_uri_transform.json" + }, + { + "id": "ack_schema_duration_transform_constraints", + "name": "duration transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "integer", + "minimum", + "maximum" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_duration_transform_constraints.json" + }, + { + "id": "ack_schema_discriminated_union", + "name": "discriminated union", + "source": "ack_schema", + "features": [ + "anyOf", + "const", + "unionOwnedDiscriminator" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_discriminated_union.json" + }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "status": "generated", + "comparison": "backend_limited", + "fixture": "ack_schema_recursive_lazy_ref.json" + }, + { + "id": "schema_model_string_common_options", + "name": "string model common and string-only options", + "source": "schema_model", + "features": [ + "string", + "title", + "description", + "format", + "const", + "minLength", + "maxLength", + "pattern", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_string_common_options.json" + }, + { + "id": "schema_model_integer_const_format", + "name": "integer model const and format", + "source": "schema_model", + "features": [ + "integer", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_integer_const_format.json" + }, + { + "id": "schema_model_number_const_format", + "name": "number model const and format", + "source": "schema_model", + "features": [ + "number", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_number_const_format.json" + }, + { + "id": "schema_model_boolean_const", + "name": "boolean model const", + "source": "schema_model", + "features": [ + "boolean", + "const" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema cannot represent a boolean const value." + }, + { + "id": "schema_model_nullable_default_extensions", + "name": "nullable model default and extensions", + "source": "schema_model", + "features": [ + "string", + "nullable", + "anyOf", + "const", + "default", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_nullable_default_extensions.json" + }, + { + "id": "schema_model_array_without_item_schema", + "name": "array model without item schema", + "source": "schema_model", + "features": [ + "array", + "minItems", + "maxItems" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema.array requires an item schema." + }, + { + "id": "schema_model_object_schema_additional_properties", + "name": "object model property count and schema additional properties", + "source": "schema_model", + "features": [ + "object", + "properties", + "required", + "minProperties", + "maxProperties", + "additionalPropertiesSchema" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_object_schema_additional_properties.json" + }, + { + "id": "schema_model_null", + "name": "null model", + "source": "schema_model", + "features": [ + "null", + "title" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no null-only schema type." + }, + { + "id": "schema_model_anyof_common_fields_explicit_null", + "name": "anyOf model common fields and explicit null branch", + "source": "schema_model", + "features": [ + "anyOf", + "nullable", + "default", + "extension", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_anyof_common_fields_explicit_null.json" + }, + { + "id": "schema_model_oneof_nullable_composition", + "name": "oneOf model nullable composition", + "source": "schema_model", + "features": [ + "oneOf", + "nullable", + "const", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_nullable_composition.json" + }, + { + "id": "schema_model_oneof_discriminator", + "name": "oneOf model discriminator metadata", + "source": "schema_model", + "features": [ + "oneOf", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_discriminator.json" + }, + { + "id": "schema_model_allof", + "name": "allOf model", + "source": "schema_model", + "features": [ + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no allOf composition builder." + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json new file mode 100644 index 00000000..11fbd65b --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json @@ -0,0 +1,16 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ] + }, + { + "type": [ + "integer", + "null" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json new file mode 100644 index 00000000..782f2003 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json @@ -0,0 +1,3 @@ +{ + "type": "integer" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json new file mode 100644 index 00000000..830edfe5 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json @@ -0,0 +1,6 @@ +{ + "type": [ + "string", + "null" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json new file mode 100644 index 00000000..3ab970ec --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json @@ -0,0 +1,3 @@ +{ + "type": "number" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json new file mode 100644 index 00000000..b7e92ecd --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json new file mode 100644 index 00000000..4d0d056a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json @@ -0,0 +1,43 @@ +{ + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "email" + ] + }, + "address": { + "type": "string", + "format": "email" + } + }, + "required": [ + "type", + "address" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "sms" + ] + }, + "number": { + "type": "string" + } + }, + "required": [ + "type", + "number" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json new file mode 100644 index 00000000..14d5a667 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json @@ -0,0 +1,21 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ], + "format": "enum", + "enum": [ + "ready" + ] + }, + { + "type": [ + "integer", + "null" + ], + "minimum": 1.0 + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json new file mode 100644 index 00000000..92590a7a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json @@ -0,0 +1,6 @@ +{ + "type": "string", + "format": "custom-format", + "description": "Current status", + "title": "Status" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json new file mode 100644 index 00000000..ea66fdad --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json @@ -0,0 +1,12 @@ +{ + "anyOf": [ + { + "type": "STRING", + "nullable": true + }, + { + "type": "INTEGER", + "nullable": true + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json new file mode 100644 index 00000000..1245034d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json @@ -0,0 +1,9 @@ +{ + "type": "ARRAY", + "items": { + "type": "STRING", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 3 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json new file mode 100644 index 00000000..e5415056 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "date" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json new file mode 100644 index 00000000..3d6502cb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "date-time" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json new file mode 100644 index 00000000..dcf0ec58 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json @@ -0,0 +1,44 @@ +{ + "anyOf": [ + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "circle" + ] + }, + "radius": { + "type": "NUMBER", + "minimum": 0.0 + } + }, + "required": [ + "type", + "radius" + ] + }, + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "square" + ] + }, + "side": { + "type": "NUMBER", + "minimum": 0.0 + } + }, + "required": [ + "type", + "side" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json new file mode 100644 index 00000000..90160e21 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "INTEGER", + "minimum": 1000.0, + "maximum": 2000.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json new file mode 100644 index 00000000..4793defb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json @@ -0,0 +1,8 @@ +{ + "type": "STRING", + "format": "enum", + "enum": [ + "admin", + "member" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json new file mode 100644 index 00000000..6cfefe38 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json @@ -0,0 +1,3 @@ +{ + "type": "STRING" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json new file mode 100644 index 00000000..16ba093e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "INTEGER", + "minimum": 1.0, + "maximum": 10.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json new file mode 100644 index 00000000..5e6cf023 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json @@ -0,0 +1,4 @@ +{ + "type": "BOOLEAN", + "nullable": true +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json new file mode 100644 index 00000000..fb42bac6 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "NUMBER", + "minimum": 0.5, + "maximum": 9.5 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json new file mode 100644 index 00000000..59781a17 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json @@ -0,0 +1,41 @@ +{ + "type": "OBJECT", + "description": "User payload", + "properties": { + "name": { + "type": "STRING", + "description": "Full name" + }, + "age": { + "type": "INTEGER", + "minimum": 0.0, + "maximum": 120.0 + }, + "role": { + "type": "STRING", + "format": "enum", + "enum": [ + "admin", + "member" + ] + }, + "tags": { + "type": "ARRAY", + "items": { + "type": "STRING" + }, + "minItems": 1, + "maxItems": 5 + } + }, + "required": [ + "name", + "role" + ], + "propertyOrdering": [ + "name", + "age", + "role", + "tags" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json new file mode 100644 index 00000000..975a1423 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "description": "Code" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json new file mode 100644 index 00000000..565738dd --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json @@ -0,0 +1,7 @@ +{ + "type": "STRING", + "format": "enum", + "enum": [ + "ready" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json new file mode 100644 index 00000000..c161c449 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "uri" +} 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 new file mode 100644 index 00000000..3c8f007a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -0,0 +1,626 @@ +{ + "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", + "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", + "sourcePackage": "firebase_ai", + "sourceVersion": "3.12.1", + "sourceClass": "Schema", + "fixtureCount": 30, + "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], + "additionalProperties": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "additionalPropertiesSchema": [ + "schema_model_object_schema_additional_properties" + ], + "allOf": [ + "ack_schema_recursive_lazy_ref", + "schema_model_allof" + ], + "any": [ + "ack_schema_any_json_compatible_branches" + ], + "anyOf": [ + "ack_schema_any_json_compatible_branches", + "ack_schema_anyof_nullable_composition", + "ack_schema_discriminated_union", + "ack_schema_nullable_boolean_default", + "ack_schema_object_passthrough", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "array": [ + "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", + "schema_model_array_without_item_schema" + ], + "boolean": [ + "ack_schema_nullable_boolean_default", + "schema_model_boolean_const" + ], + "const": [ + "ack_schema_discriminated_union", + "ack_schema_string_literal", + "schema_model_boolean_const", + "schema_model_integer_const_format", + "schema_model_nullable_default_extensions", + "schema_model_number_const_format", + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition", + "schema_model_string_common_options" + ], + "default": [ + "ack_schema_array_constraints", + "ack_schema_enum_values_default", + "ack_schema_integer_constraints", + "ack_schema_nullable_boolean_default", + "ack_schema_number_constraints", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], + "description": [ + "schema_model_string_common_options" + ], + "enum": [ + "ack_schema_enum_values_default" + ], + "exclusiveMaximum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "exclusiveMinimum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "extension": [ + "ack_schema_generic_transform", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "format": [ + "ack_schema_array_constraints", + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_uri_transform", + "schema_model_integer_const_format", + "schema_model_number_const_format", + "schema_model_string_common_options" + ], + "integer": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "schema_model_integer_const_format" + ], + "items": [ + "ack_schema_array_constraints" + ], + "maxItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "maxLength": [ + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "maxProperties": [ + "schema_model_object_schema_additional_properties" + ], + "maximum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "minItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "minLength": [ + "ack_schema_generic_transform", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "minProperties": [ + "schema_model_object_schema_additional_properties" + ], + "minimum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "multipleOf": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "null": [ + "schema_model_anyof_common_fields_explicit_null", + "schema_model_null", + "schema_model_oneof_nullable_composition" + ], + "nullable": [ + "ack_schema_anyof_nullable_composition", + "ack_schema_nullable_boolean_default", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_oneof_nullable_composition" + ], + "number": [ + "ack_schema_number_constraints", + "schema_model_number_const_format" + ], + "object": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", + "schema_model_object_schema_additional_properties" + ], + "oneOf": [ + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition" + ], + "optional": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "pattern": [ + "ack_schema_array_constraints", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "properties": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "required": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "string": [ + "ack_schema_enum_values_default", + "ack_schema_string_constraints", + "ack_schema_string_literal", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "title": [ + "schema_model_null", + "schema_model_string_common_options" + ], + "transform": [ + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_duration_transform_constraints", + "ack_schema_generic_transform", + "ack_schema_uri_transform" + ], + "unionOwnedDiscriminator": [ + "ack_schema_discriminated_union" + ], + "uniqueItems": [ + "ack_schema_array_constraints" + ] + }, + "fixtures": [ + { + "id": "ack_schema_string_constraints", + "name": "string constraints", + "source": "ack_schema", + "features": [ + "string", + "minLength", + "maxLength", + "pattern" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_constraints.json" + }, + { + "id": "ack_schema_string_literal", + "name": "string literal", + "source": "ack_schema", + "features": [ + "string", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_literal.json" + }, + { + "id": "ack_schema_enum_values_default", + "name": "Dart enum values and enum default", + "source": "ack_schema", + "features": [ + "string", + "enum", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_enum_values_default.json" + }, + { + "id": "ack_schema_integer_constraints", + "name": "integer constraints", + "source": "ack_schema", + "features": [ + "integer", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_integer_constraints.json" + }, + { + "id": "ack_schema_number_constraints", + "name": "number constraints", + "source": "ack_schema", + "features": [ + "number", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_number_constraints.json" + }, + { + "id": "ack_schema_nullable_boolean_default", + "name": "nullable boolean default", + "source": "ack_schema", + "features": [ + "boolean", + "nullable", + "anyOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_nullable_boolean_default.json" + }, + { + "id": "ack_schema_array_constraints", + "name": "array constraints", + "source": "ack_schema", + "features": [ + "array", + "items", + "format", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_array_constraints.json" + }, + { + "id": "ack_schema_object_properties_requiredness", + "name": "object properties and requiredness", + "source": "ack_schema", + "features": [ + "object", + "properties", + "required", + "optional", + "additionalProperties" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_object_properties_requiredness.json" + }, + { + "id": "ack_schema_object_passthrough", + "name": "object passthrough", + "source": "ack_schema", + "features": [ + "object", + "additionalProperties", + "anyOf", + "optional" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema cannot represent Ack.any() or open additional properties." + }, + { + "id": "ack_schema_anyof_nullable_composition", + "name": "anyOf nullable composition", + "source": "ack_schema", + "features": [ + "anyOf", + "nullable" + ], + "status": "generated", + "comparison": "equivalent", + "fixture": "ack_schema_anyof_nullable_composition.json" + }, + { + "id": "ack_schema_generic_transform", + "name": "generic transform", + "source": "ack_schema", + "features": [ + "transform", + "extension", + "minLength" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_generic_transform.json" + }, + { + "id": "ack_schema_any_json_compatible_branches", + "name": "any JSON-compatible branches", + "source": "ack_schema", + "features": [ + "any", + "anyOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no unconstrained JSON value representation." + }, + { + "id": "ack_schema_date_transform_constraints", + "name": "date transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_date_transform_constraints.json" + }, + { + "id": "ack_schema_datetime_transform", + "name": "datetime transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_datetime_transform.json" + }, + { + "id": "ack_schema_uri_transform", + "name": "uri transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_uri_transform.json" + }, + { + "id": "ack_schema_duration_transform_constraints", + "name": "duration transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "integer", + "minimum", + "maximum" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_duration_transform_constraints.json" + }, + { + "id": "ack_schema_discriminated_union", + "name": "discriminated union", + "source": "ack_schema", + "features": [ + "anyOf", + "const", + "unionOwnedDiscriminator" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_discriminated_union.json" + }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema does not support reusable definitions or references." + }, + { + "id": "schema_model_string_common_options", + "name": "string model common and string-only options", + "source": "schema_model", + "features": [ + "string", + "title", + "description", + "format", + "const", + "minLength", + "maxLength", + "pattern", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_string_common_options.json" + }, + { + "id": "schema_model_integer_const_format", + "name": "integer model const and format", + "source": "schema_model", + "features": [ + "integer", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_integer_const_format.json" + }, + { + "id": "schema_model_number_const_format", + "name": "number model const and format", + "source": "schema_model", + "features": [ + "number", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_number_const_format.json" + }, + { + "id": "schema_model_boolean_const", + "name": "boolean model const", + "source": "schema_model", + "features": [ + "boolean", + "const" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema cannot represent a boolean const value." + }, + { + "id": "schema_model_nullable_default_extensions", + "name": "nullable model default and extensions", + "source": "schema_model", + "features": [ + "string", + "nullable", + "anyOf", + "const", + "default", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_nullable_default_extensions.json" + }, + { + "id": "schema_model_array_without_item_schema", + "name": "array model without item schema", + "source": "schema_model", + "features": [ + "array", + "minItems", + "maxItems" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema.array requires an item schema." + }, + { + "id": "schema_model_object_schema_additional_properties", + "name": "object model property count and schema additional properties", + "source": "schema_model", + "features": [ + "object", + "properties", + "required", + "minProperties", + "maxProperties", + "additionalPropertiesSchema" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_object_schema_additional_properties.json" + }, + { + "id": "schema_model_null", + "name": "null model", + "source": "schema_model", + "features": [ + "null", + "title" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no null-only schema type." + }, + { + "id": "schema_model_anyof_common_fields_explicit_null", + "name": "anyOf model common fields and explicit null branch", + "source": "schema_model", + "features": [ + "anyOf", + "nullable", + "default", + "extension", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_anyof_common_fields_explicit_null.json" + }, + { + "id": "schema_model_oneof_nullable_composition", + "name": "oneOf model nullable composition", + "source": "schema_model", + "features": [ + "oneOf", + "nullable", + "const", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_nullable_composition.json" + }, + { + "id": "schema_model_oneof_discriminator", + "name": "oneOf model discriminator metadata", + "source": "schema_model", + "features": [ + "oneOf", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_discriminator.json" + }, + { + "id": "schema_model_allof", + "name": "allOf model", + "source": "schema_model", + "features": [ + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no allOf composition builder." + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json new file mode 100644 index 00000000..ea66fdad --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json @@ -0,0 +1,12 @@ +{ + "anyOf": [ + { + "type": "STRING", + "nullable": true + }, + { + "type": "INTEGER", + "nullable": true + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json new file mode 100644 index 00000000..2a68b7e4 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json @@ -0,0 +1,4 @@ +{ + "type": "INTEGER", + "format": "int32" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json new file mode 100644 index 00000000..87ebad2e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "nullable": true +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json new file mode 100644 index 00000000..dc87eb16 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json @@ -0,0 +1,4 @@ +{ + "type": "NUMBER", + "format": "double" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json new file mode 100644 index 00000000..9eef8183 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json @@ -0,0 +1,14 @@ +{ + "type": "OBJECT", + "properties": { + "id": { + "type": "STRING" + } + }, + "required": [ + "id" + ], + "propertyOrdering": [ + "id" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json new file mode 100644 index 00000000..23959eb1 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json @@ -0,0 +1,43 @@ +{ + "anyOf": [ + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "email" + ] + }, + "address": { + "type": "STRING", + "format": "email" + } + }, + "required": [ + "type", + "address" + ] + }, + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "sms" + ] + }, + "number": { + "type": "STRING" + } + }, + "required": [ + "type", + "number" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json new file mode 100644 index 00000000..2208d94e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json @@ -0,0 +1,17 @@ +{ + "anyOf": [ + { + "type": "STRING", + "format": "enum", + "nullable": true, + "enum": [ + "ready" + ] + }, + { + "type": "INTEGER", + "nullable": true, + "minimum": 1.0 + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json new file mode 100644 index 00000000..f4560bdf --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json @@ -0,0 +1,6 @@ +{ + "type": "STRING", + "format": "custom-format", + "description": "Current status", + "title": "Status" +} diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart new file mode 100644 index 00000000..e66a89d2 --- /dev/null +++ b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart @@ -0,0 +1,692 @@ +import 'dart:io'; + +// The public firebase_ai library pulls in Flutter-only runtime libraries. +// The fixture generator is a `dart run` tool, so it imports the SDK schema +// definitions directly to snapshot their toJson() output. +// ignore: implementation_imports +import 'package:firebase_ai/src/schema.dart' as firebase_ai; + +import 'firebase_ai_response_json_schema_cases.dart'; + +enum FirebaseAiNativeSchemaFixtureFamily { + schema, + jsonSchema; + + String get fixtureDirectoryName => switch (this) { + schema => 'firebase_ai_native_schema', + jsonSchema => 'firebase_ai_native_json_schema', + }; + + String get sourceClass => switch (this) { + schema => 'Schema', + jsonSchema => 'JSONSchema', + }; +} + +enum FirebaseAiSchemaComparison { + exact('exact'), + equivalent('equivalent'), + adapterTransformNeeded('adapter_transform_needed'), + unsupportedByFirebaseSchema('unsupported_by_firebase_schema'), + backendLimited('backend_limited'); + + const FirebaseAiSchemaComparison(this.jsonValue); + + final String jsonValue; +} + +enum FirebaseAiNativeSchemaStatus { + generated('generated'), + unsupported('unsupported'); + + const FirebaseAiNativeSchemaStatus(this.jsonValue); + + final String jsonValue; +} + +final class FirebaseAiNativeSchemaCase { + const FirebaseAiNativeSchemaCase.generated({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.comparisonEnum, + required Map Function() buildJsonSchema, + }) : statusEnum = FirebaseAiNativeSchemaStatus.generated, + unsupportedReason = null, + _buildJsonSchema = buildJsonSchema; + + const FirebaseAiNativeSchemaCase.unsupported({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.comparisonEnum, + required this.unsupportedReason, + }) : statusEnum = FirebaseAiNativeSchemaStatus.unsupported, + _buildJsonSchema = null; + + final String id; + final String name; + final String source; + final List features; + final FirebaseAiNativeSchemaStatus statusEnum; + final FirebaseAiSchemaComparison comparisonEnum; + final String? unsupportedReason; + final Map Function()? _buildJsonSchema; + + String get status => statusEnum.jsonValue; + + String get comparison => comparisonEnum.jsonValue; + + bool get isGenerated => statusEnum == FirebaseAiNativeSchemaStatus.generated; + + Map buildJsonSchema() { + final build = _buildJsonSchema; + if (build == null) { + throw StateError('Native Firebase schema case $id is unsupported.'); + } + return build(); + } +} + +List firebaseAiNativeSchemaCases( + FirebaseAiNativeSchemaFixtureFamily family, +) { + return [ + for (final schemaCase in firebaseAiResponseJsonSchemaCases()) + switch (family) { + FirebaseAiNativeSchemaFixtureFamily.schema => _nativeSchemaCase( + schemaCase, + ), + FirebaseAiNativeSchemaFixtureFamily.jsonSchema => _nativeJsonSchemaCase( + schemaCase, + ), + }, + ]; +} + +String firebaseAiPackageVersion() { + final lockFile = _findPubspecLock(); + final lines = lockFile.readAsLinesSync(); + for (var index = 0; index < lines.length; index += 1) { + if (lines[index].trim() != 'firebase_ai:') continue; + + for ( + var versionIndex = index + 1; + versionIndex < lines.length; + versionIndex += 1 + ) { + final line = lines[versionIndex]; + if (line.startsWith(' ') && + !line.startsWith(' ') && + line.trim().endsWith(':')) { + break; + } + + final match = RegExp( + r'^\s+version:\s+"?([^"\s]+)"?\s*$', + ).firstMatch(line); + if (match != null) return match.group(1)!; + } + } + + throw StateError('Could not find firebase_ai version in ${lockFile.path}.'); +} + +FirebaseAiNativeSchemaCase _nativeSchemaCase( + FirebaseAiResponseJsonSchemaCase schemaCase, +) { + return switch (schemaCase.id) { + 'ack_schema_string_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(description: 'Code'), + ), + 'ack_schema_string_literal' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.enumString(enumValues: ['ready']), + ), + 'ack_schema_enum_values_default' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.enumString(enumValues: ['admin', 'member']), + ), + 'ack_schema_integer_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(minimum: 1, maximum: 10), + ), + 'ack_schema_number_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.number(minimum: 0.5, maximum: 9.5), + ), + 'ack_schema_nullable_boolean_default' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.boolean(nullable: true), + ), + 'ack_schema_array_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.array( + items: firebase_ai.Schema.string(format: 'uuid'), + minItems: 1, + maxItems: 3, + ), + ), + 'ack_schema_object_properties_requiredness' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.object( + description: 'User payload', + properties: { + 'name': firebase_ai.Schema.string(description: 'Full name'), + 'age': firebase_ai.Schema.integer(minimum: 0, maximum: 120), + 'role': firebase_ai.Schema.enumString( + enumValues: ['admin', 'member'], + ), + 'tags': firebase_ai.Schema.array( + items: firebase_ai.Schema.string(), + minItems: 1, + maxItems: 5, + ), + }, + optionalProperties: ['age', 'tags'], + propertyOrdering: ['name', 'age', 'role', 'tags'], + ), + ), + 'ack_schema_object_passthrough' => _unsupported( + schemaCase, + 'Firebase Schema cannot represent Ack.any() or open additional properties.', + ), + 'ack_schema_anyof_nullable_composition' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.equivalent, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.string(nullable: true), + firebase_ai.Schema.integer(nullable: true), + ], + ), + ), + 'ack_schema_generic_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(), + ), + 'ack_schema_any_json_compatible_branches' => _unsupported( + schemaCase, + 'Firebase Schema has no unconstrained JSON value representation.', + ), + 'ack_schema_date_transform_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'date'), + ), + 'ack_schema_datetime_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'date-time'), + ), + 'ack_schema_uri_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'uri'), + ), + 'ack_schema_duration_transform_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(minimum: 1000, maximum: 2000), + ), + 'ack_schema_discriminated_union' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeSchemaDiscriminatedUnion(), + ), + 'ack_schema_recursive_lazy_ref' => _unsupported( + schemaCase, + 'Firebase Schema does not support reusable definitions or references.', + ), + 'schema_model_string_common_options' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string( + title: 'Status', + description: 'Current status', + format: 'custom-format', + ), + ), + 'schema_model_integer_const_format' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(format: 'int32'), + ), + 'schema_model_number_const_format' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.number(format: 'double'), + ), + 'schema_model_boolean_const' => _unsupported( + schemaCase, + 'Firebase Schema cannot represent a boolean const value.', + ), + 'schema_model_nullable_default_extensions' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(nullable: true), + ), + 'schema_model_array_without_item_schema' => _unsupported( + schemaCase, + 'Firebase Schema.array requires an item schema.', + ), + 'schema_model_object_schema_additional_properties' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.object( + properties: {'id': firebase_ai.Schema.string()}, + propertyOrdering: ['id'], + ), + ), + 'schema_model_null' => _unsupported( + schemaCase, + 'Firebase Schema has no null-only schema type.', + ), + 'schema_model_anyof_common_fields_explicit_null' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.string(nullable: true), + firebase_ai.Schema.integer(nullable: true), + ], + ), + ), + 'schema_model_oneof_nullable_composition' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.enumString(enumValues: ['ready'], nullable: true), + firebase_ai.Schema.integer(minimum: 1, nullable: true), + ], + ), + ), + 'schema_model_oneof_discriminator' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeSchemaDiscriminatorUnion(), + ), + 'schema_model_allof' => _unsupported( + schemaCase, + 'Firebase Schema has no allOf composition builder.', + ), + _ => _unsupported( + schemaCase, + 'No Firebase Schema fixture mapping has been defined for ${schemaCase.id}.', + ), + }; +} + +FirebaseAiNativeSchemaCase _nativeJsonSchemaCase( + FirebaseAiResponseJsonSchemaCase schemaCase, +) { + return switch (schemaCase.id) { + 'ack_schema_string_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(description: 'Code'), + ), + 'ack_schema_string_literal' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.enumString(enumValues: ['ready']), + ), + 'ack_schema_enum_values_default' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.enumString(enumValues: ['admin', 'member']), + ), + 'ack_schema_integer_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(minimum: 1, maximum: 10), + ), + 'ack_schema_number_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.number(minimum: 0.5, maximum: 9.5), + ), + 'ack_schema_nullable_boolean_default' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.boolean(nullable: true), + ), + 'ack_schema_array_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.string(format: 'uuid'), + minItems: 1, + maxItems: 3, + ), + ), + 'ack_schema_object_properties_requiredness' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.object( + description: 'User payload', + properties: { + 'name': firebase_ai.JSONSchema.string(description: 'Full name'), + 'age': firebase_ai.JSONSchema.integer(minimum: 0, maximum: 120), + 'role': firebase_ai.JSONSchema.enumString( + enumValues: ['admin', 'member'], + ), + 'tags': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.string(), + minItems: 1, + maxItems: 5, + ), + }, + optionalProperties: ['age', 'tags'], + ), + ), + 'ack_schema_object_passthrough' => _unsupported( + schemaCase, + 'Firebase JSONSchema cannot represent Ack.any() or open additional properties.', + ), + 'ack_schema_anyof_nullable_composition' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.equivalent, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.string(nullable: true), + firebase_ai.JSONSchema.integer(nullable: true), + ], + ), + ), + 'ack_schema_generic_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(), + ), + 'ack_schema_any_json_compatible_branches' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no unconstrained JSON value representation.', + ), + 'ack_schema_date_transform_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'date'), + ), + 'ack_schema_datetime_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'date-time'), + ), + 'ack_schema_uri_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'uri'), + ), + 'ack_schema_duration_transform_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(minimum: 1000, maximum: 2000), + ), + 'ack_schema_discriminated_union' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeJsonSchemaDiscriminatedUnion(), + ), + 'ack_schema_recursive_lazy_ref' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.backendLimited, + _nativeJsonSchemaRecursiveCategory(), + ), + 'schema_model_string_common_options' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string( + title: 'Status', + description: 'Current status', + format: 'custom-format', + ), + ), + 'schema_model_integer_const_format' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(), + ), + 'schema_model_number_const_format' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.number(), + ), + 'schema_model_boolean_const' => _unsupported( + schemaCase, + 'Firebase JSONSchema cannot represent a boolean const value.', + ), + 'schema_model_nullable_default_extensions' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(nullable: true), + ), + 'schema_model_array_without_item_schema' => _unsupported( + schemaCase, + 'Firebase JSONSchema.array requires an item schema.', + ), + 'schema_model_object_schema_additional_properties' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.object( + properties: {'id': firebase_ai.JSONSchema.string()}, + ), + ), + 'schema_model_null' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no null-only schema type.', + ), + 'schema_model_anyof_common_fields_explicit_null' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.string(nullable: true), + firebase_ai.JSONSchema.integer(nullable: true), + ], + ), + ), + 'schema_model_oneof_nullable_composition' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.enumString( + enumValues: ['ready'], + nullable: true, + ), + firebase_ai.JSONSchema.integer(minimum: 1, nullable: true), + ], + ), + ), + 'schema_model_oneof_discriminator' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeJsonSchemaDiscriminatorUnion(), + ), + 'schema_model_allof' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no allOf composition builder.', + ), + _ => _unsupported( + schemaCase, + 'No Firebase JSONSchema fixture mapping has been defined for ${schemaCase.id}.', + ), + }; +} + +FirebaseAiNativeSchemaCase _generatedSchema( + FirebaseAiResponseJsonSchemaCase schemaCase, + FirebaseAiSchemaComparison comparison, + firebase_ai.Schema schema, +) { + return FirebaseAiNativeSchemaCase.generated( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: comparison, + buildJsonSchema: () => _jsonObject(schema.toJson()), + ); +} + +FirebaseAiNativeSchemaCase _generatedJsonSchema( + FirebaseAiResponseJsonSchemaCase schemaCase, + FirebaseAiSchemaComparison comparison, + firebase_ai.JSONSchema schema, +) { + return FirebaseAiNativeSchemaCase.generated( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: comparison, + buildJsonSchema: () => _jsonObject(schema.toJson()), + ); +} + +FirebaseAiNativeSchemaCase _unsupported( + FirebaseAiResponseJsonSchemaCase schemaCase, + String reason, +) { + return FirebaseAiNativeSchemaCase.unsupported( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: FirebaseAiSchemaComparison.unsupportedByFirebaseSchema, + unsupportedReason: reason, + ); +} + +firebase_ai.Schema _nativeSchemaDiscriminatedUnion() { + return firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['circle']), + 'radius': firebase_ai.Schema.number(minimum: 0), + }, + ), + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['square']), + 'side': firebase_ai.Schema.number(minimum: 0), + }, + ), + ], + ); +} + +firebase_ai.Schema _nativeSchemaDiscriminatorUnion() { + return firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['email']), + 'address': firebase_ai.Schema.string(format: 'email'), + }, + ), + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['sms']), + 'number': firebase_ai.Schema.string(), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaDiscriminatedUnion() { + return firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['circle']), + 'radius': firebase_ai.JSONSchema.number(minimum: 0), + }, + ), + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['square']), + 'side': firebase_ai.JSONSchema.number(minimum: 0), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaDiscriminatorUnion() { + return firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['email']), + 'address': firebase_ai.JSONSchema.string(format: 'email'), + }, + ), + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['sms']), + 'number': firebase_ai.JSONSchema.string(), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaRecursiveCategory() { + firebase_ai.JSONSchema categoryDefinition() => firebase_ai.JSONSchema.object( + properties: { + 'name': firebase_ai.JSONSchema.string(), + 'children': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + ), + 'featured': firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + }, + ); + + return firebase_ai.JSONSchema.object( + properties: { + 'name': firebase_ai.JSONSchema.string(), + 'children': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + ), + 'featured': firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + }, + defs: {'Category': categoryDefinition()}, + ); +} + +Map _jsonObject(Map value) { + return value.cast(); +} + +File _findPubspecLock() { + var current = Directory.current.absolute; + while (true) { + final lock = File('${current.path}/pubspec.lock'); + if (lock.existsSync()) return lock; + + final parent = current.parent; + if (parent.path == current.path) { + throw StateError( + 'Could not find pubspec.lock from ${Directory.current}.', + ); + } + current = parent; + } +} diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart new file mode 100644 index 00000000..56e9480f --- /dev/null +++ b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart @@ -0,0 +1,308 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'support/firebase_ai_native_schema_cases.dart'; +import 'support/firebase_ai_response_json_schema_cases.dart'; + +void main() { + group('Firebase AI native schema fixtures', () { + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + group(family.fixtureDirectoryName, () { + final fixtures = _FirebaseAiNativeSchemaFixtures.load(family); + + test('manifest tracks every case and feature', () { + final cases = firebaseAiNativeSchemaCases(family); + final sourceCases = firebaseAiResponseJsonSchemaCases(); + + expect(fixtures.sourcePackage, 'firebase_ai'); + expect(fixtures.sourceVersion, firebaseAiPackageVersion()); + expect(fixtures.sourceClass, family.sourceClass); + expect(fixtures.fixtureCount, cases.length); + expect(fixtures.ids, sourceCases.map((schemaCase) => schemaCase.id)); + + for (final schemaCase in cases) { + final fixture = fixtures.byId(schemaCase.id); + expect(fixture.name, schemaCase.name); + expect(fixture.source, schemaCase.source); + expect(fixture.features, schemaCase.features); + expect(fixture.status, schemaCase.status); + expect(fixture.comparison, schemaCase.comparison); + + if (schemaCase.isGenerated) { + expect(fixture.fixture, '${schemaCase.id}.json'); + expect(fixture.unsupportedReason, isNull); + } else { + expect(fixture.fixture, isNull); + expect(fixture.unsupportedReason, schemaCase.unsupportedReason); + } + } + + expect(fixtures.featureCoverage, _expectedFeatureCoverage(cases)); + }); + + for (final schemaCase in firebaseAiNativeSchemaCases(family)) { + test('${schemaCase.source} ${schemaCase.name}', () { + final fixture = fixtures.byId(schemaCase.id); + + if (schemaCase.isGenerated) { + final nativeSchema = schemaCase.buildJsonSchema(); + expect(fixture.jsonSchema, nativeSchema); + _expectJsonValue(nativeSchema); + expect(() => jsonEncode(nativeSchema), returnsNormally); + } else { + expect(fixture.jsonSchema, isNull); + final fixtureFile = File( + '${fixtures.fixtureDirectory.path}/${schemaCase.id}.json', + ); + expect(fixtureFile.existsSync(), isFalse); + } + }); + } + }); + } + }); +} + +Map> _expectedFeatureCoverage( + List cases, +) { + final expected = >{}; + for (final schemaCase in cases) { + for (final feature in schemaCase.features) { + expected.putIfAbsent(feature, () => []).add(schemaCase.id); + } + } + for (final ids in expected.values) { + ids.sort(); + } + return expected; +} + +void _expectJsonValue(Object? value, [String path = r'$']) { + if (value == null || value is String || value is num || value is bool) { + return; + } + + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _expectJsonValue(value[index], '$path[$index]'); + } + return; + } + + if (value is Map) { + for (final entry in value.entries) { + expect(entry.key, isA(), reason: '$path keys must be strings'); + _expectJsonValue(entry.value, '$path.${entry.key}'); + } + return; + } + + fail('Expected $path to be JSON-compatible, got ${value.runtimeType}.'); +} + +final class _FirebaseAiNativeSchemaFixtures { + const _FirebaseAiNativeSchemaFixtures({ + required this.sourcePackage, + required this.sourceVersion, + required this.sourceClass, + required this.ids, + required this.fixtureCount, + required this.featureCoverage, + required this.fixtureDirectory, + required Map fixtures, + }) : _fixtures = fixtures; + + final String sourcePackage; + final String sourceVersion; + final String sourceClass; + final List ids; + final int fixtureCount; + final Map> featureCoverage; + final Directory fixtureDirectory; + final Map _fixtures; + + _FirebaseAiNativeSchemaFixture byId(String id) { + final fixture = _fixtures[id]; + if (fixture == null) { + fail('Missing native Firebase AI fixture metadata for $id.'); + } + return fixture; + } + + static _FirebaseAiNativeSchemaFixtures load( + FirebaseAiNativeSchemaFixtureFamily family, + ) { + final packageRoot = _findPackageRoot(); + final fixtureDir = Directory( + '${packageRoot.path}/test/fixtures/${family.fixtureDirectoryName}', + ); + final manifestFile = File('${fixtureDir.path}/manifest.json'); + if (!manifestFile.existsSync()) { + fail( + 'Missing Firebase AI native fixture manifest. Run ' + 'dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart ' + 'from packages/ack_firebase_ai.', + ); + } + + final manifestJson = _decodeJsonObject(manifestFile); + final manifestFixtures = _jsonList(manifestJson['fixtures'], 'fixtures'); + final fixtures = {}; + final ids = []; + + for (final entry in manifestFixtures) { + final manifestFixture = _jsonObject(entry, 'fixtures[]'); + final id = _jsonString(manifestFixture['id'], 'fixtures[].id'); + final fixtureName = _jsonOptionalString( + manifestFixture['fixture'], + 'fixtures[].fixture', + ); + final fixtureFile = fixtureName == null + ? null + : File('${fixtureDir.path}/$fixtureName'); + + ids.add(id); + fixtures[id] = _FirebaseAiNativeSchemaFixture( + id: id, + name: _jsonString(manifestFixture['name'], 'fixtures[].name'), + source: _jsonString(manifestFixture['source'], 'fixtures[].source'), + features: _jsonStringList( + manifestFixture['features'], + 'fixtures[].features', + ), + status: _jsonString(manifestFixture['status'], 'fixtures[].status'), + comparison: _jsonString( + manifestFixture['comparison'], + 'fixtures[].comparison', + ), + unsupportedReason: _jsonOptionalString( + manifestFixture['unsupportedReason'], + 'fixtures[].unsupportedReason', + ), + fixture: fixtureName, + jsonSchema: fixtureFile == null ? null : _decodeJsonObject(fixtureFile), + ); + } + + return _FirebaseAiNativeSchemaFixtures( + sourcePackage: _jsonString( + manifestJson['sourcePackage'], + 'sourcePackage', + ), + sourceVersion: _jsonString( + manifestJson['sourceVersion'], + 'sourceVersion', + ), + sourceClass: _jsonString(manifestJson['sourceClass'], 'sourceClass'), + ids: ids, + fixtureCount: _jsonInt(manifestJson['fixtureCount'], 'fixtureCount'), + featureCoverage: _featureCoverageFromManifest(manifestJson), + fixtureDirectory: fixtureDir, + fixtures: fixtures, + ); + } +} + +final class _FirebaseAiNativeSchemaFixture { + const _FirebaseAiNativeSchemaFixture({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.status, + required this.comparison, + required this.unsupportedReason, + required this.fixture, + required this.jsonSchema, + }); + + final String id; + final String name; + final String source; + final List features; + final String status; + final String comparison; + final String? unsupportedReason; + final String? fixture; + final Map? jsonSchema; +} + +Directory _findPackageRoot() { + var current = Directory.current.absolute; + while (true) { + final pubspec = File('${current.path}/pubspec.yaml'); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return current; + } + + final nested = Directory('${current.path}/packages/ack_firebase_ai'); + final nestedPubspec = File('${nested.path}/pubspec.yaml'); + if (nestedPubspec.existsSync() && + nestedPubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return nested; + } + + final parent = current.parent; + if (parent.path == current.path) { + fail( + 'Could not find packages/ack_firebase_ai from ${Directory.current}.', + ); + } + current = parent; + } +} + +Map> _featureCoverageFromManifest( + Map manifestJson, +) { + final featureCoverage = _jsonObject( + manifestJson['featureCoverage'], + 'featureCoverage', + ); + return { + for (final entry in featureCoverage.entries) + entry.key: _jsonStringList(entry.value, 'featureCoverage.${entry.key}'), + }; +} + +Map _decodeJsonObject(File file) { + if (!file.existsSync()) { + fail('Missing fixture file: ${file.path}'); + } + return _jsonObject(jsonDecode(file.readAsStringSync()), file.path); +} + +Map _jsonObject(Object? value, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + fail('Expected $path to be a JSON object, got ${value.runtimeType}.'); +} + +List _jsonList(Object? value, String path) { + if (value is List) return value; + if (value is List) return value.cast(); + fail('Expected $path to be a JSON array, got ${value.runtimeType}.'); +} + +String _jsonString(Object? value, String path) { + if (value is String) return value; + fail('Expected $path to be a string, got ${value.runtimeType}.'); +} + +String? _jsonOptionalString(Object? value, String path) { + if (value == null) return null; + return _jsonString(value, path); +} + +int _jsonInt(Object? value, String path) { + if (value is int) return value; + fail('Expected $path to be an integer, got ${value.runtimeType}.'); +} + +List _jsonStringList(Object? value, String path) { + return [for (final item in _jsonList(value, path)) _jsonString(item, path)]; +} diff --git a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart index 3d386691..05fd11ce 100644 --- a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart +++ b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import '../test/support/firebase_ai_native_schema_cases.dart'; import '../test/support/firebase_ai_response_json_schema_cases.dart'; const _fixtureDirectory = 'test/fixtures/firebase_ai_response_json_schema'; @@ -8,7 +9,14 @@ const _manifestPath = '$_fixtureDirectory/manifest.json'; const _generatorCommand = 'dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart'; -Future main() async { +void main() { + _writeAckAdapterFixtures(); + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + _writeNativeFixtures(family); + } +} + +void _writeAckAdapterFixtures() { final fixtureDir = Directory(_fixtureDirectory); if (!fixtureDir.existsSync()) { fixtureDir.createSync(recursive: true); @@ -60,6 +68,71 @@ Future main() async { ); } +void _writeNativeFixtures(FirebaseAiNativeSchemaFixtureFamily family) { + final fixtureDirectory = 'test/fixtures/${family.fixtureDirectoryName}'; + final manifestPath = '$fixtureDirectory/manifest.json'; + final fixtureDir = Directory(fixtureDirectory); + if (!fixtureDir.existsSync()) { + fixtureDir.createSync(recursive: true); + } + + _deleteStaleFixtures(fixtureDir); + + final cases = firebaseAiNativeSchemaCases(family); + final featureCoverage = >{}; + final manifestFixtures = >[]; + + for (final schemaCase in cases) { + String? fixtureName; + if (schemaCase.isGenerated) { + fixtureName = '${schemaCase.id}.json'; + final fixturePath = '$fixtureDirectory/$fixtureName'; + File( + fixturePath, + ).writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); + } + + manifestFixtures.add({ + 'id': schemaCase.id, + 'name': schemaCase.name, + 'source': schemaCase.source, + 'features': schemaCase.features, + 'status': schemaCase.status, + 'comparison': schemaCase.comparison, + if (fixtureName != null) 'fixture': fixtureName, + if (schemaCase.unsupportedReason != null) + 'unsupportedReason': schemaCase.unsupportedReason, + }); + + for (final feature in schemaCase.features) { + featureCoverage.putIfAbsent(feature, () => []).add(schemaCase.id); + } + } + + final manifest = { + 'description': + 'Firebase AI ${family.sourceClass}.toJson() fixtures for native SDK ' + 'schema comparison.', + 'generatedBy': _generatorCommand, + 'sourcePackage': 'firebase_ai', + 'sourceVersion': firebaseAiPackageVersion(), + 'sourceClass': family.sourceClass, + 'fixtureCount': cases.length, + 'featureCoverage': { + for (final feature in featureCoverage.keys.toList()..sort()) + feature: featureCoverage[feature]!..sort(), + }, + 'fixtures': manifestFixtures, + }; + + File(manifestPath).writeAsStringSync('${_prettyJson(manifest)}\n'); + + stdout.writeln( + 'Wrote ${cases.length} Firebase AI native ${family.sourceClass} fixture ' + 'records to $fixtureDirectory.', + ); +} + void _deleteStaleFixtures(Directory fixtureDir) { for (final entry in fixtureDir.listSync()) { if (entry is File && entry.path.endsWith('.json')) { diff --git a/pubspec.yaml b/pubspec.yaml index a7851dbf..b25e295c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -147,7 +147,7 @@ melos: firebase-ai-fixtures: run: cd packages/ack_firebase_ai && dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart - description: Regenerate Firebase AI responseJsonSchema golden fixtures + description: Regenerate Firebase AI adapter and native schema fixtures test:golden: run: melos exec -c 1 -- dart test test/golden_test.dart