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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## 2.0.0

### Breaking Changes

- **JSON Schema API**:
- `JsonObject.additionalProperties` and `JsonSchema.object(additionalProperties: ...)` now use `Object?` instead of `bool?`.
- `additionalProperties` may now be either `bool` or `JsonSchema`, matching the JSON Schema specification.

### Reliability

- **JSON Schema Parsing**:
- Fixed `JsonObject.fromJson` to accept untyped map values for `additionalProperties` (for example `{}` decoded as `Map<dynamic, dynamic>`), so schema objects are not silently dropped.

## 1.3.0

- **Spec Alignment**:
Expand Down
22 changes: 18 additions & 4 deletions lib/src/shared/json_schema/json_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ sealed class JsonSchema {
static JsonObject object({
Map<String, JsonSchema>? properties,
List<String>? required,
bool? additionalProperties,
Object? additionalProperties,
Map<String, List<String>>? dependentRequired,
String? title,
String? description,
Expand Down Expand Up @@ -530,7 +530,9 @@ class JsonArray extends JsonSchema {
class JsonObject extends JsonSchema {
final Map<String, JsonSchema>? properties;
final List<String>? required;
final bool? additionalProperties;

/// Can be a [bool] (true/false) or a [JsonSchema] constraining extra properties.
final Object? additionalProperties;
final Map<String, List<String>>? dependentRequired;

const JsonObject({
Expand All @@ -547,6 +549,16 @@ class JsonObject extends JsonSchema {
final Map<String, dynamic>? defaultValue;

factory JsonObject.fromJson(Map<String, dynamic> json) {
final additionalProps = json['additionalProperties'];
Object? parsedAdditionalProps;
if (additionalProps is bool) {
parsedAdditionalProps = additionalProps;
} else if (additionalProps is Map) {
parsedAdditionalProps = JsonSchema.fromJson(
Map<String, dynamic>.from(additionalProps),
);
}

return JsonObject(
properties: (json['properties'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(
Expand All @@ -555,7 +567,7 @@ class JsonObject extends JsonSchema {
),
),
required: (json['required'] as List?)?.cast<String>(),
additionalProperties: json['additionalProperties'] as bool?,
additionalProperties: parsedAdditionalProps,
dependentRequired:
(json['dependentRequired'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(
Expand All @@ -580,7 +592,9 @@ class JsonObject extends JsonSchema {
'properties': properties!.map((k, v) => MapEntry(k, v.toJson())),
if (required != null && required!.isNotEmpty) 'required': required,
if (additionalProperties != null)
'additionalProperties': additionalProperties,
'additionalProperties': additionalProperties is JsonSchema
? (additionalProperties as JsonSchema).toJson()
: additionalProperties,
if (dependentRequired != null) 'dependentRequired': dependentRequired,
};
}
Expand Down
14 changes: 9 additions & 5 deletions lib/src/shared/json_schema/json_schema_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,21 @@ extension JsonSchemaValidation on JsonSchema {
final dataKeys = data.keys.cast<String>().toSet();
final extraKeys = dataKeys.difference(definedKeys);

// additionalProperties is a bool? in JsonObject
// additionalProperties can be bool or JsonSchema.
if (schema.additionalProperties == false && extraKeys.isNotEmpty) {
throw JsonSchemaValidationException(
'Additional properties not allowed: ${extraKeys.join(', ')}',
path,
);
}
// Note: JsonSchema implementation of additionalProperties as Schema is NOT in JsonObject class currently
// The class definition has `bool? additionalProperties`.
// If we wanted schema-based additionalProperties, JsonObject needs update.
// Proceeding with what is available.

// When additionalProperties is a JsonSchema, validate each extra key against it.
if (schema.additionalProperties is JsonSchema && extraKeys.isNotEmpty) {
final apSchema = schema.additionalProperties as JsonSchema;
for (final key in extraKeys) {
_validate(apSchema, data[key], [...path, key]);
}
}
}

void _validateEnum(JsonEnum schema, dynamic data, List<String> path) {
Expand Down
62 changes: 62 additions & 0 deletions test/shared/json_schema_from_json_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,48 @@ void main() {
});
});

test('parses object schema with additionalProperties as schema', () {
final json = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
'additionalProperties': {'type': 'string'},
};
final schema = JsonSchema.fromJson(json);
expect(schema, isA<JsonObject>());
final s = schema as JsonObject;
expect(s.additionalProperties, isA<JsonString>());
});

test('parses object schema with untyped additionalProperties map', () {
// This is what z.record(z.string(), z.unknown()) produces
final json = {'type': 'object', 'additionalProperties': {}};
final schema = JsonSchema.fromJson(json);
expect(schema, isA<JsonObject>());
final s = schema as JsonObject;
expect(s.additionalProperties, isA<JsonAny>());
});

test('parses nested object with additionalProperties as schema', () {
// Simulates a tool schema with a nested record/map property:
// e.g. z.object({ config: z.record(z.string(), z.unknown()) })
final json = {
'type': 'object',
'properties': {
'config': {
'type': 'object',
'additionalProperties': <String, dynamic>{},
},
},
};
final schema = JsonSchema.fromJson(json);
expect(schema, isA<JsonObject>());
final s = schema as JsonObject;
final config = s.properties!['config'] as JsonObject;
expect(config.additionalProperties, isA<JsonAny>());
});

test('parses allOf schema', () {
final json = {
'allOf': [
Expand Down Expand Up @@ -192,5 +234,25 @@ void main() {
final parsed = JsonSchema.fromJson(json);
expect(parsed.toJson(), json);
});

test('object with additionalProperties schema round trip', () {
final original = JsonSchema.object(
properties: {'name': JsonSchema.string()},
additionalProperties: JsonSchema.integer(),
);
final json = original.toJson();
final parsed = JsonSchema.fromJson(json);
expect(parsed.toJson(), json);
});

test('object with additionalProperties bool round trip', () {
final original = JsonSchema.object(
properties: {'name': JsonSchema.string()},
additionalProperties: false,
);
final json = original.toJson();
final parsed = JsonSchema.fromJson(json);
expect(parsed.toJson(), json);
});
});
}
16 changes: 16 additions & 0 deletions test/shared/json_schema_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ void main() {
});
});

test('JsonObject serializes additionalProperties as schema', () {
final schema = JsonSchema.object(
properties: {
'name': JsonSchema.string(),
},
additionalProperties: JsonSchema.string(),
);
expect(schema.toJson(), {
'type': 'object',
'properties': {
'name': {'type': 'string'},
},
'additionalProperties': {'type': 'string'},
});
});

test('JsonAllOf serializes correctly', () {
final schema = JsonSchema.allOf([
JsonSchema.string(),
Expand Down
33 changes: 33 additions & 0 deletions test/shared/json_schema_validator_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,39 @@ void main() {
throwsA(isA<JsonSchemaValidationException>()),
);
});

test('validates additionalProperties as schema', () {
final schema = JsonSchema.object(
properties: {"name": JsonSchema.string()},
additionalProperties: JsonSchema.integer(),
);

// Extra property matching the schema type is valid
schema.validate({"name": "John", "age": 30});

// Extra property not matching the schema type throws
expect(
() => schema.validate({"name": "John", "age": "not an integer"}),
throwsA(isA<JsonSchemaValidationException>()),
);
});

test('validates additionalProperties as empty schema (any)', () {
// z.record(z.string(), z.unknown()) produces additionalProperties: {}
final json = {
'type': 'object',
'properties': <String, dynamic>{},
'additionalProperties': {},
};
final schema = JsonSchema.fromJson(json) as JsonObject;

// Any extra properties should be accepted
schema.validate({
"foo": "bar",
"baz": 123,
"nested": {"a": true},
});
});
});

group('enum validation', () {
Expand Down