[ 'properties' => [ $secondary_key => [ 'properties' => [ $field_id => $field_schema, ], ], ], ], ]; } /** * Check if the schema is unwrapped (has cart, checkout, customer, as top level keys). * * @param array $schema The schema to check. * @return bool */ private static function schema_is_unwrapped( $schema ) { return isset( $schema['cart'] ) || isset( $schema['checkout'] ) || isset( $schema['customer'] ); } /** * Validate the field rules. * * @param DocumentObject $document_object The document object to validate. * @param array $rules The rules to validate against. * @return bool|WP_Error */ public static function validate_document_object( DocumentObject $document_object, $rules ) { if ( self::schema_is_unwrapped( $rules ) ) { $rules = [ '$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object', 'properties' => $rules, ]; } else { if ( ! isset( $rules['$schema'] ) ) { $rules['$schema'] = 'http://json-schema.org/draft-07/schema#'; } if ( ! isset( $rules['type'] ) ) { $rules['type'] = 'object'; } } try { $vocabulary = new DefaultVocabulary(); foreach ( self::FORMAT_LIMIT_KEYWORDS as $keyword ) { $vocabulary->appendKeyword( new DateFormatLimitParser( $keyword ) ); } $validator = new Validator( new SchemaLoader( new SchemaParser( [], [], $vocabulary ) ) ); $result = $validator->validate( Helper::toJSON( $document_object->get_data() ), Helper::toJSON( $rules ) ); if ( ! $result->hasError() ) { return true; } } catch ( \Exception $e ) { return new WP_Error( 'woocommerce_rest_checkout_validation_failed', __( 'Validation failed.', 'woocommerce' ) ); } // Return generic error message. return new WP_Error( 'woocommerce_rest_checkout_invalid_field', __( 'Invalid field.', 'woocommerce' ) ); } /** * Check if the fields have defined schema. * * @param array $fields The fields. * @return bool */ public static function has_field_schema( $fields ) { $return = false; foreach ( $fields as $field ) { if ( ( ! empty( $field['validation'] ) && is_array( $field['validation'] ) ) || ( ! empty( $field['required'] ) && is_array( $field['required'] ) ) || ( ! empty( $field['hidden'] ) && is_array( $field['hidden'] ) ) ) { $return = true; break; } } return $return; } /** * Validate meta schema for field rules. * * @param mixed $rules The rules to validate. * @return bool|WP_Error True if the field options are valid, a WP_Error otherwise. */ public static function is_valid_schema( $rules ) { if ( ! is_array( $rules ) ) { return new WP_Error( 'woocommerce_rest_checkout_invalid_field_schema', 'Rules must be defined as an array.' ); } if ( empty( $rules ) ) { return true; } if ( self::schema_is_unwrapped( $rules ) ) { $rules = [ 'type' => 'object', 'properties' => $rules, ]; } if ( empty( self::$meta_schema_json ) ) { self::$meta_schema_json = self::build_meta_schema(); } $validator = new Validator(); $result = $validator->validate( Helper::toJSON( [ '$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object', 'properties' => [ 'test' => $rules, ], 'required' => [ 'test' ], ] ), self::$meta_schema_json ); $error = $result->error(); if ( null !== $error ) { return new WP_Error( 'woocommerce_rest_checkout_invalid_field_schema', self::format_schema_error( $error ) ); } return true; } /** * Describes schema errors at the failing keyword instead of the outer schema wrapper. * * @param ValidationError $error The schema validation error. * @return string The error paths and messages. */ private static function format_schema_error( ValidationError $error ): string { $formatter = new ErrorFormatter(); $errors = $formatter->format( $error, true, static function ( ValidationError $error ) use ( $formatter ): string { if ( 'const' === $error->keyword() ) { return sprintf( 'The value must be %s', wp_json_encode( $error->args()['const'] ) ); } $schema = $error->schema()->info()->data(); if ( 'enum' === $error->keyword() && is_object( $schema ) && isset( $schema->enum ) ) { return sprintf( 'The value must be one of: %s', implode( ', ', array_map( 'wp_json_encode', $schema->enum ) ) ); } return $formatter->formatErrorMessage( $error ); } ); $messages = []; foreach ( $errors as $path => $details ) { // The properties/test prefix belongs to our meta-schema check, not the supplied rule. $path = rawurldecode( preg_replace( '#^/properties/test(?=/|$)#', '', $path ) ?? $path ); $messages[] = sprintf( 'At "%s": %s.', $path ? $path : '/', implode( '; ', $details ) ); } return implode( ' ', $messages ); } /** * Adds $data references and date comparisons to the draft-07 meta schema. * * @return string The meta schema as JSON. */ private static function build_meta_schema(): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $draft_07 = (string) file_get_contents( __DIR__ . '/json-schema-draft-07.json' ); $meta_schema = json_decode( $draft_07, true ); // Fall back to the pristine meta schema rather than none at all, so rules are still checked. if ( ! is_array( $meta_schema ) ) { return $draft_07; } $meta_schema['definitions']['dataRef'] = [ 'type' => 'object', 'required' => [ '$data' ], 'properties' => [ '$data' => [ 'type' => 'string', 'anyOf' => [ [ 'format' => 'json-pointer' ], [ 'format' => 'relative-json-pointer' ], ], ], ], 'additionalProperties' => false, ]; foreach ( self::DATA_REF_KEYWORDS as $keyword ) { $meta_schema['properties'][ $keyword ] = [ 'anyOf' => [ $meta_schema['properties'][ $keyword ], [ '$ref' => '#/definitions/dataRef' ], ], ]; } foreach ( self::FORMAT_LIMIT_KEYWORDS as $keyword ) { $meta_schema['properties'][ $keyword ] = [ 'anyOf' => [ [ 'type' => 'string', 'format' => 'date', ], [ '$ref' => '#/definitions/dataRef' ], ], ]; $meta_schema['dependencies'][ $keyword ] = [ 'required' => [ 'format' ], 'properties' => [ 'format' => [ 'const' => 'date' ] ], ]; } $widened = wp_json_encode( $meta_schema ); return is_string( $widened ) ? $widened : $draft_07; } }