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
1 change: 1 addition & 0 deletions packages/genui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.9.2
Copy link
Copy Markdown
Collaborator

@polina-c polina-c Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to increase version. You did not get error about it, because validation arrived later than you created this fix.


- **Feature**: Updated example/README.md.
- **Fix**: `SurfaceDefinition.validate` now strictly enforces primitive data types, correctly identifying and rejecting malformed components (like numbers passed for strings) before they can cause fatal rendering crashes.

## 0.9.0

Expand Down
30 changes: 30 additions & 0 deletions packages/genui/lib/src/model/ui_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,36 @@ class SurfaceDefinition {
Map<String, dynamic> schema,
String path,
) {
if (schema case {'type': Object expectedType}) {
final List<String> types = expectedType is List
? expectedType.map((e) => e.toString()).toList()
: [expectedType.toString()];

final bool isValid = types.any(
(t) => switch (t) {
'string' => instance is String,
'number' => instance is num,
// A whole-valued double such as 42.0 is a valid integer.
'integer' =>
instance is num && (instance is int || instance.remainder(1) == 0),
'boolean' => instance is bool,
'object' => instance is Map,
'array' => instance is List,
'null' => instance == null,
_ => false,
},
);

if (!isValid) {
final Object actualType = instance?.runtimeType ?? 'null';
throw A2uiValidationException(
'Type mismatch. Expected $types, got $actualType',
surfaceId: surfaceId,
path: path,
);
}
}

if (instance == null) {
return;
}
Expand Down
210 changes: 208 additions & 2 deletions packages/genui/test/model/ui_models_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ void main() {
components: {'test': component},
);

// Schema invalidating the component (e.g., expecting type "Button")
final schema = S.object(
properties: {
'components': S.list(
Expand Down Expand Up @@ -116,7 +115,214 @@ void main() {
},
);

surfaceDefinition.validate(schema); // Should not throw
surfaceDefinition.validate(schema); // Should not throw.
});

test('validate enforces primitive types: string', () {
final component = const Component(
id: 'test',
type: 'Text',
// int instead of string.
properties: {'text': 42},
);
final surfaceDefinition = SurfaceDefinition(
surfaceId: 's1',
components: {'test': component},
);

final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'Text'),
'text': S.string(),
},
),
),
},
);

expect(
() => surfaceDefinition.validate(schema),
throwsA(
isA<A2uiValidationException>().having(
(e) => e.message,
'message',
contains('Type mismatch'),
),
),
);
});
Comment thread
andrewkolos marked this conversation as resolved.

test('validate enforces primitive types: boolean', () {
final component = const Component(
id: 'test',
type: 'Checkbox',
properties: {'checked': 'true'}, // string instead of bool.
);
final surfaceDefinition = SurfaceDefinition(
surfaceId: 's1',
components: {'test': component},
);

final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'Checkbox'),
'checked': S.boolean(),
},
),
),
},
);

expect(
() => surfaceDefinition.validate(schema),
throwsA(isA<A2uiValidationException>()),
);
});

test('validate enforces primitive types: '
'number vs integer', () {
final componentInt = const Component(
id: 'test1',
type: 'Slider',
properties: {'value': 42}, // Valid integer.
);
final componentDouble = const Component(
id: 'test2',
type: 'Slider',
properties: {'value': 42.5}, // Valid number, invalid integer.
);
// JSON decodes whole numbers like 42.0 to a Dart double; per JSON
// Schema a double with a zero fractional part is still an integer.
final componentWholeDouble = const Component(
id: 'test3',
type: 'Slider',
properties: {'value': 42.0},
);

final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'Slider'),
'value': S.integer(),
},
),
),
},
);

SurfaceDefinition(
surfaceId: 's1',
components: {'test1': componentInt},
).validate(schema);

SurfaceDefinition(
surfaceId: 's3',
components: {'test3': componentWholeDouble},
).validate(schema);

expect(
() => SurfaceDefinition(
surfaceId: 's2',
components: {'test2': componentDouble},
).validate(schema),
throwsA(isA<A2uiValidationException>()),
);
});

test('validate accepts both int and double for number type', () {
final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'Slider'),
'value': S.number(),
},
),
),
},
);

for (final value in const <Object>[42, 42.5]) {
SurfaceDefinition(
surfaceId: 's1',
components: {
'test': Component(
id: 'test',
type: 'Slider',
properties: {'value': value},
),
},
).validate(schema);
}
});

test('validate rejects explicit null for a non-nullable type', () {
final component = const Component(
id: 'test',
type: 'Text',
properties: {'text': null}, // null where a string is required.
);
final surfaceDefinition = SurfaceDefinition(
surfaceId: 's1',
components: {'test': component},
);

final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'Text'),
'text': S.string(),
},
),
),
},
);

expect(
() => surfaceDefinition.validate(schema),
throwsA(isA<A2uiValidationException>()),
);
});

test('validate enforces primitive types: array and object', () {
final component = const Component(
id: 'test',
type: 'List',
properties: {'items': 42}, // int instead of array.
);
final surfaceDefinition = SurfaceDefinition(
surfaceId: 's1',
components: {'test': component},
);

final schema = S.object(
properties: {
'components': S.list(
items: S.object(
properties: {
'component': S.string(constValue: 'List'),
'items': S.list(items: S.string()),
},
),
),
},
);

expect(
() => surfaceDefinition.validate(schema),
throwsA(isA<A2uiValidationException>()),
);
});
});

Expand Down
Loading