Skip to content
Open
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
8 changes: 4 additions & 4 deletions coverage_baseline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
# found in the LICENSE file.

# Monorepo Coverage High-Water Marks (Auto-generated by test_and_fix --update-baseline)
dev_tools/catalog_gallery: 51.66
dev_tools/composer: 20.28
packages/a2ui_core: 76.30
dev_tools/catalog_gallery: 51.43
dev_tools/composer: 20.29
packages/a2ui_core: 76.54
packages/genai_primitives: 100.00
packages/genui: 79.38
packages/genui: 79.66
packages/genui_a2a: 91.37
packages/json_schema_builder: 79.09
tool/e2e: 100.00
Expand Down
4 changes: 1 addition & 3 deletions dev_tools/catalog_gallery/integration_test/app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ void main() {
testWidgets('catalog_gallery smoke test - verify initial state', (
tester,
) async {
await tester.pumpWidget(
CatalogGalleryApp(sampleSource: sampleSource),
);
await tester.pumpWidget(CatalogGalleryApp(sampleSource: sampleSource));
await tester.pumpAndSettle();

expect(find.text('Catalog Gallery'), findsOneWidget);
Expand Down
4 changes: 1 addition & 3 deletions dev_tools/catalog_gallery/lib/sample_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ class DirectorySampleSource implements SampleSource {
final List<SampleRef> refs = files
.map(
(file) => SampleRef(
name: directory.fileSystem.path.basenameWithoutExtension(
file.path,
),
name: directory.fileSystem.path.basenameWithoutExtension(file.path),
load: file.readAsString,
),
)
Expand Down
1 change: 1 addition & 0 deletions packages/genui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.10.0 (in progress)

- **BREAKING**: Changed `SurfaceDefinition.validate` to be asynchronous to support resolving `$ref` schemas via `SchemaRegistry`.
- **Refactor**: genui now runs on `package:a2ui_core`. See
[the migration guide](../../docs/usage/migration/migration_0.9.1_to_0.10.0.md).
- **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI
Expand Down
17 changes: 17 additions & 0 deletions packages/genui/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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.

targets:
$default:
builders:
genui|schema_builder:
enabled: true

builders:
schema_builder:
import: "tool/schema_builder.dart"
builder_factories: ["schemaBuilder"]
build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.g.dart"]}
auto_apply: none
build_to: source
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:json_schema_builder/json_schema_builder.dart';

Expand Down Expand Up @@ -230,10 +232,13 @@ Future<void> _handlePress(
final Stream<Object?> resultStream = itemContext.dataContext.resolve(
funcMap,
);
final iterator = StreamIterator<Object?>(resultStream);
try {
await resultStream.first;
await iterator.moveNext();
} catch (exception, stackTrace) {
itemContext.reportError(exception, stackTrace);
} finally {
await iterator.cancel();
}
} else {
genUiLogger.warning(
Expand Down
59 changes: 44 additions & 15 deletions packages/genui/lib/src/engine/surface_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'dart:convert';
import 'package:a2ui_core/a2ui_core.dart' as core;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:json_schema_builder/json_schema_builder.dart';

import '../interfaces/a2ui_message_sink.dart';
import '../interfaces/surface_context.dart';
Expand All @@ -19,6 +20,8 @@ import '../model/data_model.dart';
import '../model/schema_validation.dart' as schema_validation;
import '../model/ui_models.dart';
import '../primitives/a2ui_validation_exception.dart';
import '../primitives/constants.dart';
import '../primitives/embedded_schemas.g.dart';
import '../primitives/logging.dart';

import 'surface_registry.dart' as surface_reg;
Expand Down Expand Up @@ -54,6 +57,18 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink {
late final surface_reg.SurfaceRegistry _registry =
surface_reg.SurfaceRegistry();
final Map<String, DataModel> _liveDataModels = {};
SchemaRegistry? _schemaRegistry;

SchemaRegistry _getSchemaRegistry() {
if (_schemaRegistry != null) return _schemaRegistry!;
final registry = SchemaRegistry();
registry.addSchema(
Uri.parse(commonTypesSchemaId),
Schema.fromMap(jsonDecode(commonTypesSchemaJson) as Map<String, Object?>),
);
_schemaRegistry = registry;
return registry;
}

final _onSubmit = StreamController<ChatMessage>.broadcast();
final _pendingUpdates = <String, List<core.A2uiMessage>>{};
Expand Down Expand Up @@ -207,18 +222,30 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink {
_registry.notifyUpdated(surface);
// Validation does not roll back the mutation; we surface the error
// and let the caller decide.
try {
final Catalog? genuiCatalog = catalogs.firstWhereOrNull(
(c) => c.effectiveCatalogId == surface.catalog.id,
);
if (genuiCatalog != null) {
_validateComponents(coreMessage.surfaceId, surface, genuiCatalog);
}
} on A2uiValidationException catch (e) {
genUiLogger.warning(
'Schema validation failed for surface ${e.surfaceId}: $e',
);
reportError(e, StackTrace.current);
final Catalog? genuiCatalog = catalogs.firstWhereOrNull(
(c) => c.effectiveCatalogId == surface.catalog.id,
);
if (genuiCatalog != null) {
_validateComponents(
coreMessage.surfaceId,
surface,
genuiCatalog,
).catchError((Object error, StackTrace stack) {
if (_onSubmit.isClosed) return;
if (error is A2uiValidationException) {
Comment thread
gspencergoog marked this conversation as resolved.
genUiLogger.warning(
'Schema validation failed for surface '
'${error.surfaceId}: $error',
);
reportError(error, stack);
} else {
genUiLogger.severe(
'Unexpected error during surface validation',
error,
stack,
);
}
});
}
}
}
Expand Down Expand Up @@ -331,17 +358,19 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink {

/// Validates the components currently in [surface] against [catalog]'s
/// schema. Throws [A2uiValidationException] on the first failing component.
void _validateComponents(
Future<void> _validateComponents(
String surfaceId,
core.SurfaceModel<core.ComponentApi> surface,
Catalog catalog,
) {
schema_validation.validateComponents(
) async {
final SchemaRegistry registry = _getSchemaRegistry();
await schema_validation.validateComponents(
surfaceId: surfaceId,
components: surface.componentsModel.all.map(
(c) => (id: c.id, type: c.type, json: c.toJson()),
),
schema: catalog.definition,
registry: registry,
);
}

Expand Down
91 changes: 12 additions & 79 deletions packages/genui/lib/src/model/schema_validation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import '../primitives/simple_items.dart';
///
/// Throws [A2uiValidationException] on the first component that fails. State
/// is not rolled back.
void validateComponents({
Future<void> validateComponents({
required String surfaceId,
required Iterable<({String id, String type, JsonMap json})> components,
required Schema schema,
}) {
required SchemaRegistry registry,
}) async {
final List<Map<String, Object?>> allowedSchemas = _extractAllowedSchemas(
schema.value,
);
Expand All @@ -28,12 +29,16 @@ void validateComponents({
for (final s in allowedSchemas) {
if (_schemaMatchesType(s, component.type)) {
try {
_validateInstance(
component.json,
final List<ValidationError> validationErrors = await Schema.fromMap(
s,
'/components/${component.id}',
surfaceId,
);
).validate(component.json, schemaRegistry: registry);
if (validationErrors.isNotEmpty) {
throw A2uiValidationException(
validationErrors.join('; '),
surfaceId: surfaceId,
path: '/components/${component.id}',
);
}
matched = true;
break;
} catch (e) {
Expand Down Expand Up @@ -98,75 +103,3 @@ bool _schemaMatchesType(Map<String, Object?> schema, String type) {
}
return false;
}

void _validateInstance(
Object? instance,
Map<String, Object?> schema,
String path,
String surfaceId,
) {
if (instance == null) return;

if (schema case {'const': Object? constVal} when instance != constVal) {
throw A2uiValidationException(
'Value mismatch. Expected $constVal, got $instance',
surfaceId: surfaceId,
path: path,
);
}
if (schema case {
'enum': List<Object?> enums,
} when !enums.contains(instance)) {
throw A2uiValidationException(
'Value not in enum: $instance',
surfaceId: surfaceId,
path: path,
);
}
if (schema case {'required': List<Object?> required} when instance is Map) {
for (final String key in required.cast<String>()) {
if (!instance.containsKey(key)) {
throw A2uiValidationException(
'Missing required property: $key',
surfaceId: surfaceId,
path: path,
);
}
}
}
if (schema case {
'properties': Map<String, Object?> props,
} when instance is Map) {
for (final MapEntry<String, Object?> entry in props.entries) {
final String key = entry.key;
final propSchema = entry.value as Map<String, Object?>;
if (instance.containsKey(key)) {
_validateInstance(instance[key], propSchema, '$path/$key', surfaceId);
}
}
}
if (schema case {
'items': Map<String, Object?> itemsSchema,
} when instance is List) {
for (var i = 0; i < instance.length; i++) {
_validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId);
}
}
if (schema case {'oneOf': List<Object?> oneOfs}) {
var oneMatched = false;
for (final Map<String, Object?> s in oneOfs.cast<Map<String, Object?>>()) {
try {
_validateInstance(instance, s, path, surfaceId);
oneMatched = true;
break;
} catch (_) {}
}
if (!oneMatched) {
throw A2uiValidationException(
'Value did not match any oneOf schema',
surfaceId: surfaceId,
path: path,
);
}
}
}
5 changes: 3 additions & 2 deletions packages/genui/lib/src/model/ui_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,14 @@ class SurfaceDefinition {
}

/// Validates the UI definition against a schema.
void validate(Schema schema) {
schema_validation.validateComponents(
Future<void> validate(Schema schema, {SchemaRegistry? registry}) async {
await schema_validation.validateComponents(
surfaceId: surfaceId,
components: components.values.map(
(c) => (id: c.id, type: c.type, json: c.toJson()),
),
schema: schema,
registry: registry ?? SchemaRegistry(),
);
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/genui/lib/src/primitives/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
/// The catalog ID for the basic catalog.
const String basicCatalogId =
'https://a2ui.org/specification/v0_9/basic_catalog.json';

/// The schema URI for common A2UI types.
const String commonTypesSchemaId =
'https://a2ui.org/specification/v0_9/common_types.json';
Loading
Loading