validateFunctionDefinitions function
Validates structure against generated TypeBox tables, then semantic bounds. Returned definitions and all nested values are immutable and canonically ordered.
Implementation
Map<String, Map<String, Object?>> validateFunctionDefinitions(
Object? value, {
Set<String> modelNames = const {},
Set<String> queryNames = const {},
Set<String> fileCollectionNames = const {},
}) {
final frozen = _bounded(value, '/functions');
if (frozen is! Map<String, Object?> || frozen.length > 100) {
_invalid(
'/functions',
'Expected at most 100 named definitions.',
'Supply a map of function identifiers to explicit callable or HTTP definitions.',
);
}
// Name unsupported schedule/event delivery before the generic structural
// failure, matching the TypeScript validator's functionKindUnsupported pointers.
for (final entry in frozen.entries) {
final definition = entry.value;
if (definition is! Map<String, Object?>) continue;
final path = '/functions/${_pointer(entry.key)}';
if (definition.containsKey('kind') &&
definition['kind'] != 'callable' &&
definition['kind'] != 'http') {
_invalid(
'$path/kind',
'Only callable and HTTP handlers have a delivery runtime.',
'Use callable or http; schedules and event delivery are not supported.',
);
}
for (final key in _deliveryKeys) {
if (definition.containsKey(key)) {
_invalid(
'$path/$key',
'Trigger and schedule delivery is not supported; only callable and HTTP handlers have a delivery runtime.',
'Remove $key; declare a callable or HTTP handler and invoke it explicitly.',
);
}
}
}
// Validate separately so structural errors identify the offending function.
for (final entry in frozen.entries) {
_structural('FunctionsSection', {
entry.key: entry.value,
}, '/functions/${_pointer(entry.key)}');
}
final result = <String, Map<String, Object?>>{};
for (final entry in frozen.entries) {
final name = entry.key, definition = entry.value! as Map<String, Object?>;
final path = '/functions/$name';
if (name.startsWith('ffx') || name.startsWith('system')) {
_invalid(
path,
'Function name uses a reserved prefix.',
'Choose a name without ffx or system.',
);
}
var count = 0;
void inspect(
Map<String, Object?> node,
String at,
int depth,
bool property,
) {
if (depth > 8) {
_invalid(
at,
'Schema exceeds eight levels.',
'Flatten the nested interface.',
);
}
if (property && !node.containsKey('description')) {
_invalid(
'$at/description',
'Property description is required.',
'Describe the property in 10–500 characters.',
);
}
final type = node['type'] is List
? (node['type'] as List)[1]
: node['type'];
switch (type) {
case 'object':
final properties = node['properties']! as Map<String, Object?>;
count += properties.length;
for (final required in node['required']! as List) {
if (!properties.containsKey(required)) {
_invalid(
'$at/required',
'Required property is undeclared.',
'Declare $required or remove it from required.',
);
}
}
for (final property in properties.entries) {
inspect(
property.value! as Map<String, Object?>,
'$at/properties/${property.key}',
depth + 1,
true,
);
}
case 'array':
if ((node['minItems'] as num? ?? 0) > (node['maxItems']! as num)) {
_invalid(
at,
'minItems exceeds maxItems.',
'Use ordered inclusive array bounds.',
);
}
inspect(
node['items']! as Map<String, Object?>,
'$at/items',
depth + 1,
false,
);
case 'string':
final min = node['minLength'] as num? ?? 0,
max = node['maxLength']! as num;
if (min > max) {
_invalid(
at,
'minLength exceeds maxLength.',
'Use ordered inclusive string bounds.',
);
}
for (final member in node['enum'] as List? ?? const []) {
final length = (member as String).runes.length;
if (length < min || length > max) {
_invalid(
'$at/enum',
'Enum member exceeds string bounds.',
'Count Unicode scalar values when specifying string bounds.',
);
}
}
case 'integer' || 'number':
if ((node['minimum']! as num) > (node['maximum']! as num)) {
_invalid(
at,
'minimum exceeds maximum.',
'Use ordered finite numeric bounds.',
);
}
}
}
if (definition['kind'] == 'callable') {
for (final key in ['input', 'output']) {
final schema = definition[key]! as Map<String, Object?>;
if (schema['type'] != 'object') {
_invalid(
'$path/$key/type',
'Root must be a non-nullable object.',
'Use type object at the interface root.',
);
}
inspect(schema, '$path/$key', 1, false);
}
if (count > 256) {
_invalid(
path,
'Interface exceeds 256 properties.',
'Reduce combined input and output properties.',
);
}
} else {
final http = definition['http']! as Map<String, Object?>;
final cors = http['cors']! as Map<String, Object?>;
for (final (i, value) in (cors['origins']! as List).indexed) {
final origin = value as String, uri = Uri.tryParse(value);
if (uri == null ||
!['http', 'https'].contains(uri.scheme) ||
uri.host.isEmpty ||
uri.host.contains('*') ||
!_canonicalIpHost(uri.host) ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
uri.path.isNotEmpty ||
uri.origin != origin) {
_invalid(
'$path/http/cors/origins/$i',
'Expected an exact HTTP(S) origin.',
'Remove paths, credentials, wildcards and noncanonical port spelling.',
);
}
}
}
final egress = definition['egress']! as Map<String, Object?>;
for (final (i, value) in (egress['hosts']! as List).indexed) {
final host = value as String, labels = value.split('.');
if (labels.length < 2 ||
host != host.toLowerCase() ||
labels.any(
(label) => !RegExp(
r'^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$',
).hasMatch(label),
) ||
RegExp(r'^\d+(?:\.\d+){3}$').hasMatch(host) ||
RegExp(r'(^|\.)(localhost|local|internal)$').hasMatch(host)) {
_invalid(
'$path/egress/hosts/$i',
'Expected an exact public DNS name.',
'Use a lowercase fully qualified host without a port, IP address or wildcard.',
);
}
}
for (final secret
in (definition['secrets']! as Map<String, Object?>).entries) {
if (((secret.value! as Map)['name'] as String).startsWith('FFX_')) {
_invalid(
'$path/secrets/${secret.key}/name',
'Secret name is reserved.',
'Choose a project-owned name without FFX_.',
);
}
}
final capabilities = definition['capabilities']! as Map<String, Object?>;
final grants = <String>{};
for (final (i, raw) in (capabilities['serviceGrants']! as List).indexed) {
final grant = raw! as Map<String, Object?>;
final resources = switch (grant['resourceKind']) {
'model' => modelNames,
'query' => queryNames,
_ => fileCollectionNames,
};
if (!resources.contains(grant['resourceName'])) {
_invalid(
'$path/capabilities/serviceGrants/$i',
'Grant references an undeclared resource.',
'Declare the resource or remove this grant.',
);
}
if (!grants.add('${grant['resourceKind']}/${grant['resourceName']}')) {
_invalid(
'$path/capabilities/serviceGrants/$i',
'Resource has duplicate grants.',
'Combine operations into one grant.',
);
}
}
result[name] = definition;
}
return Map.unmodifiable(result);
}