From 35b9f29f413ba67eb34dfc3ab1f4aa8c4e417681 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:07:36 -0500 Subject: [PATCH] feat: add content package schema validator --- src/Package/PackageValidationResult.php | 42 ++++++++++ src/Package/PackageValidator.php | 88 +++++++++++++++++++ tests/Unit/Package/PackageValidatorTest.php | 93 +++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/Package/PackageValidationResult.php create mode 100644 src/Package/PackageValidator.php create mode 100644 tests/Unit/Package/PackageValidatorTest.php diff --git a/src/Package/PackageValidationResult.php b/src/Package/PackageValidationResult.php new file mode 100644 index 0000000..30dfc0d --- /dev/null +++ b/src/Package/PackageValidationResult.php @@ -0,0 +1,42 @@ + */ + private array $errors; + + /** + * @param array $errors Validation errors. + */ + private function __construct( array $errors ) { + $this->errors = array_values( $errors ); + } + + public static function valid(): self { + return new self( array() ); + } + + /** + * @param array $errors Validation errors. + */ + public static function invalid( array $errors ): self { + return new self( $errors ); + } + + public function isValid(): bool { + return array() === $this->errors; + } + + /** + * @return array + */ + public function errors(): array { + return $this->errors; + } +} diff --git a/src/Package/PackageValidator.php b/src/Package/PackageValidator.php new file mode 100644 index 0000000..5760b8e --- /dev/null +++ b/src/Package/PackageValidator.php @@ -0,0 +1,88 @@ + $data Decoded package data. + */ + public function validate( array $data ): PackageValidationResult { + $errors = array(); + + foreach ( array( 'schema_version', 'generated_at', 'source', 'destination', 'manifest', 'records', 'checksums' ) as $field ) { + if ( ! array_key_exists( $field, $data ) ) { + $errors[] = $field . ' is required.'; + } + } + + if ( isset( $data['schema_version'] ) && ContentPackage::SCHEMA_VERSION !== $data['schema_version'] ) { + $errors[] = 'schema_version must be ' . ContentPackage::SCHEMA_VERSION . '.'; + } + + if ( isset( $data['source'] ) && ! is_array( $data['source'] ) ) { + $errors[] = 'source must be an object.'; + } + + if ( isset( $data['destination'] ) && ! is_array( $data['destination'] ) ) { + $errors[] = 'destination must be an object.'; + } + + if ( isset( $data['manifest'] ) && ! is_array( $data['manifest'] ) ) { + $errors[] = 'manifest must be an object.'; + } + + if ( isset( $data['records'] ) && ! is_array( $data['records'] ) ) { + $errors[] = 'records must be an object.'; + } + + if ( isset( $data['checksums'] ) && ! is_array( $data['checksums'] ) ) { + $errors[] = 'checksums must be an object.'; + } + + if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $data['records'] ) ) { + $errors = array_merge( $errors, $this->validateRecordBuckets( $data['manifest'], $data['records'] ) ); + } + + return array() === $errors ? PackageValidationResult::valid() : PackageValidationResult::invalid( $errors ); + } + + /** + * @param array $manifest Package manifest. + * @param array $records Package records. + * + * @return array + */ + private function validateRecordBuckets( array $manifest, array $records ): array { + $errors = array(); + + foreach ( self::RECORD_BUCKETS as $bucket ) { + if ( ! isset( $records[ $bucket ] ) || ! is_array( $records[ $bucket ] ) ) { + $errors[] = 'records.' . $bucket . ' is required and must be an array.'; + continue; + } + + if ( ! isset( $manifest[ $bucket ] ) || ! is_int( $manifest[ $bucket ] ) ) { + $errors[] = 'manifest.' . $bucket . ' is required and must be an integer.'; + continue; + } + + if ( count( $records[ $bucket ] ) !== $manifest[ $bucket ] ) { + $errors[] = 'manifest.' . $bucket . ' must match records.' . $bucket . ' count.'; + } + } + + return $errors; + } +} diff --git a/tests/Unit/Package/PackageValidatorTest.php b/tests/Unit/Package/PackageValidatorTest.php new file mode 100644 index 0000000..f3b0ee9 --- /dev/null +++ b/tests/Unit/Package/PackageValidatorTest.php @@ -0,0 +1,93 @@ +validate( $this->validPackage() ); + + self::assertTrue( $result->isValid() ); + self::assertSame( array(), $result->errors() ); + } + + public function test_it_rejects_missing_required_fields(): void { + $package = $this->validPackage(); + unset( $package['records'] ); + + $result = ( new PackageValidator() )->validate( $package ); + + self::assertFalse( $result->isValid() ); + self::assertSame( array( 'records is required.' ), $result->errors() ); + } + + public function test_it_rejects_unsupported_schema_versions(): void { + $package = $this->validPackage(); + $package['schema_version'] = '2.0'; + + $result = ( new PackageValidator() )->validate( $package ); + + self::assertFalse( $result->isValid() ); + self::assertSame( array( 'schema_version must be 1.0.' ), $result->errors() ); + } + + public function test_it_rejects_missing_record_buckets(): void { + $package = $this->validPackage(); + unset( $package['records']['media'] ); + + $result = ( new PackageValidator() )->validate( $package ); + + self::assertFalse( $result->isValid() ); + self::assertSame( array( 'records.media is required and must be an array.' ), $result->errors() ); + } + + public function test_it_rejects_manifest_counts_that_do_not_match_records(): void { + $package = $this->validPackage(); + $package['manifest']['posts'] = 2; + + $result = ( new PackageValidator() )->validate( $package ); + + self::assertFalse( $result->isValid() ); + self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() ); + } + + /** + * @return array + */ + private function validPackage(): array { + return array( + 'schema_version' => '1.0', + 'generated_at' => '2026-04-26T20:30:00+00:00', + 'source' => array( + 'site_url' => 'https://example.test', + 'name' => 'Example Production', + ), + 'destination' => array( + 'site_url' => 'https://staging.example.test', + 'name' => 'Example Staging', + ), + 'manifest' => array( + 'posts' => 1, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'records' => array( + 'posts' => array( + array( + 'id' => 123, + 'type' => 'post', + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ), + 'checksums' => array( + 'records' => 'sha256:abc123', + ), + ); + } +}