$schema Schema. * @param string $path Path for error messages. * @return array{0: mixed, 1: string[]} Normalized value and errors. */ public static function validate( $value, array $schema, string $path = '$', int $depth = 0 ): array { $errors = array(); $type = $schema['type'] ?? null; if ( 0 === $depth && is_array( $value ) && self::too_deep( $value, 64 ) ) { return array( null, array( "$path is nested too deeply (limit 64 levels)" ) ); } if ( $depth > 40 ) { return array( null, array( "$path is nested too deeply" ) ); } if ( is_array( $value ) && ! isset( $schema['maxItems'] ) && count( $value ) > 5000 ) { return array( null, array( "$path has more than 5000 items" ) ); } if ( is_string( $value ) && ! isset( $schema['maxLength'] ) && strlen( $value ) > 2000000 ) { return array( null, array( "$path is longer than 2 MB" ) ); } if ( null === $value && array_key_exists( 'default', $schema ) ) { $value = $schema['default']; } switch ( $type ) { case 'object': if ( is_object( $value ) ) { $value = json_decode( (string) wp_json_encode( $value ), true ); } if ( null === $value || '' === $value ) { $value = array(); } // AI apps sometimes send an object as a JSON string: accept it. if ( is_string( $value ) && '{' === substr( ltrim( $value ), 0, 1 ) ) { $decoded = json_decode( $value, true ); if ( is_array( $decoded ) ) { $value = $decoded; } } if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) === range( 0, count( $value ) - 1 ) ) ) { return array( $value, array( "$path must be an object" ) ); } $props = $schema['properties'] ?? array(); $additional = $schema['additionalProperties'] ?? false; foreach ( $schema['required'] ?? array() as $req ) { if ( ! array_key_exists( $req, $value ) || null === $value[ $req ] ) { $errors[] = "$path.$req is required"; } } $out = array(); foreach ( $value as $k => $v ) { if ( isset( $props[ $k ] ) ) { list( $nv, $errs ) = self::validate( $v, $props[ $k ], "$path.$k", $depth + 1 ); $out[ $k ] = $nv; $errors = array_merge( $errors, $errs ); } elseif ( true === $additional ) { $out[ $k ] = $v; } elseif ( is_array( $additional ) ) { list( $nv, $errs ) = self::validate( $v, $additional, "$path.$k", $depth + 1 ); $out[ $k ] = $nv; $errors = array_merge( $errors, $errs ); } else { $errors[] = "$path.$k is not an allowed property"; } } foreach ( $props as $k => $prop_schema ) { if ( ! array_key_exists( $k, $out ) && array_key_exists( 'default', $prop_schema ) ) { $out[ $k ] = $prop_schema['default']; } } return array( $out, $errors ); if ( ! is_array( $value ) || ( array() !== $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) { return array( $value, array( "$path must be an array" ) ); } if ( isset( $schema['maxItems'] ) && count( $value ) > $schema['maxItems'] ) { $errors[] = "$path has more than {$schema['maxItems']} items"; } if ( isset( $schema['minItems'] ) && count( $value ) < $schema['minItems'] ) { $errors[] = "$path needs at least {$schema['minItems']} items"; } $out = array(); foreach ( $value as $i => $item ) { if ( isset( $schema['items'] ) ) { list( $nv, $errs ) = self::validate( $item, $schema['items'], "{$path}[{$i}]", $depth + 1 ); $out[] = $nv; $errors = array_merge( $errors, $errs ); } else { $out[] = $item; } } return array( $out, $errors ); case 'integer': if ( is_string( $value ) && preg_match( '/^-?\d+$/', $value ) ) { $value = (int) $value; } if ( is_float( $value ) && floor( $value ) === $value ) { $value = (int) $value; } if ( ! is_int( $value ) ) { return array( $value, array( "$path must be an integer" ) ); } return array( $value, self::range_errors( $value, $schema, $path ) ); case 'number': if ( is_string( $value ) && is_numeric( $value ) ) { $value = $value + 0; } if ( ! is_int( $value ) && ! is_float( $value ) ) { return array( $value, array( "$path must be a number" ) ); } return array( $value, self::range_errors( $value, $schema, $path ) ); case 'boolean': if ( is_string( $value ) && in_array( strtolower( $value ), array( 'true', 'false', '1', '0' ), true ) ) { $value = in_array( strtolower( $value ), array( 'true', '1' ), true ); } if ( is_int( $value ) && in_array( $value, array( 0, 1 ), true ) ) { $value = (bool) $value; } if ( ! is_bool( $value ) ) { return array( $value, array( "$path must be a boolean" ) ); } return array( $value, array() ); case 'string': if ( is_int( $value ) || is_float( $value ) ) { $value = (string) $value; } if ( ! is_string( $value ) ) { return array( $value, array( "$path must be a string" ) ); } if ( isset( $schema['maxLength'] ) && mb_strlen( $value ) > $schema['maxLength'] ) { $errors[] = "$path exceeds {$schema['maxLength']} characters"; } if ( isset( $schema['minLength'] ) && mb_strlen( $value ) < $schema['minLength'] ) { $errors[] = "$path is shorter than {$schema['minLength']} characters"; } if ( isset( $schema['pattern'] ) && 1 !== preg_match( '#' . str_replace( '#', '\#', (string) $schema['pattern'] ) . '#u', $value ) ) { // 0 is no match; false (bad pattern or invalid UTF-8) is treated as no match too. $errors[] = "$path does not match the required pattern"; } if ( isset( $schema['enum'] ) && ! in_array( $value, $schema['enum'], true ) ) { $errors[] = "$path must be one of: " . implode( ', ', $schema['enum'] ); } if ( isset( $schema['format'] ) ) { if ( 'email' === $schema['format'] && '' !== $value && ! is_email( $value ) ) { $errors[] = "$path must be a valid email"; } if ( 'uri' === $schema['format'] && '' !== $value && ! wp_http_validate_url( $value ) && 0 !== strpos( $value, '/' ) ) { $errors[] = "$path must be a valid URL"; } } // Strip NUL bytes defensively. $value = str_replace( "\0", '', $value ); return array( $value, $errors ); case null: return array( $value, array() ); default: if ( is_array( $type ) ) { foreach ( $type as $t ) { list( $nv, $errs ) = self::validate( $value, array_merge( $schema, array( 'type' => $t ) ), $path ); if ( ! $errs ) { return array( $nv, array() ); } } return array( $value, array( "$path must be one of types: " . implode( ', ', $type ) ) ); } return array( $value, array( "$path has unsupported schema type" ) ); } } /** * Range and enum checks for numbers. * * @param int|float $value Value. * @param array $schema Schema. * @param string $path Path. * @return string[] */ private static function range_errors( $value, array $schema, string $path ): array { $errors = array(); if ( isset( $schema['minimum'] ) && $value < $schema['minimum'] ) { $errors[] = "$path must be >= {$schema['minimum']}"; } if ( isset( $schema['maximum'] ) && $value > $schema['maximum'] ) { $errors[] = "$path must be <= {$schema['maximum']}"; } if ( isset( $schema['enum'] ) && ! in_array( $value, $schema['enum'], true ) ) { $errors[] = "$path must be one of: " . implode( ', ', $schema['enum'] ); } return $errors; } /** * Is the array nested deeper than $limit levels? * * @param array $value Value. * @param int $limit Levels left. */ private static function too_deep( array $value, int $limit ): bool { if ( $limit <= 0 ) { return true; } foreach ( $value as $v ) { if ( is_array( $v ) && self::too_deep( $v, $limit - 1 ) ) { return true; } } return false; } }