diff --git a/.agent/skills/genui-helper/SKILL.md b/.agent/skills/genui-helper/SKILL.md index e1e10efd9..45d128140 100644 --- a/.agent/skills/genui-helper/SKILL.md +++ b/.agent/skills/genui-helper/SKILL.md @@ -60,4 +60,12 @@ When creating a new UI component in `genui`: - Example: https://dart.dev/tools/diagnostics/ambiguous_import - To find out details of a specific analyzer lint message, use the following url format to look up the details: - https://dart.dev/tools/linter-rules/ - - Example: https://dart.dev/tools/linter-rules/always_declare_return_types \ No newline at end of file + - Example: https://dart.dev/tools/linter-rules/always_declare_return_types + +## Code visibility + +Make every code element as private as it can be. If tests need access, use the +language's test-visibility mechanism instead of making it public. +For example, in Dart, keep +the `_` prefix and annotate with `@visibleForTesting`. + diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..c7fb70a34 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,9 @@ +# Claude Code Context + +Repository skills live in `.agent/skills/`, shared with the other agent tools +this repo supports. Claude Code only discovers skills under `.claude/skills/`, +so symlink the skills: + +```bash +ln -s ../../.agent/skills/ .claude/skills/ +``` diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index 515fbe85d..16585014f 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -9,7 +9,7 @@ packages/a2ui_core: 76.54 packages/genai_primitives: 100.00 packages/genui: 79.66 packages/genui_a2a: 91.37 -packages/json_schema_builder: 79.09 +packages/json_schema_builder: 81.93 tool/e2e: 100.00 tool/fix_copyright: 89.83 tool/release: 78.01 diff --git a/packages/json_schema_builder/CHANGELOG.md b/packages/json_schema_builder/CHANGELOG.md index f799241ed..bdc03a9c9 100644 --- a/packages/json_schema_builder/CHANGELOG.md +++ b/packages/json_schema_builder/CHANGELOG.md @@ -1,5 +1,21 @@ # [json_schema_builder](https://pub.dev/packages/json_schema_builder) Change Log +## 0.1.7 + +- **Feature**: Add `Schema.validateSync`, a synchronous validation entry point + for schemas whose references all resolve without fetching. Validation itself + is now a synchronous core, and the existing `Schema.validate` is a thin + asynchronous wrapper around it that fetches the remote references it needs + into the `SchemaRegistry` up front, in parallel. Behavior of `validate` is + unchanged. +- **Feature**: Add `SchemaRegistry.resolveSync` and `SchemaRegistry.fetch`, + which split reference resolution from fetching, and + `SchemaRegistry.prefetchDependencies`, which fetches the schemas a schema + refers to in parallel so that it can then be validated synchronously. +- **Feature**: Export `SchemaFetchException` and the new + `SchemaResolutionRequiredException`, which `validateSync` throws when a + reference can only be resolved by fetching. + ## 0.1.6 - **Feature**: Export `SchemaRegistry` to support managing schema references during component validation. diff --git a/packages/json_schema_builder/README.md b/packages/json_schema_builder/README.md index b2ee5bf90..8ea5eafc1 100644 --- a/packages/json_schema_builder/README.md +++ b/packages/json_schema_builder/README.md @@ -135,3 +135,38 @@ Future main() async { - List contains duplicate items at path #root["roles"] - Additional property "extraField" is not allowed. at path #root["extraField"] ``` + +### Synchronous Validation + +`validate` is asynchronous only because a `$ref` may point at a schema that has +to be fetched (loaded from source and parsed into a `Schema`). If your schema +has no such references — because they are inlined, or because you registered +every referenced schema up front — use `validateSync` instead and skip the +`Future`: + +```dart +final errors = userProfileSchema.validateSync(validUser); +``` + +`validateSync` performs no I/O. If validation reaches a reference whose target +would have to be fetched, it throws a `SchemaResolutionRequiredException` naming +that target instead of skipping the reference, so a missing fetch can never turn +into a passing validation. + +To fetch those schemas without validating anything, prepare the registry with +`prefetchDependencies`, which fetches everything the schema refers to, and +everything those schemas refer to in turn, in parallel: + +```dart +final registry = SchemaRegistry(); +await registry.prefetchDependencies(schema, baseUri: sourceUri); +final errors = schema.validateSync( + value, + sourceUri: sourceUri, + schemaRegistry: registry, +); +``` + +Note that a schema declaring a `$schema` meta-schema needs that meta-schema +resolved too, so pre-register it (or fetch it with one `validate` call) before +validating synchronously. diff --git a/packages/json_schema_builder/lib/json_schema_builder.dart b/packages/json_schema_builder/lib/json_schema_builder.dart index f5ee31640..94952e0ff 100644 --- a/packages/json_schema_builder/lib/json_schema_builder.dart +++ b/packages/json_schema_builder/lib/json_schema_builder.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/exceptions.dart'; export 'src/json_type.dart'; export 'src/schema/boolean_schema.dart'; export 'src/schema/integer_schema.dart'; diff --git a/packages/json_schema_builder/lib/src/exceptions.dart b/packages/json_schema_builder/lib/src/exceptions.dart index a68a07cec..ec5a6cbde 100644 --- a/packages/json_schema_builder/lib/src/exceptions.dart +++ b/packages/json_schema_builder/lib/src/exceptions.dart @@ -17,3 +17,28 @@ class SchemaFetchException implements Exception { return message; } } + +/// Thrown by the synchronous validation path when a reference can only be +/// resolved by fetching a schema that is not in the `SchemaRegistry` yet. +/// +/// Synchronous validation never performs I/O, so it cannot fetch the schema +/// itself. Rather than treating the unresolved subschema as unconstrained, +/// which would silently turn a missing fetch into a passing validation, it +/// throws this exception. +/// +/// To fix it, either bring the schema at [uri] into the registry before +/// validating (with `SchemaRegistry.addSchema` or +/// `SchemaRegistry.prefetchDependencies`), or use the asynchronous `validate` +/// method, which fetches the remote schemas it needs before validating. +class SchemaResolutionRequiredException implements Exception { + /// The URI of the schema that would have to be fetched, without any fragment. + final Uri uri; + + SchemaResolutionRequiredException(this.uri); + + @override + String toString() => + 'Synchronous validation requires the schema at $uri, which is not ' + 'registered. Add it to the SchemaRegistry, or use the asynchronous ' + 'Schema.validate to fetch it.'; +} diff --git a/packages/json_schema_builder/lib/src/schema_registry.dart b/packages/json_schema_builder/lib/src/schema_registry.dart index d33598edd..317b814a2 100644 --- a/packages/json_schema_builder/lib/src/schema_registry.dart +++ b/packages/json_schema_builder/lib/src/schema_registry.dart @@ -43,23 +43,102 @@ class SchemaRegistry { /// /// This method can also resolve fragments and JSON pointers within a schema. Future resolve(Uri uri) async { + final Schema? schema = await fetch(uri); + if (schema == null) return null; + return _getSchemaFromFragment(uri, schema); + } + + /// Resolves a schema from the given [uri] without performing any I/O. + /// + /// This behaves like [resolve], except that it only looks at the schemas this + /// registry already holds: those added with [addSchema], and those a previous + /// [resolve] or [fetch] call brought in. + /// + /// Throws a [SchemaResolutionRequiredException] if resolving [uri] would + /// require fetching a schema that this registry does not hold. + Schema? resolveSync(Uri uri) { final Uri uriWithoutFragment = uri.removeFragment(); - if (_schemas.containsKey(uriWithoutFragment)) { - return _getSchemaFromFragment(uri, _schemas[uriWithoutFragment]!); + final Schema? schema = _schemas[uriWithoutFragment]; + if (schema == null) { + throw SchemaResolutionRequiredException(uriWithoutFragment); } + return _getSchemaFromFragment(uri, schema); + } - try { - final Schema? schema = await _schemaCache.get(uriWithoutFragment); - if (schema == null) { - return null; - } - _schemas[uriWithoutFragment] = schema; - _registerIds(schema, uriWithoutFragment); + /// Fetches the schema resource that [uri] points into, ignoring any fragment, + /// and adds it to this registry. + /// + /// Returns the schema, or `null` if there is none. If the registry already + /// holds it, it is returned without any I/O. Throws a [SchemaFetchException] + /// if the fetch fails. + /// + /// This is the asynchronous counterpart to [resolveSync]: it performs the + /// I/O that [resolveSync] refuses to perform, so that a subsequent + /// [resolveSync] of the same URI can answer from memory. + Future fetch(Uri uri) async { + final Uri uriWithoutFragment = uri.removeFragment(); + final Schema? registered = _schemas[uriWithoutFragment]; + if (registered != null) return registered; - return _getSchemaFromFragment(uri, schema); - } on SchemaFetchException { - rethrow; + final Schema? schema = await _schemaCache.get(uriWithoutFragment); + if (schema == null) return null; + _schemas[uriWithoutFragment] = schema; + _registerIds(schema, uriWithoutFragment); + return schema; + } + + /// Fetches the schema resources that [schema] refers to, and that this + /// registry does not already hold, adding them to it. + /// + /// References in [schema] are resolved against [baseUri], the URI it is + /// registered under. Whatever a fetched schema refers to in turn is fetched + /// as well, and the resources discovered at each step are fetched in + /// parallel. Once this completes, [resolveSync] can answer for every + /// reference in [schema] that resolves at all, which is what + /// `Schema.validateSync` requires. + /// + /// Returns the resources that could not be brought in, keyed by URI: the + /// value is the [SchemaFetchException] the fetch failed with, or `null` if + /// the fetch produced no schema. Such an entry is not by itself an error, + /// because a reference that validation never reaches never has to resolve. + Future> prefetchDependencies( + Schema schema, { + required Uri baseUri, + }) async { + final unresolved = {}; + final seen = {baseUri.removeFragment()}; + // The resources whose own references have not been collected yet. + var pending = {baseUri.removeFragment(): schema}; + while (pending.isNotEmpty) { + final references = {}; + for (final MapEntry resource in pending.entries) { + for (final Uri reference in _referencedResources( + resource.value, + resource.key, + )) { + if (seen.add(reference)) references.add(reference); + } + } + // This round of references, fetched in parallel. One that the registry + // already holds comes back without any I/O, and whatever comes back is + // walked in the next round. + pending = {}; + await Future.wait( + references.map((Uri uri) async { + try { + final Schema? resource = await fetch(uri); + if (resource == null) { + unresolved[uri] = null; + } else { + pending[uri] = resource; + } + } on SchemaFetchException catch (e) { + unresolved[uri] = e; + } + }), + ); } + return unresolved; } /// Gets the URI for a given schema, if it has been registered. @@ -80,74 +159,33 @@ class SchemaRegistry { } void _registerIds(Schema schema, Uri baseUri) { - final String? id = schema.$id; - if (id != null) { - // This is a heuristic to avoid re-resolving a relative path that has - // already been applied to the base URI. - if (id.endsWith('/') && baseUri.path.endsWith('/$id')) { - _schemas[baseUri.removeFragment()] = schema; - } else { - final Uri newUri = baseUri.resolve(id); - _schemas[newUri.removeFragment()] = schema; - baseUri = newUri; - } - } - - void recurseOnMap(Map map) { - _registerIds(Schema.fromMap(map), baseUri); - } - - void recurseOnList(List list) { - for (final item in list) { - if (item is Map) { - recurseOnMap(item); - } - } - } - - // Keywords with map-of-schemas values - const mapOfSchemasKeywords = [ - 'properties', - 'patternProperties', - 'dependentSchemas', - '\$defs', - ]; - for (final keyword in mapOfSchemasKeywords) { - if (schema.value[keyword] case final Map map?) { - for (final Object? value in map.values) { - if (value is Map) { - recurseOnMap(value); - } - } - } - } - - // Keywords with schema values - const schemaKeywords = [ - 'additionalProperties', - 'unevaluatedProperties', - 'items', - 'unevaluatedItems', - 'contains', - 'propertyNames', - 'not', - 'if', - 'then', - 'else', - ]; - for (final keyword in schemaKeywords) { - if (schema.value[keyword] case final Map map) { - recurseOnMap(map); + _walkSchema(schema, baseUri, (Schema subschema, Uri subschemaBaseUri) { + if (subschema.$id != null) { + _schemas[subschemaBaseUri.removeFragment()] = subschema; } - } + }); + } - // Keywords with list-of-schemas values - const listOfSchemasKeywords = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; - for (final keyword in listOfSchemasKeywords) { - if (schema.value[keyword] case final List list) { - recurseOnList(list); + /// The URIs of the schema resources that [schema] refers to, other than the + /// resource it is itself part of. + /// + /// References are resolved against [baseUri], and against the base URI of + /// any `$id` within [schema], exactly as validation resolves them. + Set _referencedResources(Schema schema, Uri baseUri) { + final references = {}; + _walkSchema(schema, baseUri, (Schema subschema, Uri subschemaBaseUri) { + final Uri ownResource = subschemaBaseUri.removeFragment(); + for (final String? reference in [ + subschema.$ref, + subschema.$dynamicRef, + subschema.$schema, + ]) { + if (reference == null) continue; + final Uri target = subschemaBaseUri.resolve(reference).removeFragment(); + if (target != ownResource) references.add(target); } - } + }); + return references; } Schema? _getSchemaFromFragment(Uri uri, Schema schema) { @@ -236,3 +274,81 @@ class SchemaRegistry { return result; } } + +/// Calls [visit] with [schema] and every subschema of it, along with the base +/// URI that the references in that subschema resolve against. +/// +/// The base URI starts as [baseUri] and changes as the walk enters a subschema +/// declaring an `$id`, exactly as it does during validation. +void _walkSchema( + Schema schema, + Uri baseUri, + void Function(Schema schema, Uri baseUri) visit, +) { + final String? id = schema.$id; + var currentBaseUri = baseUri; + if (id != null) { + // This is a heuristic to avoid re-resolving a relative path that has + // already been applied to the base URI. + if (!(id.endsWith('/') && baseUri.path.endsWith('/$id'))) { + currentBaseUri = baseUri.resolve(id); + } + } + visit(schema, currentBaseUri); + + void recurseOnMap(Map map) { + _walkSchema(Schema.fromMap(map), currentBaseUri, visit); + } + + void recurseOnList(List list) { + for (final item in list) { + if (item is Map) { + recurseOnMap(item); + } + } + } + + // Keywords with map-of-schemas values + const mapOfSchemasKeywords = [ + 'properties', + 'patternProperties', + 'dependentSchemas', + '\$defs', + ]; + for (final keyword in mapOfSchemasKeywords) { + if (schema.value[keyword] case final Map map?) { + for (final Object? value in map.values) { + if (value is Map) { + recurseOnMap(value); + } + } + } + } + + // Keywords with schema values + const schemaKeywords = [ + 'additionalProperties', + 'unevaluatedProperties', + 'items', + 'unevaluatedItems', + 'contains', + 'propertyNames', + 'not', + 'if', + 'then', + 'else', + ]; + for (final keyword in schemaKeywords) { + if (schema.value[keyword] case final Map map) { + recurseOnMap(map); + } + } + + // Keywords with list-of-schemas values + const listOfSchemasKeywords = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; + for (final keyword in listOfSchemasKeywords) { + if (schema.value[keyword] case final List list) { + recurseOnList(list); + } + } +} diff --git a/packages/json_schema_builder/lib/src/schema_validation.dart b/packages/json_schema_builder/lib/src/schema_validation.dart index a224e0a91..2f87fd13f 100644 --- a/packages/json_schema_builder/lib/src/schema_validation.dart +++ b/packages/json_schema_builder/lib/src/schema_validation.dart @@ -32,6 +32,16 @@ class ValidationContext { final Map vocabularies; final LoggingContext? loggingContext; + /// The outcome of every fetch made for this validation, keyed by the URI of + /// the schema resource, that did not produce a schema. + /// + /// A `null` value means the fetch produced no schema, and a non-null value is + /// the exception it failed with. Successful fetches land in [schemaRegistry] + /// instead. Keeping the failures here rather than in the registry scopes them + /// to a single validation, so that a later validation retries the fetch just + /// as it did before this context existed. + final Map _failedFetches; + /// Creates a new validation context. ValidationContext( this.rootSchema, { @@ -48,7 +58,7 @@ class ValidationContext { 'https://json-schema.org/draft/2020-12/vocab/format-annotation': true, 'https://json-schema.org/draft/2020-12/vocab/content': true, }, - }); + }) : _failedFetches = {}; ValidationContext._copyWith({ required this.rootSchema, @@ -57,10 +67,56 @@ class ValidationContext { required this.schemaRegistry, required this.vocabularies, required this.loggingContext, - }); + required Map failedFetches, + }) : _failedFetches = failedFetches; + + /// Resolves the schema at [uri] for this validation, without performing any + /// I/O. + /// + /// This is [SchemaRegistry.resolveSync], plus the outcomes of the fetches + /// already made for this validation: a fetch that failed throws its + /// [SchemaFetchException] again, and one that produced no schema resolves to + /// `null` again, rather than asking for the fetch to be repeated. + Schema? _resolveSchemaSync(Uri uri) { + final Uri uriWithoutFragment = uri.removeFragment(); + if (_failedFetches.containsKey(uriWithoutFragment)) { + final SchemaFetchException? failure = _failedFetches[uriWithoutFragment]; + if (failure != null) throw failure; + return null; + } + return schemaRegistry.resolveSync(uri); + } + + /// Fetches the schema at [uri] into [schemaRegistry], recording the outcome + /// so that [_resolveSchemaSync] can answer for [uri] without fetching again. + Future _fetchSchema(Uri uri) async { + final Uri uriWithoutFragment = uri.removeFragment(); + try { + final Schema? schema = await schemaRegistry.fetch(uriWithoutFragment); + if (schema == null) { + _failedFetches[uriWithoutFragment] = null; + } + } on SchemaFetchException catch (e) { + _failedFetches[uriWithoutFragment] = e; + } + } + + /// Fetches the remote schemas that [schema] refers to into [schemaRegistry], + /// recording the outcome of each fetch so that [_resolveSchemaSync] can + /// answer for it without fetching again. + /// + /// The only I/O validation ever needs is fetching the target of a reference, + /// and every target it can reach is named in the schema itself. Bringing + /// them all in up front, in parallel, is what lets the asynchronous entry + /// points run the synchronous core once and wait for nothing. + Future _prefetchRemoteRefs(Schema schema) async { + _failedFetches.addAll( + await schemaRegistry.prefetchDependencies(schema, baseUri: sourceUri!), + ); + } /// Creates a copy of this context with a new [newSourceUri]. - ValidationContext withSourceUri(Uri newSourceUri) { + ValidationContext _withSourceUri(Uri newSourceUri) { return ValidationContext._copyWith( rootSchema: rootSchema, strictFormat: strictFormat, @@ -68,11 +124,12 @@ class ValidationContext { schemaRegistry: schemaRegistry, vocabularies: vocabularies, loggingContext: loggingContext, + failedFetches: _failedFetches, ); } /// Creates a copy of this context with a new set of [newVocabularies]. - ValidationContext withVocabularies(Map newVocabularies) { + ValidationContext _withVocabularies(Map newVocabularies) { return ValidationContext._copyWith( rootSchema: rootSchema, strictFormat: strictFormat, @@ -80,6 +137,7 @@ class ValidationContext { schemaRegistry: schemaRegistry, vocabularies: newVocabularies, loggingContext: loggingContext, + failedFetches: _failedFetches, ); } } @@ -87,6 +145,9 @@ class ValidationContext { /// Validates the given [data] against a [schema]. /// /// This is a helper function for recursively validating subschemas. +/// +/// The remote references it may need are fetched up front, in parallel, and +/// the validation itself then runs synchronously. Future validateSubSchema( Object? schema, Object? data, @@ -95,6 +156,35 @@ Future validateSubSchema( List dynamicScope, { AnnotationSet? initialAnnotations, }) async { + if (schema is Map) { + await context._prefetchRemoteRefs( + Schema.fromMap(schema.cast()), + ); + } + return _validateSubSchemaSync( + schema, + data, + currentPath, + context, + dynamicScope, + initialAnnotations: initialAnnotations, + ); +} + +/// Validates the given [data] against a [schema], without performing any I/O. +/// +/// This is a helper function for recursively validating subschemas. +/// +/// Every reference must resolve from `context.schemaRegistry`; see +/// [SchemaValidation.validateSync] for what happens when one does not. +ValidationResult _validateSubSchemaSync( + Object? schema, + Object? data, + List currentPath, + ValidationContext context, + List dynamicScope, { + AnnotationSet? initialAnnotations, +}) { if (schema is bool) { if (schema == false) { return ValidationResult.failure([ @@ -109,7 +199,7 @@ Future validateSubSchema( return ValidationResult.success(AnnotationSet.empty()); } if (schema is Map) { - return await Schema.fromMap(schema.cast()).validateSchema( + return Schema.fromMap(schema.cast())._validateSchemaSync( data, currentPath, context, @@ -127,6 +217,13 @@ extension SchemaValidation on Schema { /// /// Returns a list of [ValidationError] if validation fails, /// or an empty list if validation succeeds. + /// + /// A remote reference — a `$ref` pointing at a schema that is not already in + /// [schemaRegistry] — is fetched into it up front: loaded from its source, + /// parsed into a [Schema], and registered. The fetches run in parallel, and + /// the validation itself then runs synchronously. If this schema has no + /// remote references, prefer [validateSync], which does the same work + /// without the [Future]. Future> validate( Object? data, { bool strictFormat = false, @@ -136,30 +233,92 @@ extension SchemaValidation on Schema { }) async { final SchemaRegistry registry = schemaRegistry ?? SchemaRegistry(loggingContext: loggingContext); - ValidationResult? result; try { - final Uri baseUri = sourceUri ?? Uri.parse('local://schema'); - registry.addSchema(baseUri, this); - final context = ValidationContext( - this, + final ValidationContext context = _rootValidationContext( + registry, + strictFormat: strictFormat, + sourceUri: sourceUri, + loggingContext: loggingContext, + ); + await context._prefetchRemoteRefs(this); + return _validateSchemaSync(data, [], context, [this]).errors; + } finally { + if (schemaRegistry == null) { + // If we created our own, we need to dispose it. + registry.dispose(); + } + } + } + + /// Validates the given [data] against this schema, without performing any + /// I/O. + /// + /// Returns a list of [ValidationError] if validation fails, or an empty list + /// if validation succeeds. The results are identical to those of [validate]. + /// + /// This requires that every reference in this schema resolves without + /// fetching (loading from source and parsing into a [Schema]): references + /// within the schema itself, and references to schemas already added to + /// [schemaRegistry]. That covers schemas whose `$ref`s have been inlined, + /// and schemas whose dependencies were registered up front. + /// + /// If validation reaches a reference whose target would have to be fetched, + /// this throws a [SchemaResolutionRequiredException] naming that target, + /// rather than skipping the reference — an unfetched subschema would + /// otherwise be silently treated as unconstrained and turn a missing fetch + /// into a passing validation. Use [validate] for schemas with remote + /// references, or pass a [schemaRegistry] that already holds them, which + /// [SchemaRegistry.prefetchDependencies] can prepare. + List validateSync( + Object? data, { + bool strictFormat = false, + Uri? sourceUri, + SchemaRegistry? schemaRegistry, + LoggingContext? loggingContext, + }) { + final SchemaRegistry registry = + schemaRegistry ?? SchemaRegistry(loggingContext: loggingContext); + try { + final ValidationContext context = _rootValidationContext( + registry, strictFormat: strictFormat, - sourceUri: baseUri, - schemaRegistry: registry, + sourceUri: sourceUri, loggingContext: loggingContext, ); - result = await validateSchema(data, [], context, [this]); + return _validateSchemaSync(data, [], context, [this]).errors; } finally { if (schemaRegistry == null) { // If we created our own, we need to dispose it. registry.dispose(); } } - return result.errors; + } + + /// Registers this schema in [registry] and creates the context that + /// validation of it starts from. + ValidationContext _rootValidationContext( + SchemaRegistry registry, { + required bool strictFormat, + required Uri? sourceUri, + required LoggingContext? loggingContext, + }) { + final Uri baseUri = sourceUri ?? Uri.parse('local://schema'); + registry.addSchema(baseUri, this); + return ValidationContext( + this, + strictFormat: strictFormat, + sourceUri: baseUri, + schemaRegistry: registry, + loggingContext: loggingContext, + ); } /// Validates the given [data] against this schema, including any subschemas. /// /// This is the main entry point for validating an object against a schema. + /// + /// The remote references it may need are fetched up front, in parallel, and + /// the validation itself then runs synchronously. Future validateSchema( Object? data, List currentPath, @@ -167,6 +326,103 @@ extension SchemaValidation on Schema { List dynamicScope, { AnnotationSet? initialAnnotations, }) async { + await context._prefetchRemoteRefs(this); + return _validateSchemaSync( + data, + currentPath, + context, + dynamicScope, + initialAnnotations: initialAnnotations, + ); + } + + /// Validates the given [data] against the type-specific keywords in this + /// schema. + /// + /// The remote references it may need are fetched up front, in parallel, and + /// the validation itself then runs synchronously. + Future validateTypeSpecificKeywords( + Object? data, + List currentPath, + ValidationContext context, + List dynamicScope, + ) async { + await context._prefetchRemoteRefs(this); + return _validateTypeSpecificKeywordsSync( + data, + currentPath, + context, + dynamicScope, + ); + } + + /// Validates an object against the schema. + /// + /// The remote references it may need are fetched up front, in parallel, and + /// the validation itself then runs synchronously. + Future validateObject( + Map data, + List currentPath, + ValidationContext context, + List dynamicScope, + ) async { + await context._prefetchRemoteRefs(this); + return _validateObjectSync(data, currentPath, context, dynamicScope); + } + + /// Validates a list against the schema. + /// + /// The remote references it may need are fetched up front, in parallel, and + /// the validation itself then runs synchronously. + Future validateList( + List data, + List currentPath, + ValidationContext context, + List dynamicScope, + ) async { + await context._prefetchRemoteRefs(this); + return _validateListSync(data, currentPath, context, dynamicScope); + } + + /// Resolves a `$ref` reference to a schema. + /// + /// The target is fetched if it is not registered yet, and the resolution + /// itself then runs synchronously. + Future<(Schema, Uri)?> resolveRef( + String ref, + Schema rootSchema, + ValidationContext context, + ) async { + await context._fetchSchema(context.sourceUri!.resolve(ref)); + return _resolveRefSync(ref, rootSchema, context); + } + + /// Resolves a `$dynamicRef` reference to a schema. + /// + /// The target is fetched if it is not registered yet, and the resolution + /// itself then runs synchronously. + Future<(Schema, Uri)?> resolveDynamicRef( + String ref, + List dynamicScope, + ValidationContext context, + ) async { + await context._fetchSchema(context.sourceUri!.resolve(ref)); + return _resolveDynamicRefSync(ref, dynamicScope, context); + } + + /// Validates the given [data] against this schema, including any subschemas, + /// without performing any I/O. + /// + /// This is the main entry point for validating an object against a schema. + /// Every reference must resolve from `context.schemaRegistry`; see + /// [validateSync] for what happens when one does not. + ValidationResult _validateSchemaSync( + Object? data, + List currentPath, + ValidationContext context, + List dynamicScope, { + AnnotationSet? initialAnnotations, + }) { var currentContext = context; if ($id != null) { // This is a heuristic to avoid re-resolving a relative path that has @@ -174,7 +430,7 @@ extension SchemaValidation on Schema { if (!($id!.endsWith('/') && context.sourceUri!.path.endsWith('/${$id}'))) { final Uri newUri = context.sourceUri!.resolve($id!); - currentContext = context.withSourceUri(newUri); + currentContext = context._withSourceUri(newUri); } } @@ -189,18 +445,18 @@ extension SchemaValidation on Schema { if ($schema != null) { try { final Uri metaSchemaUri = Uri.parse($schema!); - final Schema? metaSchema = await currentContext.schemaRegistry.resolve( + final Schema? metaSchema = currentContext._resolveSchemaSync( metaSchemaUri, ); if (metaSchema != null) { final Object? vocabulary = metaSchema.value['\$vocabulary']; if (vocabulary is Map) { - currentContext = currentContext.withVocabularies( + currentContext = currentContext._withVocabularies( vocabulary.cast(), ); } else { // If $vocabulary is not present, default to all vocabularies. - currentContext = currentContext.withVocabularies(const { + currentContext = currentContext._withVocabularies(const { 'https://json-schema.org/draft/2020-12/vocab/core': true, 'https://json-schema.org/draft/2020-12/vocab/applicator': true, 'https://json-schema.org/draft/2020-12/vocab/unevaluated': true, @@ -224,17 +480,21 @@ extension SchemaValidation on Schema { } if ($dynamicRef case final ref?) { - final (Schema, Uri)? resolution = await resolveDynamicRef( + final (Schema, Uri)? resolution = _resolveDynamicRefSync( ref, newDynamicScope, currentContext, ); if (resolution case (final referencedSchema, final referencedUri)?) { - final ValidationContext newContext = currentContext.withSourceUri( + final ValidationContext newContext = currentContext._withSourceUri( referencedUri, ); - final ValidationResult refResult = await referencedSchema - .validateSchema(data, currentPath, newContext, newDynamicScope); + final ValidationResult refResult = referencedSchema._validateSchemaSync( + data, + currentPath, + newContext, + newDynamicScope, + ); errors.addAll(refResult.errors); allAnnotations = allAnnotations.merge(refResult.annotations); @@ -242,8 +502,8 @@ extension SchemaValidation on Schema { siblingSchemaMap.remove(kDynamicRef); if (siblingSchemaMap.isNotEmpty) { final siblingSchema = Schema.fromMap(siblingSchemaMap); - final ValidationResult siblingResult = await siblingSchema - .validateSchema( + final ValidationResult siblingResult = siblingSchema + ._validateSchemaSync( data, currentPath, currentContext, @@ -266,17 +526,21 @@ extension SchemaValidation on Schema { } if ($ref case final ref?) { - final (Schema, Uri)? resolution = await resolveRef( + final (Schema, Uri)? resolution = _resolveRefSync( ref, currentContext.rootSchema, currentContext, ); if (resolution case (final referencedSchema, final referencedUri)?) { - final ValidationContext newContext = currentContext.withSourceUri( + final ValidationContext newContext = currentContext._withSourceUri( referencedUri, ); - final ValidationResult refResult = await referencedSchema - .validateSchema(data, currentPath, newContext, newDynamicScope); + final ValidationResult refResult = referencedSchema._validateSchemaSync( + data, + currentPath, + newContext, + newDynamicScope, + ); context.loggingContext?.log( 'Annotations from $ref: ${refResult.annotations.evaluatedKeys}', ); @@ -287,8 +551,8 @@ extension SchemaValidation on Schema { siblingSchemaMap.remove(kRef); if (siblingSchemaMap.isNotEmpty) { final siblingSchema = Schema.fromMap(siblingSchemaMap); - final ValidationResult siblingResult = await siblingSchema - .validateSchema( + final ValidationResult siblingResult = siblingSchema + ._validateSchemaSync( data, currentPath, currentContext, @@ -312,7 +576,7 @@ extension SchemaValidation on Schema { // 1. Conditional Applicators: if/then/else if (ifSchema case final ifS?) { - final ValidationResult ifResult = await validateSubSchema( + final ValidationResult ifResult = _validateSubSchemaSync( ifS, data, currentPath, @@ -322,7 +586,7 @@ extension SchemaValidation on Schema { if (ifResult.isValid) { allAnnotations = allAnnotations.merge(ifResult.annotations); if (thenSchema case final thenS?) { - final ValidationResult thenResult = await validateSubSchema( + final ValidationResult thenResult = _validateSubSchemaSync( thenS, data, currentPath, @@ -336,7 +600,7 @@ extension SchemaValidation on Schema { } } else { if (elseSchema case final elseS?) { - final ValidationResult elseResult = await validateSubSchema( + final ValidationResult elseResult = _validateSubSchemaSync( elseS, data, currentPath, @@ -355,7 +619,7 @@ extension SchemaValidation on Schema { if (allOf case final List allOfList) { final allOfAnnotations = []; for (final subSchema in allOfList) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( subSchema, data, currentPath, @@ -375,7 +639,7 @@ extension SchemaValidation on Schema { final anyOfAnnotations = []; final allAnyOfErrors = []; for (final subSchema in anyOfList) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( subSchema, data, currentPath, @@ -401,7 +665,7 @@ extension SchemaValidation on Schema { var passedCount = 0; AnnotationSet? oneOfAnnotations; for (final subSchema in oneOfList) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( subSchema, data, currentPath, @@ -429,7 +693,7 @@ extension SchemaValidation on Schema { } if (not case final notSchema?) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( notSchema, data, currentPath, @@ -473,7 +737,7 @@ extension SchemaValidation on Schema { } // 4. Type-Specific Validation - final ValidationResult typeResult = await validateTypeSpecificKeywords( + final ValidationResult typeResult = _validateTypeSpecificKeywordsSync( data, currentPath, currentContext, @@ -493,7 +757,7 @@ extension SchemaValidation on Schema { for (final String dataKey in data.keys) { if (!allAnnotations.evaluatedKeys.contains(dataKey)) { final newPath = [...currentPath, dataKey]; - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( up, data[dataKey], newPath, @@ -515,7 +779,7 @@ extension SchemaValidation on Schema { for (var i = 0; i < data.length; i++) { if (!allAnnotations.evaluatedItems.contains(i)) { final newPath = [...currentPath, i.toString()]; - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( ui, data[i], newPath, @@ -538,12 +802,12 @@ extension SchemaValidation on Schema { /// Validates the given [data] against the type-specific keywords in this /// schema. - Future validateTypeSpecificKeywords( + ValidationResult _validateTypeSpecificKeywordsSync( Object? data, List currentPath, ValidationContext context, List dynamicScope, - ) async { + ) { final JsonType actualType = getJsonType(data); final errors = []; @@ -582,14 +846,14 @@ extension SchemaValidation on Schema { // Now, apply keywords based on the actual type of the data. switch (actualType) { case JsonType.object: - return await (this as ObjectSchema).validateObject( + return (this as ObjectSchema)._validateObjectSync( data as Map, currentPath, context, dynamicScope, ); case JsonType.list: - return await (this as ListSchema).validateList( + return (this as ListSchema)._validateListSync( data as List, currentPath, context, @@ -720,14 +984,14 @@ extension SchemaValidation on Schema { /// Validates an object against the schema. /// - /// This method is called by [validateTypeSpecificKeywords] when the data is - /// a [Map]. - Future validateObject( + /// This method is called by [_validateTypeSpecificKeywordsSync] when the + /// data is a [Map]. + ValidationResult _validateObjectSync( Map data, List currentPath, ValidationContext context, List dynamicScope, - ) async { + ) { final objectSchema = this as ObjectSchema; final errors = []; var annotations = AnnotationSet.empty(); @@ -797,7 +1061,7 @@ extension SchemaValidation on Schema { if (objectSchema.dependentSchemas case final ds?) { for (final MapEntry entry in ds.entries) { if (data.containsKey(entry.key)) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( entry.value, data, currentPath, @@ -816,7 +1080,7 @@ extension SchemaValidation on Schema { if (data.containsKey(entry.key)) { final List newPath = [...currentPath, entry.key]; evaluatedKeys.add(entry.key); - final ValidationResult result = await entry.value.validateSchema( + final ValidationResult result = entry.value._validateSchemaSync( data[entry.key], newPath, context, @@ -835,7 +1099,7 @@ extension SchemaValidation on Schema { if (pattern.hasMatch(dataKey)) { final newPath = [...currentPath, dataKey]; evaluatedKeys.add(dataKey); - final ValidationResult result = await entry.value.validateSchema( + final ValidationResult result = entry.value._validateSchemaSync( data[dataKey], newPath, context, @@ -850,7 +1114,7 @@ extension SchemaValidation on Schema { if (objectSchema.propertyNames case final propNamesSchema?) { for (final String key in data.keys) { - final ValidationResult result = await propNamesSchema.validateSchema( + final ValidationResult result = propNamesSchema._validateSchemaSync( key, currentPath, context, @@ -866,7 +1130,7 @@ extension SchemaValidation on Schema { if (objectSchema.additionalProperties case final ap?) { final newPath = [...currentPath, dataKey]; - final ValidationResult result = await ap.validateSchema( + final ValidationResult result = ap._validateSchemaSync( data[dataKey], newPath, context, @@ -892,14 +1156,14 @@ extension SchemaValidation on Schema { /// Validates a list against the schema. /// - /// This method is called by [validateTypeSpecificKeywords] when the data is - /// a [List]. - Future validateList( + /// This method is called by [_validateTypeSpecificKeywordsSync] when the + /// data is a [List]. + ValidationResult _validateListSync( List data, List currentPath, ValidationContext context, List dynamicScope, - ) async { + ) { final errors = []; final evaluatedItems = {}; final listSchema = this as ListSchema; @@ -953,7 +1217,7 @@ extension SchemaValidation on Schema { if (listSchema.contains case final containsSchema?) { final matches = []; for (var i = 0; i < data.length; i++) { - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( containsSchema, data[i], currentPath, @@ -1012,7 +1276,7 @@ extension SchemaValidation on Schema { for (var i = 0; i < pItems.length && i < data.length; i++) { evaluatedItems.add(i); final newPath = [...currentPath, i.toString()]; - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( pItems[i], data[i], newPath, @@ -1027,7 +1291,7 @@ extension SchemaValidation on Schema { for (var i = startIndex; i < data.length; i++) { evaluatedItems.add(i); final newPath = [...currentPath, i.toString()]; - final ValidationResult result = await validateSubSchema( + final ValidationResult result = _validateSubSchemaSync( itemSchema, data[i], newPath, @@ -1061,16 +1325,20 @@ extension SchemaValidation on Schema { throw StateError('Unknown JSON type for value: $data'); } - /// Resolves a `$ref` reference to a schema. - Future<(Schema, Uri)?> resolveRef( + /// Resolves a `$ref` reference to a schema, without performing any I/O. + /// + /// Returns `null` if the reference does not resolve. Throws a + /// [SchemaResolutionRequiredException] if resolving it would require + /// fetching a schema that is not registered; see [validateSync]. + (Schema, Uri)? _resolveRefSync( String ref, Schema rootSchema, ValidationContext context, - ) async { + ) { final Uri baseUri = context.sourceUri!; final Uri refUri = baseUri.resolve(ref); try { - final Schema? schema = await context.schemaRegistry.resolve(refUri); + final Schema? schema = context._resolveSchemaSync(refUri); if (schema == null) return null; return (schema, refUri); } on SchemaFetchException { @@ -1078,14 +1346,19 @@ extension SchemaValidation on Schema { } } - /// Resolves a `$dynamicRef` reference to a schema. - Future<(Schema, Uri)?> resolveDynamicRef( + /// Resolves a `$dynamicRef` reference to a schema, without performing any + /// I/O. + /// + /// Returns `null` if the reference does not resolve. Throws a + /// [SchemaResolutionRequiredException] if resolving it would require + /// fetching a schema that is not registered; see [validateSync]. + (Schema, Uri)? _resolveDynamicRefSync( String ref, List dynamicScope, ValidationContext context, - ) async { + ) { // 1. Initial resolution, just like $ref - final (Schema, Uri)? initialResolution = await resolveRef( + final (Schema, Uri)? initialResolution = _resolveRefSync( ref, dynamicScope.last, context, diff --git a/packages/json_schema_builder/pubspec.yaml b/packages/json_schema_builder/pubspec.yaml index d273bd9ed..d27d71291 100644 --- a/packages/json_schema_builder/pubspec.yaml +++ b/packages/json_schema_builder/pubspec.yaml @@ -4,7 +4,7 @@ name: json_schema_builder description: A full-featured package used to build and validate JSON schemas in Dart. -version: 0.1.6 +version: 0.1.7 homepage: https://github.com/flutter/genui/tree/main/packages/json_schema_builder license: BSD-3-Clause issue_tracker: https://github.com/flutter/genui/issues diff --git a/packages/json_schema_builder/test/sync_validation_test.dart b/packages/json_schema_builder/test/sync_validation_test.dart new file mode 100644 index 000000000..4c090aa55 --- /dev/null +++ b/packages/json_schema_builder/test/sync_validation_test.dart @@ -0,0 +1,735 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:json_schema_builder/src/schema_cache.dart'; +import 'package:test/test.dart'; + +/// A registry whose remote fetches are served by [responses] instead of the +/// network. +SchemaRegistry _registryServing(Map responses) { + final client = MockClient((http.Request request) async { + final Object? body = responses[request.url.toString()]; + if (body == null) return http.Response('Not found', 404); + return http.Response(jsonEncode(body), 200); + }); + return SchemaRegistry(schemaCache: SchemaCache(httpClient: client)); +} + +void main() { + final personSchema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 1}, + 'age': {'type': 'integer', 'minimum': 0}, + }, + 'required': ['name'], + }); + + group('validateSync', () { + test('accepts valid data', () { + expect(personSchema.validateSync({'name': 'Ada', 'age': 36}), isEmpty); + }); + + test('reports the same errors as validate', () async { + const Object data = {'age': -1}; + final List asyncErrors = await personSchema.validate( + data, + ); + final List syncErrors = personSchema.validateSync(data); + + expect(syncErrors, isNotEmpty); + expect( + syncErrors.map((ValidationError e) => e.toErrorString()), + asyncErrors.map((ValidationError e) => e.toErrorString()), + ); + }); + + test('honors strictFormat', () { + final schema = Schema.fromMap({'type': 'string', 'format': 'email'}); + + expect(schema.validateSync('not-an-email'), isEmpty); + expect( + schema + .validateSync('not-an-email', strictFormat: true) + .map((ValidationError e) => e.error), + [ValidationErrorType.formatInvalid], + ); + }); + + test('resolves references within the schema', () { + final schema = Schema.fromMap({ + r'$defs': { + 'positiveInt': {'type': 'integer', 'minimum': 1}, + }, + 'type': 'array', + 'items': {r'$ref': r'#/$defs/positiveInt'}, + }); + + expect(schema.validateSync([1, 2, 3]), isEmpty); + expect(schema.validateSync([1, 0]), isNotEmpty); + expect(schema.validateSync([1, 'two']), isNotEmpty); + }); + + test('resolves references to schemas in the registry', () { + final registry = SchemaRegistry() + ..addSchema( + Uri.parse('https://example.com/name.json'), + Schema.fromMap({'type': 'string', 'minLength': 1}), + ); + final schema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'name': {r'$ref': 'https://example.com/name.json'}, + }, + }); + + expect( + schema.validateSync({'name': 'Ada'}, schemaRegistry: registry), + isEmpty, + ); + expect( + schema.validateSync({'name': ''}, schemaRegistry: registry), + isNotEmpty, + ); + }); + + test('throws when a reference would have to be fetched', () { + final schema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'name': {r'$ref': 'https://example.com/name.json'}, + }, + }); + + expect( + () => schema.validateSync({'name': 'Ada'}), + throwsA( + isA().having( + (SchemaResolutionRequiredException e) => e.uri, + 'uri', + Uri.parse('https://example.com/name.json'), + ), + ), + ); + }); + + test( + 'throws rather than passing data an unfetched schema would reject', + () { + final schema = Schema.fromMap({ + r'$ref': 'https://example.com/name.json', + }); + + // Without the reference resolved, this data is unconstrained. Silently + // treating it as valid would turn a missing fetch into a false pass. + expect( + () => schema.validateSync(42), + throwsA(isA()), + ); + }, + ); + + test('throws when the meta schema would have to be fetched', () { + final schema = Schema.fromMap({ + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'string', + }); + + expect( + () => schema.validateSync('hello'), + throwsA( + isA().having( + (SchemaResolutionRequiredException e) => e.uri, + 'uri', + Uri.parse('https://json-schema.org/draft/2020-12/schema'), + ), + ), + ); + }); + + test('succeeds against a registry warmed up by validate', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/name.json': {'type': 'string', 'minLength': 1}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'name': {r'$ref': 'https://example.com/name.json'}, + }, + }); + + // The synchronous path cannot fetch the reference... + expect( + () => schema.validateSync({'name': ''}, schemaRegistry: registry), + throwsA(isA()), + ); + + // ...but once the asynchronous path has fetched it into the registry, + // every later validation can be synchronous. + expect( + await schema.validate({'name': 'Ada'}, schemaRegistry: registry), + isEmpty, + ); + expect( + schema.validateSync({'name': 'Ada'}, schemaRegistry: registry), + isEmpty, + ); + expect( + schema + .validateSync({'name': ''}, schemaRegistry: registry) + .map((ValidationError e) => e.error), + contains(ValidationErrorType.minLengthNotMet), + ); + }); + + test( + 'asks for a fetch that a previous validation failed to make', + () async { + final SchemaRegistry registry = _registryServing(const {}); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'name': {r'$ref': 'https://example.com/missing.json'}, + }, + }); + + // The asynchronous path tries the fetch, which fails, and reports the + // failure as a reference resolution error. + final List asyncErrors = await schema.validate({ + 'name': 'Ada', + }, schemaRegistry: registry); + expect( + asyncErrors.map((ValidationError e) => e.error), + contains(ValidationErrorType.refResolutionError), + ); + + // That failure belongs to the validation that made it, not to the + // registry: the reference is still unresolved, so the synchronous path + // still refuses to guess at it. + expect( + () => schema.validateSync({'name': 'Ada'}, schemaRegistry: registry), + throwsA(isA()), + ); + }, + ); + + test('retries a fetch that a previous validation failed to make', () async { + var attempts = 0; + final client = MockClient((http.Request request) async { + attempts++; + if (attempts == 1) return http.Response('Not found', 404); + return http.Response(jsonEncode({'type': 'string'}), 200); + }); + final registry = SchemaRegistry( + schemaCache: SchemaCache(httpClient: client), + ); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$ref': 'https://example.com/flaky.json', + }); + + expect( + (await schema.validate( + 1, + schemaRegistry: registry, + )).map((ValidationError e) => e.error), + contains(ValidationErrorType.refResolutionError), + ); + // The second validation retries the fetch rather than reusing the + // failure, and this time the schema rejects the data on its merits. + expect( + (await schema.validate( + 1, + schemaRegistry: registry, + )).map((ValidationError e) => e.error), + contains(ValidationErrorType.typeMismatch), + ); + expect(attempts, 2); + }); + }); + + group('validate', () { + test('still fetches remote references', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/name.json': {'type': 'string', 'minLength': 1}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({r'$ref': 'https://example.com/name.json'}); + + expect(await schema.validate('Ada', schemaRegistry: registry), isEmpty); + expect( + (await schema.validate( + '', + schemaRegistry: registry, + )).map((ValidationError e) => e.error), + contains(ValidationErrorType.minLengthNotMet), + ); + }); + + test('fetches independent remote references in parallel', () async { + var inFlight = 0; + var mostInFlight = 0; + final bothArrived = Completer(); + final client = MockClient((http.Request request) async { + inFlight++; + mostInFlight = max(mostInFlight, inFlight); + if (inFlight == 2 && !bothArrived.isCompleted) bothArrived.complete(); + // Hold each request open until the other one arrives, so that the two + // can only both complete if they were made concurrently. + await bothArrived.future.timeout( + const Duration(seconds: 5), + onTimeout: () {}, + ); + inFlight--; + return http.Response(jsonEncode({'type': 'string'}), 200); + }); + final registry = SchemaRegistry( + schemaCache: SchemaCache(httpClient: client), + ); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + 'type': 'object', + 'properties': { + 'first': {r'$ref': 'https://example.com/first.json'}, + 'second': {r'$ref': 'https://example.com/second.json'}, + }, + }); + + expect( + await schema.validate({ + 'first': 'Ada', + 'second': 'Grace', + }, schemaRegistry: registry), + isEmpty, + ); + expect(mostInFlight, 2); + }); + + test('a reference it never reaches does not fail the validation', () async { + final SchemaRegistry registry = _registryServing(const {}); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + 'type': 'string', + r'$defs': { + 'unused': {r'$ref': 'https://example.com/missing.json'}, + }, + }); + + // The reference is prefetched and the fetch fails, but nothing validates + // against it, so the data is still valid. + expect(await schema.validate('Ada', schemaRegistry: registry), isEmpty); + }); + + test('fetches a chain of remote references', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/a.json': {r'$ref': 'https://example.com/b.json'}, + 'https://example.com/b.json': {r'$ref': 'https://example.com/c.json'}, + 'https://example.com/c.json': {'type': 'integer'}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({r'$ref': 'https://example.com/a.json'}); + + expect(await schema.validate(1, schemaRegistry: registry), isEmpty); + expect( + await schema.validate('one', schemaRegistry: registry), + isNotEmpty, + ); + }); + }); + group('asynchronous helpers', () { + /// Creates the context that validation of [schema] starts from, the way + /// the entry points do. + ValidationContext contextFor(Schema schema, {SchemaRegistry? registry}) { + final SchemaRegistry schemaRegistry = registry ?? SchemaRegistry(); + final Uri sourceUri = Uri.parse('local://schema'); + schemaRegistry.addSchema(sourceUri, schema); + return ValidationContext( + schema, + sourceUri: sourceUri, + schemaRegistry: schemaRegistry, + ); + } + + test('validateSubSchema applies a subschema', () async { + final ValidationContext context = contextFor(personSchema); + + expect( + (await validateSubSchema( + {'type': 'string'}, + 'Ada', + [], + context, + [], + )).isValid, + isTrue, + ); + expect( + (await validateSubSchema( + {'type': 'string'}, + 1, + [], + context, + [], + )).isValid, + isFalse, + ); + // A boolean schema accepts or rejects everything. + expect( + (await validateSubSchema(true, 'Ada', [], context, [])).isValid, + isTrue, + ); + expect( + (await validateSubSchema(false, 'Ada', [], context, [])).isValid, + isFalse, + ); + // Anything that is not a schema at all constrains nothing. + expect( + (await validateSubSchema(42, 'Ada', [], context, [])).isValid, + isTrue, + ); + }); + + test('validateSchema fetches the references it needs', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/name.json': {'type': 'string', 'minLength': 1}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({r'$ref': 'https://example.com/name.json'}); + final ValidationContext context = contextFor(schema, registry: registry); + + expect( + (await schema.validateSchema('Ada', [], context, [schema])).isValid, + isTrue, + ); + expect( + (await schema.validateSchema('', [], context, [ + schema, + ])).errors.map((ValidationError e) => e.error), + contains(ValidationErrorType.minLengthNotMet), + ); + }); + + test( + 'validateTypeSpecificKeywords applies the keywords for the type', + () async { + final schema = Schema.fromMap({'type': 'integer', 'maximum': 10}); + final ValidationContext context = contextFor(schema); + + expect( + (await schema.validateTypeSpecificKeywords(5, [], context, [ + schema, + ])).isValid, + isTrue, + ); + expect( + (await schema.validateTypeSpecificKeywords(11, [], context, [ + schema, + ])).errors.map((ValidationError e) => e.error), + contains(ValidationErrorType.maximumExceeded), + ); + }, + ); + + test('validateObject applies the object keywords', () async { + final ValidationContext context = contextFor(personSchema); + + expect( + (await personSchema.validateObject( + {'name': 'Ada'}, + [], + context, + [personSchema], + )).isValid, + isTrue, + ); + expect( + (await personSchema.validateObject( + {'age': 36}, + [], + context, + [personSchema], + )).errors.map((ValidationError e) => e.error), + contains(ValidationErrorType.requiredPropertyMissing), + ); + }); + + test('validateList applies the list keywords', () async { + final schema = Schema.fromMap({ + 'type': 'array', + 'minItems': 2, + 'items': {'type': 'integer'}, + }); + final ValidationContext context = contextFor(schema); + + expect( + (await schema.validateList([1, 2], [], context, [schema])).isValid, + isTrue, + ); + expect( + (await schema.validateList( + [1], + [], + context, + [schema], + )).errors.map((ValidationError e) => e.error), + contains(ValidationErrorType.minItemsNotMet), + ); + }); + + test( + 'resolveRef resolves a local reference and fetches a remote one', + () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/name.json': {'type': 'string'}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$defs': { + 'positiveInt': {'type': 'integer', 'minimum': 1}, + }, + }); + final ValidationContext context = contextFor( + schema, + registry: registry, + ); + + final (Schema, Uri)? local = await schema.resolveRef( + r'#/$defs/positiveInt', + schema, + context, + ); + expect(local?.$1.value, {'type': 'integer', 'minimum': 1}); + + final (Schema, Uri)? remote = await schema.resolveRef( + 'https://example.com/name.json', + schema, + context, + ); + expect(remote?.$1.value, {'type': 'string'}); + expect(remote?.$2, Uri.parse('https://example.com/name.json')); + + expect( + await schema.resolveRef(r'#/$defs/missing', schema, context), + isNull, + ); + }, + ); + + test('resolveDynamicRef follows the dynamic scope', () async { + final schema = Schema.fromMap({ + r'$id': 'https://example.com/root.json', + r'$defs': { + 'item': {r'$dynamicAnchor': 'item', 'type': 'integer'}, + }, + }); + final ValidationContext context = contextFor(schema); + + final (Schema, Uri)? resolved = await schema.resolveDynamicRef('#item', [ + schema, + ], context); + expect(resolved?.$1.value, { + r'$dynamicAnchor': 'item', + 'type': 'integer', + }); + + expect( + await schema.resolveDynamicRef('#missing', [schema], context), + isNull, + ); + }); + }); + + group('reference resolution failures', () { + test(r'reports an unresolvable $dynamicRef', () async { + final schema = Schema.fromMap({r'$dynamicRef': r'#/$defs/missing'}); + + expect( + (await schema.validate( + 'anything', + )).map((ValidationError e) => e.toErrorString()), + contains(contains('Failed to resolve dynamic reference')), + ); + }); + + test('reports a meta schema that cannot be fetched', () async { + final SchemaRegistry registry = _registryServing(const {}); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$schema': 'https://example.com/meta.json', + 'type': 'string', + }); + + expect( + (await schema.validate( + 'Ada', + schemaRegistry: registry, + )).map((ValidationError e) => e.toErrorString()), + contains(contains('Failed to resolve meta schema')), + ); + }); + + test( + 'keeps every vocabulary for a meta schema that declares none', + () async { + final registry = SchemaRegistry() + ..addSchema( + Uri.parse('https://example.com/meta.json'), + Schema.fromMap({'type': 'object'}), + ); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$schema': 'https://example.com/meta.json', + 'type': 'string', + 'minLength': 3, + }); + + // The meta schema has no $vocabulary, so validation keeps all of them + // and minLength still applies. + expect(schema.validateSync('abc', schemaRegistry: registry), isEmpty); + expect( + schema + .validateSync('ab', schemaRegistry: registry) + .map((ValidationError e) => e.error), + contains(ValidationErrorType.minLengthNotMet), + ); + }, + ); + + test('treats a fetch that produces no schema as unresolved', () async { + final registry = SchemaRegistry(schemaCache: _EmptySchemaCache()); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$ref': 'https://example.com/nothing.json', + }); + + expect( + (await schema.validate( + 'Ada', + schemaRegistry: registry, + )).map((ValidationError e) => e.error), + contains(ValidationErrorType.refResolutionError), + ); + }); + }); + + group('SchemaRegistry', () { + test('resolve returns a registered schema, fragment and all', () async { + final registry = SchemaRegistry(); + addTearDown(registry.dispose); + registry.addSchema( + Uri.parse('https://example.com/root.json'), + Schema.fromMap({ + r'$defs': { + 'name': {'type': 'string'}, + }, + }), + ); + + expect( + (await registry.resolve( + Uri.parse(r'https://example.com/root.json#/$defs/name'), + ))?.value, + {'type': 'string'}, + ); + expect( + await registry.resolve( + Uri.parse(r'https://example.com/root.json#/$defs/missing'), + ), + isNull, + ); + }); + + test('resolve fetches a schema it does not hold', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/name.json': {'type': 'string'}, + }); + addTearDown(registry.dispose); + + expect( + (await registry.resolve( + Uri.parse('https://example.com/name.json'), + ))?.value, + {'type': 'string'}, + ); + // The schema is registered now, so the synchronous path can see it. + expect( + registry.resolveSync(Uri.parse('https://example.com/name.json'))?.value, + {'type': 'string'}, + ); + }); + + test('prefetchDependencies fetches a chain of references', () async { + final SchemaRegistry registry = _registryServing({ + 'https://example.com/a.json': {r'$ref': 'https://example.com/b.json'}, + 'https://example.com/b.json': {'type': 'integer'}, + }); + addTearDown(registry.dispose); + final schema = Schema.fromMap({r'$ref': 'https://example.com/a.json'}); + + expect( + await registry.prefetchDependencies( + schema, + baseUri: Uri.parse('local://schema'), + ), + isEmpty, + ); + // Both links of the chain are in the registry now, so the synchronous + // path can see them. + expect( + registry.resolveSync(Uri.parse('https://example.com/b.json'))?.value, + {'type': 'integer'}, + ); + }); + + test('prefetchDependencies reports what it could not bring in', () async { + final SchemaRegistry registry = _registryServing(const {}); + addTearDown(registry.dispose); + final schema = Schema.fromMap({ + r'$ref': 'https://example.com/missing.json', + }); + + final Map unresolved = await registry + .prefetchDependencies(schema, baseUri: Uri.parse('local://schema')); + + expect(unresolved.keys, [Uri.parse('https://example.com/missing.json')]); + expect(unresolved.values.single, isA()); + }); + + test('resolve reports a fetch that produces no schema as null', () async { + final registry = SchemaRegistry(schemaCache: _EmptySchemaCache()); + addTearDown(registry.dispose); + + expect( + await registry.resolve(Uri.parse('https://example.com/nothing.json')), + isNull, + ); + }); + }); + + group('SchemaResolutionRequiredException', () { + test('names the schema that would have to be fetched', () { + final exception = SchemaResolutionRequiredException( + Uri.parse('https://example.com/name.json'), + ); + + expect(exception.toString(), contains('https://example.com/name.json')); + expect(exception.toString(), contains('SchemaRegistry')); + }); + }); +} + +/// A cache whose fetches succeed without producing a schema. +class _EmptySchemaCache extends SchemaCache { + @override + Future get(Uri uri) async => null; +} diff --git a/packages/json_schema_builder/test/test_suite_test.dart b/packages/json_schema_builder/test/test_suite_test.dart index 63c399028..6b145c505 100644 --- a/packages/json_schema_builder/test/test_suite_test.dart +++ b/packages/json_schema_builder/test/test_suite_test.dart @@ -54,6 +54,21 @@ void main() { schemaRegistry.addSchema(uri, schema); } + // Test cases the synchronous path could not run because they still needed a + // schema fetched. Everything the remotes directory covers is registered + // above, and the asynchronous run of each case fetches anything else into the + // registry before the synchronous run, so this is expected to stay empty. + final syncSkipped = []; + tearDownAll(() { + expect( + syncSkipped, + isEmpty, + reason: + 'Cases that the synchronous validation path did not cover. Register ' + 'the schemas they reference so that both paths run.', + ); + }); + for (final File file in testFilePaths.map(File.new)) { final String content = file.readAsStringSync(); final tests = jsonDecode(content) as List; @@ -105,6 +120,32 @@ void main() { 'Log:\n${loggingContext.buffer}', ); } + + // Run the same case through the synchronous path. The asynchronous + // run above fetched anything remote into `schemaRegistry`, which is + // the precondition `validateSync` documents, so the two runs must + // produce exactly the same errors. + final List syncErrors; + try { + syncErrors = schema.validateSync( + data, + sourceUri: file.uri, + schemaRegistry: schemaRegistry, + loggingContext: loggingContext, + ); + } on SchemaResolutionRequiredException catch (e) { + // This case still needs a schema fetched, which the synchronous + // path deliberately refuses to do. + syncSkipped.add('$groupDescription / $testDescription: ${e.uri}'); + return; + } + expect( + syncErrors.map((ValidationError e) => e.toErrorString()), + errors.map((ValidationError e) => e.toErrorString()), + reason: + 'Synchronous validation disagreed with asynchronous ' + 'validation.\nLog:\n${loggingContext.buffer}', + ); }); } });