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
5 changes: 5 additions & 0 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* Expose the shared `deepUnmodifiableCopy` helper for generated immutable
constructors. It snapshots nested collections without validating data or
changing opaque noncollection objects.
Expand Down
142 changes: 123 additions & 19 deletions packages/ack/lib/src/json_schema/json_schema_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String, Object?>{};
final children = <String, _ImportedNode>{};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -122,7 +155,7 @@ final class _ImportedNode {
};
if (source case final Map<String, Object?> 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.',
Expand All @@ -132,7 +165,8 @@ final class _ImportedNode {
}
if (dynamicReference != null ||
dynamicAnchorName != null ||
(source is Map<String, Object?> &&
(isKeywordActive('format') &&
source is Map<String, Object?> &&
(source as Map<String, Object?>).containsKey('format'))) {
throw UnsupportedError(
'This imported schema needs 2020-12 semantics and cannot be lowered '
Expand Down Expand Up @@ -183,6 +217,8 @@ final class _JsonSchemaCompiler {
final resources = <Uri, _ImportedNode>{};
final anchors = <Uri, _ImportedNode>{};
final dynamicAnchors = <Uri, _ImportedNode>{};
final _pendingChildren = Queue<_ImportedNode>();
final _fallbackDocumentUris = <Uri>{};

static const mapKeywords = {
r'$defs',
Expand Down Expand Up @@ -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.');
}
Expand All @@ -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(),
Expand Down Expand Up @@ -336,30 +391,42 @@ 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<String, Object?>) 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<String, Object?>) {
_fail(node, key, 'Expected a map of schemas.');
}
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) {
Expand All @@ -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;
Comment thread
leoafarias marked this conversation as resolved.
}
_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) =>
Expand All @@ -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;
Expand Down Expand Up @@ -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]!;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -578,7 +681,7 @@ final class _JsonSchemaCompiler {
base,
dialect: resource.dialectUri,
);
_configureDialect(indexed);
_indexPendingChildren();
return indexed;
}

Expand All @@ -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',
Expand Down
45 changes: 0 additions & 45 deletions packages/ack/lib/src/schema_model/ack_schema_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -365,27 +365,6 @@ Map<String, Object?> _relocateRootPointers(
Set<String> retainedKeywords,
Set<Map<String, Object?>> 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) {
Expand Down Expand Up @@ -437,31 +416,7 @@ Map<String, Object?> _relocateRootPointers(
.replace(fragment: '/anyOf/0$fragment')
.toString();
}
for (final key in mapKeywords) {
if (rewritten[key] case final Map<String, Object?> 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;
Expand Down
Loading
Loading