Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema

Validation{} │ WC 1.0

Service class validating checkout field schema.

No Hooks.

Usage

$Validation = new Validation();
// use class methods

Methods

  1. public static get_field_schema_with_context( $field_id, $field_schema, $context )
  2. public static has_field_schema( $fields )
  3. public static is_valid_schema( $rules )
  4. public static validate_document_object( DocumentObject $document_object, $rules )
  5. private static build_meta_schema()
  6. private static format_schema_error( ValidationError $error )
  7. private static schema_is_unwrapped( $schema )

Validation{} code WC 11.2.1

class Validation {
	/**
	 * Meta schema.
	 *
	 * @var string
	 */
	private static $meta_schema_json = '';

	/**
	 * Date comparison keywords shared with ajv-formats.
	 */
	private const FORMAT_LIMIT_KEYWORDS = [
		'formatMinimum',
		'formatMaximum',
		'formatExclusiveMinimum',
		'formatExclusiveMaximum',
	];

	/**
	 * Keywords that may hold a `$data` reference instead of a literal value, because
	 * the base document schema we have doesn't include those.
	 */
	private const DATA_REF_KEYWORDS = [
		'multipleOf',
		'maximum',
		'exclusiveMaximum',
		'minimum',
		'exclusiveMinimum',
		'maxLength',
		'minLength',
		'pattern',
		'maxItems',
		'minItems',
		'uniqueItems',
		'maxProperties',
		'minProperties',
		'required',
		'enum',
		'format',
	];

	/**
	 * Get the field schema with context.
	 *
	 * @param string $field_id The field ID.
	 * @param array  $field_schema The field schema.
	 * @param string $context The context.
	 * @return array
	 */
	public static function get_field_schema_with_context( $field_id, $field_schema, $context ) {
		$primary_key   = 'checkout';
		$secondary_key = 'additional_fields';
		switch ( $context ) {
			case 'billing_address':
			case 'shipping_address':
				$primary_key   = 'customer';
				$secondary_key = $context;
				break;
			case 'contact':
				$primary_key   = 'customer';
				$secondary_key = 'additional_fields';
				break;
		}
		return [
			$primary_key => [
				'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;
	}
}