Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
test/schemas/extensions/list_schema_extensions_test.dart \
test/utils/collection_utils_test.dart \
test/json_schema_import_test.dart \
test/json_schema_bundled_dialect_test.dart \
test/json_schema_vocabulary_contract_test.dart \
test/json_schema_import_api_test.dart

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
* Reject duration inputs that cannot preserve their original milliseconds.
* Preserve custom JSON Schema vocabulary semantics, inert annotation payloads,
explicit format assertions, and fluent null policies through export.
* Resolve custom dialects bundled under active schema keywords while leaving
unrelated annotation data untouched.
* Preserve schema-first alias metadata and strict/passthrough precedence;
diagnose unsupported shape transformations instead of emitting wrong fields.
* Resolve same-build value-model and getter references, nullable nested Sets,
Expand Down
3 changes: 3 additions & 0 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
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.
* Discover selected custom dialects bundled beneath their own active schema
keywords, without interpreting inactive keyword payloads or annotation data
as schemas.
* Expose the shared `deepUnmodifiableCopy` helper for generated immutable
constructors. It snapshots nested collections without validating data or
changing opaque noncollection objects.
Expand Down
124 changes: 118 additions & 6 deletions packages/ack/lib/src/json_schema/json_schema_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -460,12 +460,117 @@ final class _JsonSchemaCompiler {
_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);
// A selected dialect can be bundled beneath its own applicator keywords.
// Discover that resource before reporting an unresolved dependency, but
// only when its vocabulary makes the containing locations schemas.
if (!progressed &&
!_pendingChildren
.toList(growable: false)
.any(_discoverBundledDialect)) {
_configureDialect(_pendingChildren.first);
}
}
}

bool _discoverBundledDialect(_ImportedNode node) {
final wanted = node.dialectUri;
if (resources.containsKey(wanted)) return true;

bool visit(_ImportedNode parent, List<(_ImportedNode, String)> path) {
final source = parent.source;
if (source is! Map<String, Object?>) return false;

bool visitChild(Object? value, String keyword, String suffix) {
if (value is! Map<String, Object?>) return false;
var base = parent.baseUri;
var dialect = parent.dialectUri;
try {
if (value.containsKey(r'$id')) {
final id = value[r'$id'];
if (id is! String) return false;
base = base.resolve(id);
if (base.fragment.isNotEmpty) return false;
base = base.removeFragment();
}
if (value.containsKey(r'$schema')) {
final declared = value[r'$schema'];
if (declared is! String) return false;
dialect = base.resolve(declared);
if (dialect.fragment.isNotEmpty) return false;
dialect = dialect.removeFragment();
}
} on FormatException {
// Probing must not validate unrelated, potentially inert payloads.
return false;
}
final child = _ImportedNode(
value,
parent.documentUri,
'${parent.pointer}/$suffix',
base,
dialect,
);
final childPath = [...path, (parent, keyword)];
if (value.containsKey(r'$id') && base == wanted) {
if (value[r'$vocabulary'] is! Map<String, Object?>) return false;
for (final (scope, key) in childPath) {
if (scope.dialectUri != wanted &&
!resources.containsKey(scope.dialectUri) &&
!draft7Dialects.contains(scope.dialectUri)) {
return false;
}
_configureDialect(scope, bundledDialect: child);
if (!scope.isKeywordActive(key)) return false;
}
_index(
value,
child.documentUri,
child.pointer,
parent.baseUri,
dialect: parent.dialectUri,
);
return true;
}
return visit(child, childPath);
}

for (final entry in source.entries) {
final key = entry.key;
final value = entry.value;
if (mapKeywords.contains(key) && value is Map<String, Object?>) {
for (final child in value.entries) {
if (visitChild(
child.value,
key,
'$key/${escapeJsonPointerToken(child.key)}',
)) {
return true;
}
}
} else if (listKeywords.contains(key) && value is List) {
for (final (index, child) in value.indexed) {
if (visitChild(child, key, '$key/$index')) return true;
}
} else if (childKeywords.contains(key) && visitChild(value, key, key)) {
return true;
}
}
return false;
}

// Probe detached nodes so unsuccessful candidates cannot alter compilation.
return visit(
_ImportedNode(
node.source,
node.documentUri,
node.pointer,
node.baseUri,
node.dialectUri,
),
[],
);
}

Never _invalidSchema(_ImportedNode node, String key) =>
_fail(node, key, 'Expected a schema object or boolean.');

Expand Down Expand Up @@ -546,8 +651,10 @@ final class _JsonSchemaCompiler {
_checkProductiveCycles(nodes);
}

