From 5d55d341b405acc2dd9fa64669ae380d1b68f4d5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:30:02 -0400 Subject: [PATCH] fix(ack): preserve custom JSON Schema vocabulary semantics --- packages/ack/CHANGELOG.md | 5 + .../src/json_schema/json_schema_compiler.dart | 142 +++++- .../src/schema_model/ack_schema_model.dart | 45 -- .../ack_schema_model_builder.dart | 84 ++-- .../lib/src/schemas/imported_json_schema.dart | 91 +++- .../json_schema_vocabulary_contract_test.dart | 420 ++++++++++++++++++ 6 files changed, 669 insertions(+), 118 deletions(-) create mode 100644 packages/ack/test/json_schema_vocabulary_contract_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 8b2b69fe..2702d15c 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -10,6 +10,11 @@ without decoding runtime values again or adding encode callback passes. * Reject duration millisecond inputs that overflow the platform's `Duration` representation instead of silently decoding to a different duration. +* Respect custom JSON Schema vocabularies during import and export: keep + inactive keywords inert, preserve annotation payloads and explicit format + assertions, and apply fluent nullability under an active assertion dialect. + Identical caller-supplied standard meta-schemas can replace offline fallbacks + without relaxing duplicate-ID checks between caller resources. ## 2.0.0-alpha.1 diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 622a7374..7664a50e 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -26,6 +26,9 @@ final class _ImportedNode { bool applicatorVocabulary = true; bool unevaluatedVocabulary = true; bool validationVocabulary = true; + bool metadataVocabulary = true; + bool contentVocabulary = true; + bool formatAnnotationVocabulary = true; bool formatAssertion = false; final keywords = {}; final children = {}; @@ -80,6 +83,36 @@ final class _ImportedNode { List<_ImportedNode>? oneOf; List<_ImportedNode>? prefixItems; + /// Keywords outside the selected dialect retain only annotation semantics. + bool isKeywordActive(String keyword) { + if (keyword == r'$defs' || keyword == 'definitions') return true; + if (keyword == 'unevaluatedProperties' || keyword == 'unevaluatedItems') { + return unevaluatedVocabulary; + } + if (_JsonSchemaCompiler.mapKeywords.contains(keyword) || + _JsonSchemaCompiler.childKeywords.contains(keyword) || + _JsonSchemaCompiler.listKeywords.contains(keyword)) { + return applicatorVocabulary; + } + if (_JsonSchemaCompiler.validationKeywords.contains(keyword)) { + return validationVocabulary; + } + return switch (keyword) { + 'title' || + 'description' || + 'default' || + 'examples' || + 'readOnly' || + 'writeOnly' || + 'deprecated' => metadataVocabulary, + 'contentEncoding' || + 'contentMediaType' || + 'contentSchema' => contentVocabulary, + 'format' => formatAnnotationVocabulary || formatAssertion, + _ => true, + }; + } + List<_ImportedNode> get dependencies => [ ?reference, ?dynamicReference, @@ -122,7 +155,7 @@ final class _ImportedNode { }; if (source case final Map document) { for (final keyword in unsupportedKeywords) { - if (document.containsKey(keyword)) { + if (isKeywordActive(keyword) && document.containsKey(keyword)) { throw UnsupportedError( 'Cannot lower "$keyword" at ${_keywordPointer(this, keyword)} ' 'with toJsonSchemaDraft7(); use toJsonSchema() for 2020-12 semantics.', @@ -132,7 +165,8 @@ final class _ImportedNode { } if (dynamicReference != null || dynamicAnchorName != null || - (source is Map && + (isKeywordActive('format') && + source is Map && (source as Map).containsKey('format'))) { throw UnsupportedError( 'This imported schema needs 2020-12 semantics and cannot be lowered ' @@ -183,6 +217,8 @@ final class _JsonSchemaCompiler { final resources = {}; final anchors = {}; final dynamicAnchors = {}; + final _pendingChildren = Queue<_ImportedNode>(); + final _fallbackDocumentUris = {}; static const mapKeywords = { r'$defs', @@ -242,8 +278,26 @@ final class _JsonSchemaCompiler { 'exclusiveMinimum', 'exclusiveMaximum', }; + static const validationKeywords = { + ...counts, + ...bounds, + 'type', + 'const', + 'enum', + 'multipleOf', + 'minContains', + 'maxContains', + 'uniqueItems', + 'pattern', + 'required', + 'dependentRequired', + }; - _ImportedNode addDocument(Object document, Uri uri) { + _ImportedNode addDocument( + Object document, + Uri uri, { + bool isFallback = false, + }) { if (uri.hasFragment && uri.fragment.isNotEmpty) { throw ArgumentError.value(uri, 'baseUri', 'Must not contain a fragment.'); } @@ -268,6 +322,7 @@ final class _JsonSchemaCompiler { 'Conflicting schema documents for retrieval URI: ${uri.removeFragment()}.', ); } + if (isFallback) _fallbackDocumentUris.add(uri.removeFragment()); return _index( copy!, uri.removeFragment(), @@ -336,10 +391,22 @@ final class _JsonSchemaCompiler { _register(dynamicAnchors, uri, node, r'$dynamicAnchor'); _register(anchors, uri, node, r'$dynamicAnchor'); } + // Core definition locations cannot be disabled. Register them now so a + // bundled dialect can be found before interpreting optional keywords. + _indexChildren(node, coreOnly: true); + _pendingChildren.add(node); + return node; + } + + void _indexChildren(_ImportedNode node, {required bool coreOnly}) { + final source = node.source; + if (source is! Map) return; for (final entry in source.entries) { final key = entry.key; + final coreLocation = key == r'$defs' || key == 'definitions'; + if (coreLocation != coreOnly || !node.isKeywordActive(key)) continue; final value = entry.value; - final path = '$pointer/${escapeJsonPointerToken(key)}'; + final path = '${node.pointer}/${escapeJsonPointerToken(key)}'; if (mapKeywords.contains(key)) { if (value is! Map) { _fail(node, key, 'Expected a map of schemas.'); @@ -347,19 +414,19 @@ final class _JsonSchemaCompiler { for (final child in value.entries) { _index( child.value ?? _invalidSchema(node, key), - document, + node.documentUri, '$path/${escapeJsonPointerToken(child.key)}', - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } } else if (childKeywords.contains(key)) { _index( value ?? _invalidSchema(node, key), - document, + node.documentUri, path, - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } else if (listKeywords.contains(key)) { if (value is! List || value.isEmpty) { @@ -368,15 +435,35 @@ final class _JsonSchemaCompiler { for (final (i, child) in value.indexed) { _index( child ?? _invalidSchema(node, key), - document, + node.documentUri, '$path/$i', - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } } } - return node; + } + + void _indexPendingChildren() { + while (_pendingChildren.isNotEmpty) { + final count = _pendingChildren.length; + var progressed = false; + for (var i = 0; i < count; i++) { + final node = _pendingChildren.removeFirst(); + if (!resources.containsKey(node.dialectUri) && + !draft7Dialects.contains(node.dialectUri)) { + _pendingChildren.add(node); + continue; + } + _configureDialect(node); + _indexChildren(node, coreOnly: false); + progressed = true; + } + // Other documents may have supplied a nested dialect during this pass. + // If none could be indexed, report the unresolved dialect as before. + if (!progressed) _configureDialect(_pendingChildren.first); + } } Never _invalidSchema(_ImportedNode node, String key) => @@ -390,7 +477,16 @@ final class _JsonSchemaCompiler { ) { if (registry[uri] case final prior?) { if (!identical(prior, node)) { - _fail(node, keyword, 'Duplicate schema identifier: $uri.'); + // Delayed optional-keyword indexing can discover an explicitly supplied + // copy after the offline fallback was registered. Only an identical + // fallback may be replaced; two caller-owned identifiers still conflict. + final replacesFallback = + _fallbackDocumentUris.contains(prior.documentUri) && + !_fallbackDocumentUris.contains(node.documentUri) && + deepEquals(prior.source, node.source); + if (!replacesFallback) { + _fail(node, keyword, 'Duplicate schema identifier: $uri.'); + } } } registry[uri] = node; @@ -436,9 +532,7 @@ final class _JsonSchemaCompiler { } void compile(_ImportedNode root) { - for (final node in locations.values) { - _configureDialect(node); - } + _indexPendingChildren(); final nodes = _compileReachable(root); for (final node in locations.values) { node.resourceRoot = resources[node.baseUri]!; @@ -487,6 +581,15 @@ final class _JsonSchemaCompiler { unevaluatedVocabularyUri, ); node.validationVocabulary = vocabulary.containsKey(validationVocabularyUri); + node.metadataVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/meta-data', + ); + node.contentVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/content', + ); + node.formatAnnotationVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/format-annotation', + ); node.formatAssertion = assertFormats || vocabulary.containsKey(formatAssertionVocabularyUri); } @@ -578,7 +681,7 @@ final class _JsonSchemaCompiler { base, dialect: resource.dialectUri, ); - _configureDialect(indexed); + _indexPendingChildren(); return indexed; } @@ -588,6 +691,7 @@ final class _JsonSchemaCompiler { for (final entry in source.entries) { final key = entry.key; final value = entry.value; + if (!node.isKeywordActive(key)) continue; if (const { r'$id', r'$schema', 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 85e8b02b..d9f1d582 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -365,27 +365,6 @@ Map _relocateRootPointers( Set retainedKeywords, Set> indexedSchemas, ) { - const mapKeywords = { - r'$defs', - 'definitions', - 'properties', - 'patternProperties', - 'dependentSchemas', - }; - const childKeywords = { - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'if', - 'then', - 'else', - 'unevaluatedProperties', - 'unevaluatedItems', - }; - const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; - late Object? Function(Object?, Uri) visit; Object? visitExtension(Object? value, Uri base) { @@ -437,31 +416,7 @@ Map _relocateRootPointers( .replace(fragment: '/anyOf/0$fragment') .toString(); } - for (final key in mapKeywords) { - if (rewritten[key] case final Map children) { - rewritten[key] = children.map( - (name, child) => MapEntry(name, visit(child, currentBase)), - ); - } - } - for (final key in childKeywords) { - if (rewritten.containsKey(key)) { - rewritten[key] = visit(rewritten[key], currentBase); - } - } - for (final key in listKeywords) { - if (rewritten[key] case final List children) { - rewritten[key] = [ - for (final child in children) visit(child, currentBase), - ]; - } - } for (final key in rewritten.keys.toList(growable: false)) { - if (mapKeywords.contains(key) || - childKeywords.contains(key) || - listKeywords.contains(key)) { - continue; - } rewritten[key] = visitExtension(rewritten[key], currentBase); } return rewritten; 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 e9b731d3..76c448f4 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 @@ -103,9 +103,14 @@ final class _SchemaModelBuilder { return merged; } - AckSchemaModel _build(AckSchema schema) { + AckSchemaModel _build( + AckSchema schema, { + bool requireStandardDialect = false, + }) { if (schema is WrapperSchema) { - final base = _build(schema.inner); + // Wrappers can add null operators or projected constraints. Keep those + // outside a source dialect that may have disabled their vocabularies. + final base = _build(schema.inner, requireStandardDialect: true); var layered = base .withDescription(schema.description ?? base.description) .withNullable(schema.isNullable); @@ -168,7 +173,10 @@ final class _SchemaModelBuilder { InstanceSchema() => _instance(schema), DiscriminatedObjectSchema() => _discriminated(schema), LazySchema() => _lazy(schema), - ImportedJsonSchema() => _imported(schema), + ImportedJsonSchema() => _imported( + schema, + requireStandardDialect: requireStandardDialect, + ), _ => throw UnsupportedError( 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', ), @@ -177,11 +185,19 @@ final class _SchemaModelBuilder { return schema is LazySchema ? model : _applyConstraints(model, schema); } - AckSchemaModel _imported(ImportedJsonSchema schema) { + AckSchemaModel _imported( + ImportedJsonSchema schema, { + required bool requireStandardDialect, + }) { final importIndex = _importCount++; if (preserveImportedDialect) { final indexedSchemas = HashSet>.identity(); - if (_composingImports) { + final needsAssertionEnvelope = + schema.needsStandardAssertionEnvelope && + (requireStandardDialect || + schema.isNullable != schema.sourceAllowsNull || + schema.constraints.isNotEmpty); + if (_composingImports || needsAssertionEnvelope) { final document = schema.export2020Document( importIndex: importIndex, includeFluentNullability: false, @@ -279,6 +295,27 @@ final class _SchemaModelBuilder { final base = Uri.parse(document[r'$id']! as String); index(document, base, []); + + Uri canonicalLocation(Uri reference) { + if (locations[reference] case final exact?) return exact; + // Boolean schemas have no map identity to index. Their pointer still + // follows the canonical resource of the nearest indexed ancestor. + var prefix = reference.fragment; + while (prefix.startsWith('/')) { + prefix = prefix.substring(0, prefix.lastIndexOf('/')); + final ancestor = prefix.isEmpty + ? reference.removeFragment() + : reference.replace(fragment: prefix); + if (locations[ancestor] case final canonical?) { + return canonical.replace( + fragment: + '${canonical.fragment}${reference.fragment.substring(prefix.length)}', + ); + } + } + return reference; + } + Object? normalize(Object? value, Uri base) { if (value is! Map) return value; final id = value[r'$id']; @@ -308,7 +345,7 @@ final class _SchemaModelBuilder { // Dormant malformed references are not evaluated by the importer. continue; } - final canonical = locations[resolved] ?? resolved; + final canonical = canonicalLocation(resolved); final address = _resourceLocations[canonical.removeFragment()]; if (address != null && (canonical.fragment.isEmpty || @@ -797,41 +834,8 @@ Map _mapImportedChildren( return value; } - const maps = { - r'$defs', - 'definitions', - 'properties', - 'patternProperties', - 'dependentSchemas', - }; - const children = { - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'if', - 'then', - 'else', - 'unevaluatedProperties', - 'unevaluatedItems', - }; - const lists = {'allOf', 'anyOf', 'oneOf', 'prefixItems'}; return { for (final entry in schema.entries) - entry.key: maps.contains(entry.key) && entry.value is Map - ? { - for (final child - in (entry.value! as Map).entries) - child.key: visit(child.value, [entry.key, child.key]), - } - : children.contains(entry.key) - ? visit(entry.value, [entry.key]) - : lists.contains(entry.key) && entry.value is List - ? [ - for (final (index, child) in (entry.value! as List).indexed) - visit(child, [entry.key, '$index']), - ] - : visitIndexed(entry.value, [entry.key]), + entry.key: visitIndexed(entry.value, [entry.key]), }; } diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index edf13037..d9af8563 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -31,7 +31,7 @@ final class ImportedJsonSchema extends AckSchema } for (final entry in _standardMetaSchemaDocuments.entries) { if (!compiler.resources.containsKey(entry.key)) { - compiler.addDocument(entry.value, entry.key); + compiler.addDocument(entry.value, entry.key, isFallback: true); } } compiler.compile(root); @@ -112,6 +112,44 @@ final class ImportedJsonSchema extends AckSchema _ => value, }; + final dialectResources = { + for (final node in _indexedNodes) + if (identical(node, node.resourceRoot)) node.baseUri: node, + for (final node in _indexedNodes) + if (node.pointer == '#') node.documentUri: node, + }; + final customAssertionDialects = + vocabulary})>{}; + + Uri? assertionDialectFor(_ImportedNode node) { + if (!assertFormats) return null; + if (node.dialectUri == _JsonSchemaCompiler.standardDialect || + _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri)) { + return _assertionDialect; + } + final dialect = dialectResources[node.dialectUri]!; + final vocabulary = + (dialect.source as Map)[r'$vocabulary']! + as Map; + if (vocabulary.containsKey( + _JsonSchemaCompiler.formatAssertionVocabularyUri, + )) { + return null; + } + return customAssertionDialects.putIfAbsent(dialect.baseUri, () { + final token = base64Url + .encode(utf8.encode(dialect.baseUri.toString())) + .replaceAll('=', ''); + return ( + id: Uri.parse('$_assertionDialect:$token'), + vocabulary: { + ...vocabulary, + _JsonSchemaCompiler.formatAssertionVocabularyUri: true, + }, + ); + }).id; + } + Map resource(_ImportedNode node, {Uri? syntheticId}) { final source = copyJson(node.source); // Edit only indexed schema locations, never annotation payloads. This @@ -129,13 +167,10 @@ final class ImportedJsonSchema extends AckSchema } if (exported is! Map) continue; onSchema?.call(exported); - if (assertFormats && - (indexed.dialectUri == _JsonSchemaCompiler.standardDialect || - _JsonSchemaCompiler.draft7Dialects.contains( - indexed.dialectUri, - )) && - (identical(indexed, node) || exported.containsKey(r'$schema'))) { - exported[r'$schema'] = _assertionDialect.toString(); + if (identical(indexed, node) || exported.containsKey(r'$schema')) { + if (assertionDialectFor(indexed) case final dialect?) { + exported[r'$schema'] = dialect.toString(); + } } // A retrieval URI is an address for the original document, not a // resource that can be replaced by a $ref-only alias. Resolve it to @@ -164,15 +199,15 @@ final class ImportedJsonSchema extends AckSchema } } } - return { + final exportedResource = { if (source is Map) ...source, r'$id': (syntheticId ?? node.baseUri).toString(), - if (assertFormats && - (node.dialectUri == _JsonSchemaCompiler.standardDialect || - _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri))) - r'$schema': _assertionDialect.toString(), + if (assertionDialectFor(node) case final dialect?) + r'$schema': dialect.toString(), if (source is bool) 'allOf': [source], }; + onSchema?.call(exportedResource); + return exportedResource; } final syntheticId = @@ -203,7 +238,7 @@ final class ImportedJsonSchema extends AckSchema while (definitions.containsKey(name)) { name = '${name}_'; } - definitions[name] = { + final standardAssertionDialect = { r'$id': _assertionDialect.toString(), r'$schema': _JsonSchemaCompiler.standardDialect.toString(), r'$vocabulary': { @@ -224,6 +259,29 @@ final class ImportedJsonSchema extends AckSchema {r'$ref': 'https://json-schema.org/draft/2020-12/meta/$name'}, ], }; + onSchema?.call(standardAssertionDialect); + definitions[name] = standardAssertionDialect; + var customIndex = 0; + for (final entry in customAssertionDialects.entries) { + var name = '_ack_format_assertion_custom_${customIndex++}'; + while (definitions.containsKey(name)) { + name = '${name}_'; + } + // Dialect vocabularies are not inherited through $ref. Repeat the + // original map, adding only the requested format assertion, while + // retaining the original meta-schema's constraints and identity. + final customAssertionDialect = { + r'$id': entry.value.id.toString(), + r'$schema': _JsonSchemaCompiler.standardDialect.toString(), + r'$vocabulary': entry.value.vocabulary, + r'$dynamicAnchor': 'meta', + 'allOf': [ + {r'$ref': entry.key.toString()}, + ], + }; + onSchema?.call(customAssertionDialect); + definitions[name] = customAssertionDialect; + } } root[r'$defs'] = definitions; } @@ -243,6 +301,11 @@ final class ImportedJsonSchema extends AckSchema @internal bool get sourceAllowsNull => _checkImportedNode(_root, null) == null; + /// Whether generated applicator/validation assertions need an outer dialect. + @internal + bool get needsStandardAssertionEnvelope => + !_root.applicatorVocabulary || !_root.validationVocabulary; + @override @protected SchemaResult validateRuntimeWithContext( diff --git a/packages/ack/test/json_schema_vocabulary_contract_test.dart b/packages/ack/test/json_schema_vocabulary_contract_test.dart new file mode 100644 index 00000000..73a19727 --- /dev/null +++ b/packages/ack/test/json_schema_vocabulary_contract_test.dart @@ -0,0 +1,420 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +const _standardDialect = 'https://json-schema.org/draft/2020-12/schema'; +const _vocabularyPrefix = 'https://json-schema.org/draft/2020-12/vocab/'; +final _dialectUri = Uri.parse('https://example.test/meta/custom'); + +Map _dialect(List vocabularies) => { + r'$id': _dialectUri.toString(), + r'$schema': _standardDialect, + r'$vocabulary': { + '${_vocabularyPrefix}core': true, + for (final vocabulary in vocabularies) + '$_vocabularyPrefix$vocabulary': true, + }, +}; + +AckSchema _import( + Map source, { + List vocabularies = const ['validation'], + bool assertFormats = false, +}) => Ack.fromJsonSchema( + {r'$schema': _dialectUri.toString(), ...source}, + documents: {_dialectUri: _dialect(vocabularies)}, + assertFormats: assertFormats, +); + +Iterable> _maps(Object? value) sync* { + if (value is Map) { + yield value; + for (final child in value.values) { + yield* _maps(child); + } + } else if (value is List) { + for (final child in value) { + yield* _maps(child); + } + } +} + +const _officialFormatAnnotationMetaSchema = { + r'$schema': _standardDialect, + r'$id': 'https://json-schema.org/draft/2020-12/meta/format-annotation', + r'$dynamicAnchor': 'meta', + 'title': 'Format vocabulary meta-schema for annotation results', + 'type': ['object', 'boolean'], + 'properties': { + 'format': {'type': 'string'}, + }, +}; + +void main() { + group('inactive vocabulary keywords are annotations', () { + final applicatorCases = { + 'properties': false, + 'patternProperties': 'annotation', + 'dependentSchemas': 42, + 'items': 'annotation', + 'additionalProperties': [], + 'not': 42, + 'contains': 'annotation', + 'propertyNames': null, + 'if': [], + 'then': null, + 'else': 'annotation', + 'allOf': [], + 'anyOf': false, + 'oneOf': 'annotation', + 'prefixItems': {}, + }; + for (final entry in applicatorCases.entries) { + test('does not interpret inactive ${entry.key}', () { + final schema = _import({'type': 'string', entry.key: entry.value}); + expect(schema.safeParse('ok').isOk, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + final exported = schema.toJsonSchema(); + expect(exported[entry.key], entry.value); + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse('ok').isOk, isTrue); + expect(restored.safeParse(1).isFail, isTrue); + }); + } + + test( + 'does not register identifiers or resolve references in annotations', + () { + final annotation = { + 'notASchema': { + r'$id': 'https://example.test/real', + r'$ref': 'https://example.test/not-supplied', + }, + 'alsoNotASchema': {r'$id': 42}, + }; + final schema = _import({ + 'type': 'string', + 'properties': annotation, + r'$defs': { + 'real': {r'$id': 'https://example.test/real', 'type': 'string'}, + }, + }); + expect(schema.parse('ok'), 'ok'); + expect(schema.toJsonSchema()['properties'], annotation); + }, + ); + + test( + 'does not validate inactive validation, metadata, or content syntax', + () { + final source = { + 'type': 42, + 'enum': 'annotation', + 'minimum': false, + 'multipleOf': 0, + 'minLength': 'annotation', + 'required': false, + 'dependentRequired': [], + 'uniqueItems': 'annotation', + 'description': 42, + 'readOnly': 'annotation', + 'examples': false, + 'format': false, + 'contentSchema': 'annotation', + 'unevaluatedProperties': 'annotation', + 'unevaluatedItems': 42, + }; + final schema = _import(source, vocabularies: []); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final value in [null, 1, 'text', {}, []]) { + expect(schema.safeParse(value).isOk, isTrue); + expect(restored.safeParse(value).isOk, isTrue); + } + for (final entry in source.entries) { + expect(schema.toJsonSchema()[entry.key], entry.value); + } + }, + ); + }); + + test('still validates the syntax of active vocabularies', () { + for (final testCase in [ + ( + vocabulary: 'applicator', + source: {'properties': false}, + ), + (vocabulary: 'validation', source: {'minLength': 'bad'}), + (vocabulary: 'meta-data', source: {'description': 42}), + (vocabulary: 'content', source: {'contentSchema': 42}), + ( + vocabulary: 'unevaluated', + source: {'unevaluatedItems': 'bad'}, + ), + ( + vocabulary: 'format-annotation', + source: {'format': 42}, + ), + ]) { + expect( + () => _import(testCase.source, vocabularies: [testCase.vocabulary]), + throwsA(isA()), + ); + } + expect( + () => _import({'format': 42}, vocabularies: [], assertFormats: true), + throwsA(isA()), + ); + }); + + test( + 'core definitions remain schemas regardless of optional vocabularies', + () { + expect( + () => _import({ + r'$defs': {'invalid': 42}, + }, vocabularies: []), + throwsA(isA()), + ); + }, + ); + + test('explicit references can target schemas inside inert containers', () { + final schema = _import({ + r'$ref': '#/properties/target', + 'properties': { + 'target': {'type': 'string', 'minLength': 2}, + }, + }); + expect(schema.safeParse('ok').isOk, isTrue); + expect(schema.safeParse('x').isFail, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + }); + + test('a dialect can be indexed after the schema that selects it', () { + final catalogUri = Uri.parse('https://example.test/catalog'); + for (final container in [r'$defs', 'properties']) { + final schema = Ack.fromJsonSchema( + { + r'$schema': _dialectUri.toString(), + 'type': 'string', + 'properties': false, + }, + documents: { + catalogUri: { + container: { + 'customDialect': _dialect(['validation']), + }, + }, + }, + ); + expect(schema.parse('ok'), 'ok'); + expect(schema.safeParse(1).isFail, isTrue); + } + }); + + test('nullable export does not rewrite references in inert containers', () { + const id = 'https://example.test/nullable-source'; + final annotation = { + 'notASchema': {r'$ref': '#'}, + }; + final schema = _import({ + r'$id': id, + 'type': 'string', + 'properties': annotation, + }).nullable(); + final resource = _maps( + schema.toJsonSchema(), + ).where((map) => map[r'$id'] == id).single; + // The source may be enveloped, but its annotation payload is unchanged. + final properties = + resource['properties'] ?? + _maps( + resource, + ).firstWhere((map) => map.containsKey('properties'))['properties']; + expect(properties, annotation); + }); + + test('composed export does not register or rewrite inert resource IDs', () { + const id = 'https://example.test/composed-source'; + const realId = 'https://example.test/real-resource'; + final annotation = { + 'notASchema': {r'$id': realId, r'$ref': '#', 'type': 'integer'}, + }; + final schema = Ack.object({ + 'value': _import({ + r'$id': id, + 'type': 'string', + 'properties': annotation, + r'$defs': { + 'real': {r'$id': realId, 'type': 'string'}, + }, + }), + }); + final exported = schema.toJsonSchema(); + final resource = _maps(exported).where((map) => map[r'$id'] == id).single; + expect(resource['properties'], annotation); + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse({'value': 'ok'}).isOk, isTrue); + expect(restored.safeParse({'value': 1}).isFail, isTrue); + }); + + test('fluent null policies use operators from an active dialect', () { + final widened = _import({'type': 'string'}).nullable(); + final narrowed = _import({}, vocabularies: []).nullable(value: false); + final wrapped = Ack.preserveBoundary( + _import({}, vocabularies: []), + ).nullable(value: false); + final defaulted = _import({ + 'type': ['string', 'null'], + }).withDefault('fallback'); + for (final schema in [widened, narrowed, wrapped, defaulted]) { + for (final exported in [ + schema.toJsonSchema(), + schema.toJsonSchemaDraft7(), + ]) { + final restored = Ack.fromJsonSchema(exported); + for (final value in [null, 'ok', 42, {}]) { + expect( + restored.safeParse(value).isOk, + schema.safeParse(value).isOk, + reason: '$value exported as $exported', + ); + } + } + } + }); + + test( + 'inactive modern keywords do not block semantics-preserving Draft-7', + () { + final schema = _import({ + 'type': 'string', + 'prefixItems': 'annotation', + 'unevaluatedItems': false, + 'format': 42, + }); + final restored = Ack.fromJsonSchema(schema.toJsonSchemaDraft7()); + expect(restored.safeParse('ok').isOk, isTrue); + expect(restored.safeParse(1).isFail, isTrue); + }, + ); + + test('identical supplied official resources precede bundled fallbacks', () { + for (final container in [r'$defs', 'properties']) { + final schema = Ack.fromJsonSchema( + {r'$ref': _officialFormatAnnotationMetaSchema[r'$id']}, + documents: { + Uri.parse('https://example.test/catalog'): { + container: {'official': _officialFormatAnnotationMetaSchema}, + }, + }, + ); + expect(schema.safeParse({'format': 'email'}).isOk, isTrue); + expect(schema.safeParse({'format': 42}).isFail, isTrue); + } + }); + + test( + 'caller resource conflicts still fail instead of replacing fallbacks', + () { + for (final resources in [ + { + 'different': { + ..._officialFormatAnnotationMetaSchema, + 'title': 'A different definition using the same official ID', + }, + }, + { + 'first': _officialFormatAnnotationMetaSchema, + 'second': _officialFormatAnnotationMetaSchema, + }, + ]) { + expect( + () => Ack.fromJsonSchema( + {'type': 'string'}, + documents: { + Uri.parse('https://example.test/catalog'): { + 'properties': resources, + }, + }, + ), + throwsA(isA()), + ); + } + }, + ); + + test('custom format assertion exports add only the requested vocabulary', () { + for (final validation in [false, true]) { + final schema = _import( + { + 'type': validation ? 'string' : 42, + 'format': 'email', + 'allOf': [false], + 'properties': false, + }, + vocabularies: [if (validation) 'validation'], + assertFormats: true, + ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final candidate in [ + (value: 'alice@example.com', valid: true), + (value: 'not-an-email', valid: false), + (value: 42, valid: !validation), + ]) { + expect(schema.safeParse(candidate.value).isOk, candidate.valid); + expect(restored.safeParse(candidate.value).isOk, candidate.valid); + } + } + }); + + test('custom assertion dialects retain aliases and compose consistently', () { + const canonical = 'https://example.test/meta/canonical'; + final retrieval = Uri.parse('https://example.test/meta/retrieval'); + const originalVocabulary = { + '${_vocabularyPrefix}core': true, + '${_vocabularyPrefix}validation': true, + '${_vocabularyPrefix}format-annotation': false, + }; + final schema = Ack.fromJsonSchema( + { + r'$id': 'https://example.test/aliased-source', + r'$schema': retrieval.toString(), + 'type': 'string', + 'format': 'email', + 'properties': false, + }, + documents: { + retrieval: { + r'$id': canonical, + r'$schema': _standardDialect, + r'$vocabulary': originalVocabulary, + }, + }, + assertFormats: true, + ); + final exported = schema.toJsonSchema(); + expect(schema.toJsonSchema(), exported); + final originalDialect = _maps( + exported, + ).where((map) => map[r'$id'] == canonical).single; + expect(originalDialect[r'$vocabulary'], originalVocabulary); + + for (final testCase in [ + ( + schema: Ack.fromJsonSchema(exported), + valid: 'alice@example.com', + invalid: 'not-an-email', + ), + ( + schema: Ack.fromJsonSchema( + Ack.object({'first': schema, 'second': schema}).toJsonSchema(), + ), + valid: {'first': 'alice@example.com', 'second': 'bob@example.com'}, + invalid: {'first': 'alice@example.com', 'second': 'not-an-email'}, + ), + ]) { + expect(testCase.schema.safeParse(testCase.valid).isOk, isTrue); + expect(testCase.schema.safeParse(testCase.invalid).isFail, isTrue); + } + }); +}