blob: 85a2c09344e51f3c26a96462223093a4641b3064 [file] [log] [blame]
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of 'api.dart';
/// Sent from the client to request a list of tools the server has.
extension type ListToolsRequest.fromMap(Map<String, Object?> _value)
implements PaginatedRequest {
static const methodName = 'tools/list';
factory ListToolsRequest({Cursor? cursor, MetaWithProgressToken? meta}) =>
ListToolsRequest.fromMap({
if (cursor != null) 'cursor': cursor,
if (meta != null) '_meta': meta,
});
}
/// The server's response to a tools/list request from the client.
extension type ListToolsResult.fromMap(Map<String, Object?> _value)
implements PaginatedResult {
factory ListToolsResult({
required List<Tool> tools,
Cursor? nextCursor,
Meta? meta,
}) => ListToolsResult.fromMap({
'tools': tools,
if (nextCursor != null) 'nextCursor': nextCursor,
if (meta != null) '_meta': meta,
});
List<Tool> get tools {
final tools = (_value['tools'] as List?)?.cast<Tool>();
if (tools == null) {
throw ArgumentError('Missing tools field in $ListToolsResult');
}
return tools;
}
}
/// The server's response to a tool call.
///
/// Any errors that originate from the tool SHOULD be reported inside the result
/// object, with `isError` set to true, _not_ as an MCP protocol-level error
/// response. Otherwise, the LLM would not be able to see that an error occurred
/// and self-correct.
///
/// However, any errors in _finding_ the tool, an error indicating that the
/// server does not support tool calls, or any other exceptional conditions,
/// should be reported as an MCP error response.
extension type CallToolResult.fromMap(Map<String, Object?> _value)
implements Result {
factory CallToolResult({
Meta? meta,
required List<Content> content,
Map<String, Object?>? structuredContent,
bool? isError,
}) => CallToolResult.fromMap({
'content': content,
if (structuredContent != null) 'structuredContent': structuredContent,
if (isError != null) 'isError': isError,
if (meta != null) '_meta': meta,
});
/// The returned content, either [TextContent], [ImageContent],
/// [AudioContent], [ResourceLink] or [EmbeddedResource].
List<Content> get content {
final content = (_value['content'] as List?)?.cast<Content>();
if (content == null) {
throw ArgumentError('Missing content field in $CallToolResult');
}
return content;
}
/// The content as structured output, if the [Tool] declared an
/// `outputSchema`.
Map<String, Object?>? get structuredContent =>
_value['structuredContent'] as Map<String, Object?>?;
/// Whether the tool call ended in an error.
///
/// If not set, this is assumed to be false (the call was successful).
bool? get isError => _value['isError'] as bool?;
}
/// Used by the client to invoke a tool provided by the server.
extension type CallToolRequest._fromMap(Map<String, Object?> _value)
implements Request {
static const methodName = 'tools/call';
factory CallToolRequest({
required String name,
Map<String, Object?>? arguments,
MetaWithProgressToken? meta,
}) => CallToolRequest._fromMap({
'name': name,
if (arguments != null) 'arguments': arguments,
if (meta != null) '_meta': meta,
});
/// The name of the method to invoke.
String get name {
final name = _value['name'] as String?;
if (name == null) {
throw ArgumentError('Missing name field in $CallToolRequest');
}
return name;
}
/// The arguments to pass to the method.
Map<String, Object?>? get arguments =>
(_value['arguments'] as Map?)?.cast<String, Object?>();
}
/// An optional notification from the server to the client, informing it that
/// the list of tools it offers has changed.
///
/// This may be issued by servers without any previous subscription from the
/// client.
extension type ToolListChangedNotification.fromMap(Map<String, Object?> _value)
implements Notification {
static const methodName = 'notifications/tools/list_changed';
factory ToolListChangedNotification({Meta? meta}) =>
ToolListChangedNotification.fromMap({if (meta != null) '_meta': meta});
}
/// Definition for a tool the client can call.
extension type Tool.fromMap(Map<String, Object?> _value)
implements BaseMetadata {
factory Tool({
required String name,
String? title,
String? description,
required ObjectSchema inputSchema,
// Only supported since version `ProtocolVersion.v2025_06_18`.
ObjectSchema? outputSchema,
// Only supported since version `ProtocolVersion.v2025_03_26`.
ToolAnnotations? annotations,
// Only supported since version `ProtocolVersion.v2025_03_26`.
Meta? meta,
}) => Tool.fromMap({
'name': name,
if (title != null) 'title': title,
if (description != null) 'description': description,
'inputSchema': inputSchema,
if (outputSchema != null) 'outputSchema': outputSchema,
if (annotations != null) 'annotations': annotations,
if (meta != null) '_meta': meta,
});
/// Optional additional tool information.
///
/// Only supported since version [ProtocolVersion.v2025_03_26].
ToolAnnotations? get toolAnnotations =>
(_value['annotations'] as Map?)?.cast<String, Object?>()
as ToolAnnotations?;
/// A human-readable description of the tool.
String? get description => _value['description'] as String?;
/// A JSON [ObjectSchema] object defining the expected parameters for the
/// tool.
ObjectSchema get inputSchema {
final inputSchema = _value['inputSchema'] as ObjectSchema?;
if (inputSchema == null) {
throw ArgumentError('Missing inputSchema field in $Tool');
}
return inputSchema;
}
/// An optional JSON [ObjectSchema] object defining the expected schema of the
/// tool output.
///
/// If the `outputSchema` is specified, then the output from the tool must
/// conform to the schema.
ObjectSchema? get outputSchema => _value['outputSchema'] as ObjectSchema?;
}
/// Additional properties describing a Tool to clients.
///
/// NOTE: all properties in ToolAnnotations are **hints**. They are not
/// guaranteed to provide a faithful description of tool behavior (including
/// descriptive properties like `title`).
///
/// Clients should never make tool use decisions based on ToolAnnotations
/// received from untrusted servers.
extension type ToolAnnotations.fromMap(Map<String, Object?> _value) {
factory ToolAnnotations({
bool? destructiveHint,
bool? idempotentHint,
bool? openWorldHint,
bool? readOnlyHint,
String? title,
}) => ToolAnnotations.fromMap({
if (destructiveHint != null) 'destructiveHint': destructiveHint,
if (idempotentHint != null) 'idempotentHint': idempotentHint,
if (openWorldHint != null) 'openWorldHint': openWorldHint,
if (readOnlyHint != null) 'readOnlyHint': readOnlyHint,
if (title != null) 'title': title,
});
/// If true, the tool may perform destructive updates to its environment.
///
/// If false, the tool performs only additive updates.
///
/// (This property is meaningful only when `readOnlyHint == false`)
bool? get destructiveHint => _value['destructiveHint'] as bool?;
/// If true, calling the tool repeatedly with the same arguments will have no
/// additional effect on the its environment.
///
/// (This property is meaningful only when `readOnlyHint == false`)
bool? get idempotentHint => _value['idempotentHint'] as bool?;
/// If true, this tool may interact with an "open world" of external entities.
///
/// If false, the tool's domain of interaction is closed. For example, the
/// world of a web search tool is open, whereas that of a memory tool is not.
bool? get openWorldHint => _value['openWorldHint'] as bool?;
/// If true, the tool does not modify its environment.
bool? get readOnlyHint => _value['readOnlyHint'] as bool?;
/// A human-readable title for the tool.
String? get title => _value['title'] as String?;
}
/// The valid types for properties in a JSON-RCP2 schema.
enum JsonType {
object('object'),
list('array'),
string('string'),
num('number'),
int('integer'),
bool('boolean'),
enumeration('enum'),
nil('null');
const JsonType(this.typeName);
final String typeName;
}
/// Enum representing the types of validation failures when checking data
/// against a schema.
enum ValidationErrorType {
// For custom validation.
custom,
// General
typeMismatch,
// Schema combinators
allOfNotMet,
anyOfNotMet,
oneOfNotMet,
notConditionViolated,
// Object specific
requiredPropertyMissing,
additionalPropertyNotAllowed,
minPropertiesNotMet,
maxPropertiesExceeded,
@Deprecated(
'These events are no longer emitted, just emit a single error for the '
'key itself',
)
propertyNamesInvalid,
@Deprecated(
'These events are no longer emitted, just emit a single error for the '
'property itself',
)
propertyValueInvalid,
patternPropertyValueInvalid,
unevaluatedPropertyNotAllowed,
// Array/List specific
minItemsNotMet,
maxItemsExceeded,
uniqueItemsViolated,
@Deprecated(
'These events are no longer emitted, just emit a single error for the '
'item itself',
)
itemInvalid,
@Deprecated(
'These events are no longer emitted, just emit a single error for the '
'prefix item itself',
)
prefixItemInvalid,
unevaluatedItemNotAllowed,
// String specific
minLengthNotMet,
maxLengthExceeded,
patternMismatch,
// Enum specific
enumValueNotAllowed,
// Number/Integer specific
minimumNotMet,
maximumExceeded,
exclusiveMinimumNotMet,
exclusiveMaximumExceeded,
multipleOfInvalid,
}
/// A validation error with detailed information about the location of the
/// error.
extension type ValidationError.fromMap(Map<String, Object?> _value) {
factory ValidationError(
ValidationErrorType error, {
required List<String> path,
String? details,
}) => ValidationError.fromMap({
'error': error.name,
'path': path.toList(),
if (details != null) 'details': details,
});
factory ValidationError.typeMismatch({
required List<String> path,
required Type expectedType,
required Object? actualValue,
}) => ValidationError(
ValidationErrorType.typeMismatch,
path: path,
details: 'Value `$actualValue` is not of type `$expectedType`',
);
/// The type of validation error that occurred.
ValidationErrorType get error =>
ValidationErrorType.values.firstWhere((t) => t.name == _value['error']);
/// The path to the object that had the error.
List<String> get path => (_value['path'] as List).cast<String>();
/// Additional details about the error (optional).
String? get details => _value['details'] as String?;
String toErrorString() {
return '${details != null ? '$details' : error.name} at path '
'#root${path.map((p) => '["$p"]').join('')}'
'';
}
}
/// A JSON Schema object defining the any kind of property.
///
/// See the subtypes [ObjectSchema], [ListSchema], [StringSchema],
/// [NumberSchema], [IntegerSchema], [BooleanSchema], [NullSchema].
///
/// To get an instance of a subtype, you should inspect the [type] as well as
/// check for any schema combinators ([allOf], [anyOf], [oneOf], [not]), as both
/// may be present.
///
/// If a [type] is provided, it applies to all sub-schemas, and you can cast all
/// the sub-schemas directly to the specified type from the parent schema.
///
/// See https://json-schema.org/understanding-json-schema/reference for the full
/// specification.
///
/// **Note:** Only a subset of the json schema spec is supported by these types,
/// if you need something more complex you can create your own
/// `Map<String, Object?>` and cast it to [Schema] (or [ObjectSchema]) directly.
extension type Schema.fromMap(Map<String, Object?> _value) {
/// A combined schema, see
/// https://json-schema.org/understanding-json-schema/reference/combining#schema-composition
factory Schema.combined({
JsonType? type,
String? title,
String? description,
List<Schema>? allOf,
List<Schema>? anyOf,
List<Schema>? oneOf,
List<Schema>? not,
}) => Schema.fromMap({
if (type != null) 'type': type.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (allOf != null) 'allOf': allOf,
if (anyOf != null) 'anyOf': anyOf,
if (oneOf != null) 'oneOf': oneOf,
if (not != null) 'not': not,
});
/// Alias for [StringSchema.new].
static const string = StringSchema.new;
/// Alias for [BooleanSchema.new].
static const bool = BooleanSchema.new;
/// Alias for [NumberSchema.new].
static const num = NumberSchema.new;
/// Alias for [IntegerSchema.new].
static const int = IntegerSchema.new;
/// Alias for [ListSchema.new].
static const list = ListSchema.new;
/// Alias for [ObjectSchema.new].
static const object = ObjectSchema.new;
/// Alias for [EnumSchema.new].
static const enumeration = EnumSchema.new;
/// Alias for [NullSchema.new].
static const nil = NullSchema.new;
/// The [JsonType] of this schema, if present.
///
/// Use this in switch statements to determine the type of schema and cast to
/// the appropriate subtype.
///
/// Note that it is good practice to include a default case, to avoid breakage
/// in the case that a new type is added.
///
/// This is not required, and commonly won't be present if one of the schema
/// combinators ([allOf], [anyOf], [oneOf], or [not]) are used.
JsonType? get type => JsonType.values.firstWhereOrNull(
(t) => (_value['type'] as String? ?? '') == t.typeName,
);
/// A title for this schema, should be short.
String? get title => _value['title'] as String?;
/// A description of this schema.
String? get description => _value['description'] as String?;
/// Schema combinator that requires all sub-schemas to match.
List<Schema>? get allOf => (_value['allOf'] as List?)?.cast<Schema>();
/// Schema combinator that requires at least one of the sub-schemas to match.
List<Schema>? get anyOf => (_value['anyOf'] as List?)?.cast<Schema>();
/// Schema combinator that requires exactly one of the sub-schemas to match.
List<Schema>? get oneOf => (_value['oneOf'] as List?)?.cast<Schema>();
/// Schema combinator that requires none of the sub-schemas to match.
List<Schema>? get not => (_value['not'] as List?)?.cast<Schema>();
}
extension SchemaValidation on Schema {
/// Validates the given [data] against this schema.
///
/// Returns a list of [ValidationError] if validation fails,
/// or an empty list if validation succeeds.
List<ValidationError> validate(Object? data) {
final failures = _createHashSet();
_validateSchema(data, [], failures);
return failures.toList();
}
/// Performs validation based on the direct, non-combinator keywords of this
/// schema.
///
/// Adds failures to [accumulatedFailures] and returns `false` if any occur.
bool _performDirectValidation(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
var isValid = true;
if (type case final schemaType?) {
switch (schemaType) {
case JsonType.object:
isValid = (this as ObjectSchema)._validateObject(
data,
currentPath,
accumulatedFailures,
);
case JsonType.list:
isValid = (this as ListSchema)._validateList(
data,
currentPath,
accumulatedFailures,
);
case JsonType.string:
isValid = (this as StringSchema)._validateString(
data,
currentPath,
accumulatedFailures,
);
case JsonType.num:
isValid = (this as NumberSchema)._validateNumber(
data,
currentPath,
accumulatedFailures,
);
case JsonType.int:
isValid = (this as IntegerSchema)._validateInteger(
data,
currentPath,
accumulatedFailures,
);
case JsonType.enumeration:
isValid = (this as EnumSchema)._validateEnum(
data,
currentPath,
accumulatedFailures,
);
case JsonType.bool:
if (data is! bool) {
isValid = false;
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: bool,
actualValue: data,
),
);
}
case JsonType.nil:
if (data != null) {
isValid = false;
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: Null,
actualValue: data,
),
);
}
}
}
return isValid;
}
/// Validates data against the schema.
///
/// Adds failures to [accumulatedFailures] and returns `false` if any occur.
bool _validateSchema(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
var isValid = true;
// Validate data against the non-combinator keywords of the current schema
// ('this').
isValid =
_performDirectValidation(data, currentPath, accumulatedFailures) &&
isValid;
// Handle combinator keywords. Create the "base schema" from 'this' schema,
// excluding combinator keywords. This base schema's constraints are
// effectively ANDed with each sub-schema in combinators.
final baseSchemaMapForCombinators = Map<String, Object?>.from(_value);
baseSchemaMapForCombinators.remove('allOf');
baseSchemaMapForCombinators.remove('anyOf');
baseSchemaMapForCombinators.remove('oneOf');
baseSchemaMapForCombinators.remove('not');
final baseSchemaForCombinators = Schema.fromMap(
baseSchemaMapForCombinators,
);
// Helper to merge a sub-schema with the baseSchemaForCombinators.
Schema mergeWithBase(Schema subSchema) {
final mergedMap = Map<String, Object?>.from(
baseSchemaForCombinators._value,
);
// Sub-schema's values override base's if keys conflict. This is generally
// correct; if sub-schema specifies a conflicting 'type', the merged
// schema will use sub-schema's type, and validation will proceed. If this
// makes the schema unsatisfiable with base constraints, validation will
// fail.
mergedMap.addAll(subSchema._value);
return Schema.fromMap(mergedMap);
}
if (allOf case final allOfList?) {
var allSubSchemasAreValid = true;
for (final subSchemaMember in allOfList) {
final effectiveSubSchema = mergeWithBase(subSchemaMember);
allSubSchemasAreValid =
effectiveSubSchema._validateSchema(
data,
currentPath,
accumulatedFailures,
) &&
allSubSchemasAreValid;
}
// `allOf` fails if any effective sub-schema (Base AND SubMember) failed.
if (!allSubSchemasAreValid) {
isValid = false;
accumulatedFailures.add(
ValidationError(ValidationErrorType.allOfNotMet, path: currentPath),
);
}
}
if (anyOf case final anyOfList?) {
var oneSubSchemaPassed = false;
anyOfList.any((element) {
final effectiveSubSchema = mergeWithBase(element);
if (effectiveSubSchema._validateSchema(
data,
currentPath,
_createHashSet(),
)) {
oneSubSchemaPassed = true;
return true;
}
return false;
});
if (!oneSubSchemaPassed) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.anyOfNotMet,
path: currentPath,
details: 'No sub-schema passed validation for $data',
),
);
}
}
if (oneOf case final oneOfList?) {
var matchingSubSchemaCount = 0;
for (final subSchemaMember in oneOfList) {
final effectiveSubSchema = mergeWithBase(subSchemaMember);
if (effectiveSubSchema._validateSchema(
data,
currentPath,
_createHashSet(),
)) {
matchingSubSchemaCount++;
}
}
if (matchingSubSchemaCount != 1) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.oneOfNotMet,
path: currentPath,
details:
'Exactly one sub-schema must match $data but '
'$matchingSubSchemaCount did',
),
);
}
}
if (not case final notList?) {
for (final subSchemaInNot in notList) {
final effectiveSubSchemaForNot = mergeWithBase(subSchemaInNot);
// 'not' is violated if data *validates* against the (Base AND
// NotSubSchema).
if (effectiveSubSchemaForNot._validateSchema(
data,
currentPath,
_createHashSet(),
)) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.notConditionViolated,
path: currentPath,
details: '$data matched the schema $subSchemaInNot',
),
);
}
}
}
return isValid;
}
}
/// A JSON Schema definition for an object with properties.
///
/// `ObjectSchema` is used to define the expected structure, data types, and
/// constraints for MCP argument objects. It allows you to specify:
///
/// - Which properties an object can or must have ([properties], [required]).
/// - The schema for each of those properties (e.g., string, number, nested
/// object).
/// - Whether additional properties not explicitly defined are allowed
/// ([additionalProperties], [unevaluatedProperties]).
/// - Constraints on the number of properties ([minProperties],
/// [maxProperties]).
/// - Constraints on property names ([propertyNames]).
///
/// See https://json-schema.org/understanding-json-schema/reference/object.html
/// for more details on object schemas.
///
/// Example:
///
/// To define a schema for a product object that requires `productId` and
/// `productName`, has an optional `price` (non-negative number) and optional
/// `tags` (list of unique strings), and optional `dimensions` (an object with
/// required numeric length, width, and height):
///
/// ```dart
/// final productSchema = ObjectSchema(
/// title: 'Product',
/// description: 'Schema for a product object',
/// required: ['productId', 'productName'],
/// properties: {
/// 'productId': Schema.string(
/// description: 'Unique identifier for the product',
/// ),
/// 'productName': Schema.string(description: 'Name of the product'),
/// 'price': Schema.num(
/// description: 'Price of the product',
/// minimum: 0,
/// ),
/// 'tags': Schema.list(
/// description: 'Optional list of tags for the product',
/// items: Schema.string(),
/// uniqueItems: true,
/// ),
/// 'dimensions': ObjectSchema(
/// description: 'Optional product dimensions',
/// properties: {
/// 'length': Schema.num(),
/// 'width': Schema.num(),
/// 'height': Schema.num(),
/// },
/// required: ['length', 'width', 'height'],
/// ),
/// },
/// additionalProperties: false, // No other properties allowed beyond those defined
/// );
/// ```
///
/// This schema can then be used with the `validate` method to check if a given
/// JSON-like map conforms to the defined structure.
///
/// For example, valid data might look like:
///
/// ```json
/// {
/// "productId": "ABC12345",
/// "productName": "Super Widget",
/// "price": 19.99,
/// "tags": ["electronics", "gadget"],
/// "dimensions": {"length": 10, "width": 5, "height": 2.5}
/// }
/// ```
///
/// And invalid data (e.g., missing productName, or an extra undefined
/// property):
/// ```json
/// {
/// "productId": "XYZ67890",
/// "price": 9.99
/// }
/// ```
///
/// ```json
/// {
/// "productId": "DEF4567",
/// "productName": "Another Gadget",
/// "color": "blue" // Invalid if additionalProperties is false
/// }
/// ```
extension type ObjectSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory ObjectSchema({
String? title,
String? description,
Map<String, Schema>? properties,
Map<String, Schema>? patternProperties,
List<String>? required,
/// Must be one of bool, Schema, or Null
Object? additionalProperties,
bool? unevaluatedProperties,
StringSchema? propertyNames,
int? minProperties,
int? maxProperties,
}) => ObjectSchema.fromMap({
'type': JsonType.object.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (properties != null) 'properties': properties,
if (patternProperties != null) 'patternProperties': patternProperties,
if (required != null) 'required': required,
if (additionalProperties != null)
'additionalProperties': additionalProperties,
if (unevaluatedProperties != null)
'unevaluatedProperties': unevaluatedProperties,
if (propertyNames != null) 'propertyNames': propertyNames,
if (minProperties != null) 'minProperties': minProperties,
if (maxProperties != null) 'maxProperties': maxProperties,
});
/// A map of the properties of the object to the nested [Schema]s for those
/// properties.
Map<String, Schema>? get properties =>
(_value['properties'] as Map?)?.cast<String, Schema>();
/// A map of the property patterns of the object to the nested [Schema]s for
/// those properties.
///
/// For example, to define a schema where any property name starting with
/// "x-" should have a string value:
///
/// ```dart
/// final schema = ObjectSchema(patternProperties: {r'^x-': Schema.string()});
/// ```
///
Map<String, Schema>? get patternProperties =>
(_value['patternProperties'] as Map?)?.cast<String, Schema>();
/// A list of the required properties by name.
///
/// For example, to define a schema for an object that requires a `name`
/// property:
///
/// ```dart
/// final schema = ObjectSchema(
/// required: ['name'],
/// properties: {'name': Schema.string()},
/// );
/// ```
///
/// In this schema, an object like `{'name': 'John'}` would be valid, but
/// `{}` or `{'age': 30}` would be invalid because they do not contain the
/// required `name` property. Note that the type of the `name` property is
/// also defined using the `properties` field; `required` only enforces the
/// presence of the property, not its type or value, which are handled by
/// the corresponding schema in the `properties` map (if provided, otherwise
/// any value is accepted).
///
/// Properties in this list must be set in the object.
List<String>? get required => (_value['required'] as List?)?.cast<String>();
/// Rules for additional properties that don't match the
/// [properties] or [patternProperties] schemas.
///
/// Can be either a [bool] or a [Schema], if it is a [Schema] then additional
/// properties should match that [Schema].
///
/// For example, to define a schema where any property not explicitly defined
/// in `properties` should have an integer value:
///
/// ```dart
/// final schema = ObjectSchema(
/// properties: {'name': Schema.string()},
/// additionalProperties: Schema.int(),
/// );
/// ```
///
/// In this schema, an object like `{'name': 'John', 'age': 30}` would be
/// valid, but `{'name': 'John', 'age': 'thirty'}` would be invalid because
/// `age` is not a defined property and its value is not an integer as
/// required by `additionalProperties`.
Object? get additionalProperties => _value['additionalProperties'];
/// Similar to [additionalProperties] but more flexible, see
/// https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties
/// for more details.
///
/// For example, to define a schema where any property not explicitly defined
/// in `properties` or matched by `patternProperties` is disallowed:
///
/// ```dart
/// final schema = ObjectSchema(
/// properties: {'name': Schema.string()},
/// patternProperties: {r'^x-': Schema.string()},
/// unevaluatedProperties: false,
/// );
/// ```
///
/// In this schema, an object like `{'name': 'John', 'x-id': '123'}` would be
/// valid, but `{'name': 'John', 'age': 30}` would be invalid because `age` is
/// neither a defined property nor matches the pattern, and
/// `unevaluatedProperties` is set to `false`.
bool? get unevaluatedProperties => _value['unevaluatedProperties'] as bool?;
/// A list of valid patterns for all property names.
///
/// For example, to define a schema where all property names must start with
/// a lowercase letter:
///
/// ```dart
/// final schema = ObjectSchema(
/// propertyNames: Schema.string(pattern: r'^[a-z].*$'),
/// );
/// ```
///
/// In this schema, an object like `{'name': 'John', 'age': 30}` would be
/// valid, but `{'Name': 'John', 'Age': 30}` would be invalid because the
/// property names do not start with a lowercase letter.
StringSchema? get propertyNames =>
(_value['propertyNames'] as Map?)?.cast<String, Object?>()
as StringSchema?;
/// The minimum number of properties in this object.
///
/// If the object has less than this many properties, it will be invalid.
int? get minProperties => _value['minProperties'] as int?;
/// The maximum number of properties in this object.
///
/// If the object has more than this many properties, it will be invalid.
int? get maxProperties => _value['maxProperties'] as int?;
bool _validateObject(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data is! Map<String, Object?>) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: Map<String, Object?>,
actualValue: data,
),
);
return false;
}
var isValid = true;
if (minProperties case final min? when data.keys.length < min) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.minPropertiesNotMet,
path: currentPath,
details:
'There should be at least $minProperties '
'properties. Only ${data.keys.length} were found',
),
);
}
if (maxProperties case final max? when data.keys.length > max) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.maxPropertiesExceeded,
path: currentPath,
details:
'Exceeded maxProperties limit of $maxProperties '
'(${data.keys.length})',
),
);
}
for (final reqProp in required ?? const []) {
if (!data.containsKey(reqProp)) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.requiredPropertyMissing,
path: currentPath,
details: 'Required property "$reqProp" is missing',
),
);
}
}
// Check for property values that match any defined properties in the
// `properties` map. If a property name exists in the `properties` map, the
// value of that property is validated against the schema associated with
// that property.
final evaluatedKeys = <String>{};
if (properties case final props?) {
for (final entry in props.entries) {
if (data.containsKey(entry.key)) {
currentPath.add(entry.key);
evaluatedKeys.add(entry.key);
isValid =
entry.value._validateSchema(
data[entry.key],
currentPath,
accumulatedFailures,
) &&
isValid;
currentPath.removeLast();
}
}
}
// Check for property values that match any defined pattern properties.
// If a property name matches a pattern in `patternProperties`, the value
// of that property is validated against the schema associated with that
// pattern.
if (patternProperties case final patternProps?) {
for (final entry in patternProps.entries) {
final pattern = RegExp(entry.key);
for (final dataKey in data.keys) {
if (pattern.hasMatch(dataKey)) {
currentPath.add(dataKey);
evaluatedKeys.add(dataKey);
isValid =
entry.value._validateSchema(
data[dataKey],
currentPath,
accumulatedFailures,
) &&
isValid;
currentPath.removeLast();
}
}
}
}
// If a `propertyNames` schema is defined, iterate over each key (property
// name) in the input `data` object and validate it against that schema.
// If any property name is invalid, mark the overall validation as failed
// and record the specific errors.
if (propertyNames case final propNamesSchema?) {
for (final key in data.keys) {
currentPath.add(key);
isValid =
propNamesSchema._validateSchema(
key,
currentPath,
accumulatedFailures,
) &&
isValid;
currentPath.removeLast();
}
}
// If additionalProperties is defined, check if unevaluated properties
// (properties not in `properties` or `patternProperties`) are allowed. If
// additionalProperties is not defined, check if unevaluated properties are
// allowed based on the value of unevaluatedProperties.
for (final dataKey in data.keys) {
if (evaluatedKeys.contains(dataKey)) continue;
if (additionalProperties != null) {
final ap = additionalProperties;
currentPath.add(dataKey);
if (ap is bool && !ap) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.additionalPropertyNotAllowed,
path: currentPath,
details: 'Additional property "$dataKey" is not allowed',
),
);
} else if (ap is Schema) {
isValid =
ap._validateSchema(
data[dataKey],
currentPath,
accumulatedFailures,
) &&
isValid;
}
currentPath.removeLast();
} else if (unevaluatedProperties == false) {
isValid = false;
// Only applies if additionalProperties is not defined
currentPath.add(dataKey);
accumulatedFailures.add(
ValidationError(
ValidationErrorType.unevaluatedPropertyNotAllowed,
path: currentPath,
details: 'Unevaluated property "$dataKey" is not allowed',
),
);
currentPath.removeLast();
}
}
return isValid;
}
}
/// A JSON Schema definition for a String.
extension type const StringSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory StringSchema({
String? title,
String? description,
int? minLength,
int? maxLength,
String? pattern,
}) => StringSchema.fromMap({
'type': JsonType.string.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (minLength != null) 'minLength': minLength,
if (maxLength != null) 'maxLength': maxLength,
if (pattern != null) 'pattern': pattern,
});
/// The minimum allowed length of this String.
int? get minLength => _value['minLength'] as int?;
/// The maximum allowed length of this String.
int? get maxLength => _value['maxLength'] as int?;
/// A regular expression pattern that the String must match.
String? get pattern => _value['pattern'] as String?;
bool _validateString(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data is! String) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: String,
actualValue: data,
),
);
return false;
}
var isValid = true;
if (minLength case final minLen? when data.length < minLen) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.minLengthNotMet,
path: currentPath,
details: 'String "$data" is not at least $minLen characters long',
),
);
}
if (maxLength case final maxLen? when data.length > maxLen) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.maxLengthExceeded,
path: currentPath,
details: 'String "$data" is more than $maxLen characters long',
),
);
}
if (pattern case final dataPattern?
when !RegExp(dataPattern).hasMatch(data)) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.patternMismatch,
path: currentPath,
details: 'String "$data" doesn\'t match the pattern "$dataPattern"',
),
);
}
return isValid;
}
}
/// A JSON Schema definition for a set of allowed string values.
extension type EnumSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory EnumSchema({
String? title,
String? description,
required Iterable<String> values,
}) => EnumSchema.fromMap({
'type': JsonType.enumeration.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
'enum': values,
});
/// A title for this schema, should be short.
String? get title => _value['title'] as String?;
/// A description of this schema.
String? get description => _value['description'] as String?;
/// The allowed enum values.
Iterable<String> get values {
final values = (_value['enum'] as Iterable?)?.cast<String>();
if (values == null) {
throw ArgumentError('Missing required property "values"');
}
assert(
values.toSet().length == values.length,
"The 'values' property has duplicate entries.",
);
return values;
}
bool _validateEnum(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data is! String) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: String,
actualValue: data,
),
);
return false;
}
if (!values.contains(data)) {
accumulatedFailures.add(
ValidationError(
ValidationErrorType.enumValueNotAllowed,
path: currentPath,
details:
'String "$data" is not one of the allowed values: '
'${values.join(', ')}',
),
);
return false;
}
return true;
}
}
/// A JSON Schema definition for a [num].
extension type NumberSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory NumberSchema({
String? title,
String? description,
num? minimum,
num? maximum,
num? exclusiveMinimum,
num? exclusiveMaximum,
num? multipleOf,
}) => NumberSchema.fromMap({
'type': JsonType.num.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (minimum != null) 'minimum': minimum,
if (maximum != null) 'maximum': maximum,
if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum,
if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum,
if (multipleOf != null) 'multipleOf': multipleOf,
});
/// The minimum value (inclusive) for this number.
num? get minimum => _value['minimum'] as num?;
/// The maximum value (inclusive) for this number.
num? get maximum => _value['maximum'] as num?;
/// The minimum value (exclusive) for this number.
num? get exclusiveMinimum => _value['exclusiveMinimum'] as num?;
/// The maximum value (exclusive) for this number.
num? get exclusiveMaximum => _value['exclusiveMaximum'] as num?;
/// The value must be a multiple of this number.
num? get multipleOf => _value['multipleOf'] as num?;
bool _validateNumber(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data is! num) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: num,
actualValue: data,
),
);
return false;
}
var isValid = true;
if (minimum case final min? when data < min) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.minimumNotMet,
path: currentPath,
details: 'Value $data is not at least $min',
),
);
}
if (maximum case final max? when data > max) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.maximumExceeded,
path: currentPath,
details: 'Value $data is larger than $max',
),
);
}
if (exclusiveMinimum case final exclusiveMin? when data <= exclusiveMin) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.exclusiveMinimumNotMet,
path: currentPath,
details: 'Value $data is not greater than $exclusiveMin',
),
);
}
if (exclusiveMaximum case final exclusiveMax? when data >= exclusiveMax) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.exclusiveMaximumExceeded,
path: currentPath,
details: 'Value $data is not less than $exclusiveMax',
),
);
}
if (multipleOf case final multOf? when multOf != 0) {
final remainder = data / multOf;
if ((remainder - remainder.round()).abs() > 1e-9) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.multipleOfInvalid,
path: currentPath,
details: 'Value $data is not a multiple of $multipleOf',
),
);
}
}
return isValid;
}
}
/// A JSON Schema definition for an [int].
extension type IntegerSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory IntegerSchema({
String? title,
String? description,
int? minimum,
int? maximum,
int? exclusiveMinimum,
int? exclusiveMaximum,
num? multipleOf,
}) => IntegerSchema.fromMap({
'type': JsonType.int.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (minimum != null) 'minimum': minimum,
if (maximum != null) 'maximum': maximum,
if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum,
if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum,
if (multipleOf != null) 'multipleOf': multipleOf,
});
/// The minimum value (inclusive) for this integer.
int? get minimum => _value['minimum'] as int?;
/// The maximum value (inclusive) for this integer.
int? get maximum => _value['maximum'] as int?;
/// The minimum value (exclusive) for this integer.
int? get exclusiveMinimum => _value['exclusiveMinimum'] as int?;
/// The maximum value (exclusive) for this integer.
int? get exclusiveMaximum => _value['exclusiveMaximum'] as int?;
/// The value must be a multiple of this number.
num? get multipleOf => _value['multipleOf'] as num?;
bool _validateInteger(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data == null || (data is! int && data is! num)) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: int,
actualValue: data,
),
);
return false;
}
if (data is num) {
final intData = data.toInt();
if (data != intData) {
accumulatedFailures.add(
ValidationError(
ValidationErrorType.typeMismatch,
path: currentPath,
details: 'Value $data is a number, but is not an integer',
),
);
return false;
}
data = intData;
} else {
data = data as int;
}
var isValid = true;
if (minimum case final min? when data < min) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.minimumNotMet,
path: currentPath,
details: 'Value $data is less than the minimum of $min',
),
);
}
if (maximum case final max? when data > max) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.maximumExceeded,
path: currentPath,
details: 'Value $data is more than the maximum of $max',
),
);
}
if (exclusiveMinimum case final exclusiveMin? when data <= exclusiveMin) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.exclusiveMinimumNotMet,
path: currentPath,
details: 'Value $data is not greater than $exclusiveMin',
),
);
}
if (exclusiveMaximum case final exclusiveMax? when data >= exclusiveMax) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.exclusiveMaximumExceeded,
path: currentPath,
details: 'Value $data is not less than $exclusiveMax',
),
);
}
if (multipleOf case final multOf?
when multOf != 0 && (data % multOf != 0)) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.multipleOfInvalid,
path: currentPath,
details: 'Value $data is not a multiple of $multOf',
),
);
}
return isValid;
}
}
/// A JSON Schema definition for a [bool].
extension type BooleanSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory BooleanSchema({String? title, String? description}) =>
BooleanSchema.fromMap({
'type': JsonType.bool.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
});
}
/// A JSON Schema definition for `null`.
extension type NullSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory NullSchema({String? title, String? description}) =>
NullSchema.fromMap({
'type': JsonType.nil.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
});
}
/// A JSON Schema definition for a [List].
extension type ListSchema.fromMap(Map<String, Object?> _value)
implements Schema {
factory ListSchema({
String? title,
String? description,
Schema? items,
List<Schema>? prefixItems,
bool? unevaluatedItems,
int? minItems,
int? maxItems,
bool? uniqueItems,
}) => ListSchema.fromMap({
'type': JsonType.list.typeName,
if (title != null) 'title': title,
if (description != null) 'description': description,
if (items != null) 'items': items,
if (prefixItems != null) 'prefixItems': prefixItems,
if (unevaluatedItems != null) 'unevaluatedItems': unevaluatedItems,
if (minItems != null) 'minItems': minItems,
if (maxItems != null) 'maxItems': maxItems,
if (uniqueItems != null) 'uniqueItems': uniqueItems,
});
/// The schema for all the items in this list, or all those after
/// [prefixItems] (if present).
///
/// For example, to define a schema for a list where all items must be
/// strings:
///
/// ```dart
/// final schema = ListSchema(items: Schema.string());
/// ```
///
/// In this schema, a list like `['apple', 'banana', 'cherry']` would be
/// valid, but `['apple', 42, 'cherry']` would be invalid because it
/// contains a non-string item.
///
/// Note that if you want to define a schema for a list where the initial
/// items have specific types and the remaining items follow a different
/// schema, you can use the `prefixItems` property in conjunction with
/// `items`. For example, to allow a string followed by an integer, and
/// then any number of booleans:
///
/// ```dart
/// final schema = ListSchema(
/// prefixItems: [Schema.string(), Schema.int()],
/// items: Schema.bool(),
/// );
/// ```
Schema? get items => _value['items'] as Schema?;
/// The schema for the initial items in this list, if specified.
///
/// For example, to define a schema for a list where the first item must be
/// a string and the second item must be an integer:
///
/// ```dart
/// final schema = ListSchema(
/// prefixItems: [Schema.string(), Schema.int()],
/// );
/// ```
///
/// In this schema, a list like `['hello', 42]` would be valid, but
/// `[42, 'hello']` or `['hello']` would be invalid because they do not
/// conform to the specified order and types of the prefix items.
///
/// Note that if you want to allow additional items in the list that do not
/// match the prefix items, you can use the `items` property to define a
/// schema for those additional items. For example, to allow any number of
/// additional strings after the initial string and integer:
///
/// ```dart
/// final schema = ListSchema(
/// prefixItems: [Schema.string(), Schema.int()],
/// items: Schema.string()
/// );
/// ```
List<Schema>? get prefixItems =>
(_value['prefixItems'] as List?)?.cast<Schema>();
/// Whether or not additional items in the list are allowed that don't
/// match the [items] or [prefixItems] schemas.
///
/// For example, to define a schema for a list where only items matching
/// `prefixItems` or `items` are allowed:
///
/// ```dart
/// final schema = ListSchema(
/// prefixItems: [Schema.string(), Schema.int()],
/// items: Schema.bool(),
/// unevaluatedItems: false,
/// );
/// ```
///
/// In this schema, a list like `['hello', 42, true]` would be valid, but
/// `['hello', 42, true, 123]` would be invalid because the last item does
/// not match the schema defined by `items` (which applies to items
/// beyond those covered by `prefixItems`), and `unevaluatedItems` is set
/// to `false`, disallowing any items not explicitly matched by the
/// schema.
bool? get unevaluatedItems => _value['unevaluatedItems'] as bool?;
/// The minimum number of items in this list.
int? get minItems => _value['minItems'] as int?;
/// The maximum number of items in this list.
int? get maxItems => _value['maxItems'] as int?;
/// Whether or not all the items in this list must be unique.
///
/// For example, to define a schema for a list where all items must be
/// unique:
///
/// ```dart
/// final schema = ListSchema(
/// items: Schema.string(),
/// uniqueItems: true,
/// );
/// ```
///
/// In this schema, a list like `['apple', 'banana', 'cherry']` would be
/// valid, but `['apple', 'banana', 'apple']` would be invalid because it
/// contains duplicate items. Note that the type of the items is also
/// defined using the `items` property; `uniqueItems` only enforces the
/// uniqueness of the items, not their type or value, which are handled by
/// the corresponding schema in the `items` property.
bool? get uniqueItems => _value['uniqueItems'] as bool?;
bool _validateList(
Object? data,
List<String> currentPath,
HashSet<ValidationError> accumulatedFailures,
) {
if (data is! List<dynamic>) {
accumulatedFailures.add(
ValidationError.typeMismatch(
path: currentPath,
expectedType: List<dynamic>,
actualValue: data,
),
);
return false;
}
var isValid = true;
if (minItems case final min? when data.length < min) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.minItemsNotMet,
path: currentPath,
details:
'List has ${data.length} items, but must have at least '
'$min',
),
);
}
if (maxItems case final max? when data.length > max) {
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.maxItemsExceeded,
path: currentPath,
details:
'List has ${data.length} items, but must have less than '
'$max',
),
);
}
if (uniqueItems == true && data.toSet().length != data.length) {
final seenItems = <Object?>{};
final duplicates = <Object?>{};
for (final item in data) {
if (seenItems.contains(item)) {
duplicates.add(item);
} else {
seenItems.add(item);
}
}
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.uniqueItemsViolated,
path: currentPath,
details: 'List contains duplicate items: ${duplicates.join(', ')}',
),
);
}
final evaluatedItems = List<bool>.filled(data.length, false);
if (prefixItems case final pItems?) {
for (var i = 0; i < pItems.length && i < data.length; i++) {
evaluatedItems[i] = true;
currentPath.add(i.toString());
isValid =
pItems[i]._validateSchema(
data[i],
currentPath,
accumulatedFailures,
) &&
isValid;
currentPath.removeLast();
}
}
if (items case final itemSchema?) {
final startIndex = prefixItems?.length ?? 0;
for (var i = startIndex; i < data.length; i++) {
evaluatedItems[i] = true;
currentPath.add(i.toString());
isValid =
itemSchema._validateSchema(
data[i],
currentPath,
accumulatedFailures,
) &&
isValid;
currentPath.removeLast();
}
}
if (unevaluatedItems == false) {
for (var i = 0; i < data.length; i++) {
if (!evaluatedItems[i]) {
currentPath.add(i.toString());
isValid = false;
accumulatedFailures.add(
ValidationError(
ValidationErrorType.unevaluatedItemNotAllowed,
path: currentPath,
details: 'Unevaluated item in list at index $i',
),
);
currentPath.removeLast();
// Only report the first unevaluated item to avoid excessive errors.
// If we want all, remove the break. For now, keeping existing
// behavior of early exit.
break;
}
}
}
return isValid;
}
}
HashSet<ValidationError> _createHashSet() {
return HashSet<ValidationError>(
equals: (ValidationError a, ValidationError b) {
return const ListEquality<String>().equals(a.path, b.path) &&
a.details == b.details &&
a.error == b.error;
},
hashCode: (ValidationError error) {
return Object.hashAll([...error.path, error.details, error.error]);
},
);
}