void _configureDialect(_ImportedNode node) {
final dialect = resources[node.dialectUri];
void _configureDialect(_ImportedNode node, {_ImportedNode? bundledDialect}) {
final dialect = bundledDialect?.baseUri == node.dialectUri
? bundledDialect
: resources[node.dialectUri];
if (dialect == null) {
if (draft7Dialects.contains(node.dialectUri)) {
node.applicatorVocabulary = true;
Expand Down Expand Up @@ -575,7 +682,12 @@ final class _JsonSchemaCompiler {
code: 'unsupported_dialect',
);
}
_checkRequiredVocabularies(node, r'$schema', vocabulary);
// A discovery candidate may live in an inactive keyword's annotation data.
// Check its requirements only after discovery proves it is a schema and
// normal indexing registers it as a resource.
if (bundledDialect == null) {
_checkRequiredVocabularies(node, r'$schema', vocabulary);
}
node.applicatorVocabulary = vocabulary.containsKey(applicatorVocabularyUri);
node.unevaluatedVocabulary = vocabulary.containsKey(
unevaluatedVocabularyUri,
Expand Down
176 changes: 176 additions & 0 deletions packages/ack/test/json_schema_bundled_dialect_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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/';
const _customDialect = 'https://example.test/meta/custom';

Map<String, Object?> _dialect({bool applicator = true}) => {
r'$id': _customDialect,
r'$schema': _standardDialect,
r'$vocabulary': {
'${_vocabularyPrefix}core': true,
if (applicator) '${_vocabularyPrefix}applicator': true,
'${_vocabularyPrefix}validation': true,
},
};

void _expectStringRoundTrip(Map<String, Object?> document) {
final schema = Ack.fromJsonSchema(document);
final exported = schema.toJsonSchema();
final restored = Ack.fromJsonSchema(exported);
for (final candidate in [schema, restored]) {
expect(candidate.safeParse('ok').isOk, isTrue);
expect(candidate.safeParse(1).isFail, isTrue);
expect(candidate.safeParse(null).isFail, isTrue);
}
expect(schema.toJsonSchema(), exported);
}

void main() {
for (final container in ['allOf', 'properties', 'items']) {
test('discovers a selected dialect bundled under $container', () {
_expectStringRoundTrip({
r'$schema': _customDialect,
'type': 'string',
container: switch (container) {
'allOf' => [_dialect()],
'properties' => {'meta': _dialect()},
_ => _dialect(),
},
});
});
}

test('resolves a bundled dialect through nested relative resource IDs', () {
_expectStringRoundTrip({
r'$id': 'https://example.test/root',
r'$schema': _customDialect,
'type': 'string',
'allOf': [
{
r'$id': 'meta/',
'allOf': [
{..._dialect(), r'$id': 'custom'},
],
},
],
});
});

test('honors a different dialect along the resource discovery path', () {
_expectStringRoundTrip({
r'$schema': _customDialect,
'type': 'string',
'allOf': [
{
r'$id': 'https://example.test/standard-resource',
r'$schema': _standardDialect,
// Enabled by this resource's dialect, but not the root's dialect.
'unevaluatedProperties': _dialect(),
},
],
});
});

test(
'discovery leaves unrelated inactive keywords and annotations inert',
() {
final annotation = {
r'$id': _customDialect,
r'$schema': 'https://example.test/not-supplied',
r'$ref': 'https://example.test/not-supplied',
};
final source = <String, Object?>{
r'$schema': _customDialect,
'type': 'string',
'default': annotation,
'contentSchema': annotation,
'unevaluatedProperties': {
..._dialect(),
r'$vocabulary': {
'${_vocabularyPrefix}core': true,
'https://example.test/unsupported-vocabulary': true,
},
},
'unevaluatedItems': {r'$id': _customDialect},
'allOf': [_dialect()],
};
_expectStringRoundTrip(source);
final exported = Ack.fromJsonSchema(source).toJsonSchema();
expect(exported['default'], annotation);
expect(exported['contentSchema'], annotation);
expect(
exported['unevaluatedProperties'],
source['unevaluatedProperties'],
);
expect(exported['unevaluatedItems'], source['unevaluatedItems']);
},
);

for (final container in ['default', 'examples', 'x-annotation']) {
test('does not discover dialect IDs inside $container data', () {
expect(
() => Ack.fromJsonSchema({
r'$schema': _customDialect,
container: container == 'examples' ? [_dialect()] : _dialect(),
}),
throwsA(isA<JsonSchemaImportException>()),
);
});
}

test('does not discover a dialect beneath a keyword it disables', () {
expect(
() => Ack.fromJsonSchema({
r'$schema': _customDialect,
'allOf': [_dialect(applicator: false)],
}),
throwsA(isA<JsonSchemaImportException>()),
);
});

test('reports duplicate active bundled dialect identifiers', () {
expect(
() => Ack.fromJsonSchema({
r'$schema': _customDialect,
'allOf': [_dialect(), _dialect()],
}),
throwsA(
isA<JsonSchemaImportException>().having(
(error) => error.toString(),
'diagnostic',
contains('Duplicate schema identifier'),
),
),
);
});

test(
'still rejects unknown required vocabularies of discovered dialects',
() {
expect(
() => Ack.fromJsonSchema({
r'$schema': _customDialect,
'allOf': [
{
..._dialect(),
r'$vocabulary': {
'${_vocabularyPrefix}core': true,
'${_vocabularyPrefix}applicator': true,
'https://example.test/unsupported-vocabulary': true,
},
},
],
}),
throwsA(
isA<JsonSchemaImportException>().having(
(error) => error.toString(),
'diagnostic',
contains('Unknown required vocabulary'),
),
),
);
},
);
}
Loading