diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dc1f982 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "knplabs/json-schema", + "description": "PHP json-schema implementation", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Knplabs\\JsonSchema\\": "src/" + } + }, + "authors": [ + { + "name": "KnpLabs", + "email": "hello@knplabs.com" + } + ], + "require": { + "php": ">=8.0" + } +} diff --git a/src/Collection.php b/src/Collection.php new file mode 100644 index 0000000..3af4156 --- /dev/null +++ b/src/Collection.php @@ -0,0 +1,35 @@ +> $schemas + */ + public function __construct(private iterable $schemas) + { + } + + /** + * @template J of JsonSchema + * + * @param class-string $schemaClassName + * + * @return J + */ + public function get(string $schemaClassName): JsonSchema + { + foreach ($this->schemas as $schema) { + if (is_a($schema, $schemaClassName)) { + return $schema; + } + } + + throw new Exception("Schema {$schemaClassName} not found."); + } +} diff --git a/src/CollectionSchema.php b/src/CollectionSchema.php new file mode 100644 index 0000000..b383db0 --- /dev/null +++ b/src/CollectionSchema.php @@ -0,0 +1,74 @@ + + * + * @extends JsonSchema + */ +abstract class CollectionSchema extends JsonSchema +{ + /** + * @param JsonSchema $itemSchema + */ + public function __construct(private JsonSchema $itemSchema) + { + } + + public function getExamples(): iterable + { + yield [...$this->itemSchema->getExamples()]; + } + + public function getTitle(): string + { + return sprintf('Collection<%s>', $this->itemSchema->getTitle()); + } + + protected function getUniqueItems(): ?bool + { + return null; + } + + protected function getMinLength(): ?int + { + return null; + } + + protected function getMaxLength(): ?int + { + return null; + } + + protected function getRange(): ?int + { + return null; + } + + protected function getSchema(): array + { + $schema = [ + 'type' => 'array', + 'items' => $this->itemSchema->jsonSerialize(), + ]; + + if (null !== $uniqueItems = $this->getUniqueItems()) { + $schema['uniqueItems'] = $uniqueItems; + } + + if (null !== $minLength = $this->getMinLength()) { + $schema['minLength'] = $minLength; + } + + if (null !== $maxLength = $this->getMaxLength()) { + $schema['maxLength'] = $maxLength; + } + + return $schema; + } +} diff --git a/src/EnumSchema.php b/src/EnumSchema.php new file mode 100644 index 0000000..02fbb5b --- /dev/null +++ b/src/EnumSchema.php @@ -0,0 +1,29 @@ + + */ +abstract class EnumSchema extends JsonSchema +{ + public function getExamples(): iterable + { + return $this->getEnum(); + } + + protected function getSchema(): array + { + return [ + 'enum' => [...$this->getEnum()], + ]; + } + + /** + * @return iterable + */ + abstract protected function getEnum(): iterable; +} diff --git a/src/JsonSchema.php b/src/JsonSchema.php new file mode 100644 index 0000000..87a628b --- /dev/null +++ b/src/JsonSchema.php @@ -0,0 +1,260 @@ + $schema + * @template E of mixed + * + * @return JsonSchema + */ + public static function nullable(self $schema): self + { + return self::create( + '', + '', + [...$schema->getExamples(), null], + ['oneOf' => [self::null(), $schema->jsonSerialize()]], + ); + } + + /** + * @param iterable $examples + * @param array $schema + * @template E of mixed + * + * @return JsonSchema + */ + public static function create( + string $title, + string $description, + iterable $examples, + $schema + ): self { + return new class($title, $description, $examples, $schema) extends JsonSchema { + /** + * @var iterable + */ + private readonly iterable $examples; + + /** + * @param iterable $examples + * @param array $schema + */ + public function __construct( + private string $title, + private string $description, + iterable $examples, + private $schema + ) { + $this->examples = [...$examples]; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + /** + * @return iterable + */ + public function getExamples(): iterable + { + yield from $this->examples; + } + + protected function getSchema(): array + { + return $this->schema; + } + }; + } + + /** + * @template I + * + * @param JsonSchema $jsonSchema + * + * @return JsonSchema> + */ + public static function collection(self $jsonSchema): self + { + return self::create( + sprintf('Collection<%s>', $jsonSchema->getTitle()), + '', + [[...$jsonSchema->getExamples()]], + [ + 'type' => 'array', + 'items' => $jsonSchema, + ] + ); + } + + /** + * @return array&array{title: string, description: string, examples: array} + */ + public function jsonSerialize(): mixed + { + $schema = $this->getSchema(); + + /** + * @var array&array{title: string, description: string, examples: array} + */ + return array_merge( + $schema, + [ + 'title' => $this->getTitle(), + 'description' => $this->getDescription(), + 'examples' => [...$this->getExamples()], + ], + ); + } + + /** + * @return iterable + */ + abstract public function getExamples(): iterable; + + public function getTitle(): string + { + return ''; + } + + public function getDescription(): string + { + return ''; + } + + /** + * @param scalar $value + * + * @return array + */ + protected static function constant($value): array + { + return [ + 'const' => $value, + ]; + } + + /** + * @return array + */ + protected static function null(): array + { + return [ + 'type' => 'null', + ]; + } + + /** + * @return array + */ + protected static function text(): array + { + return [ + 'type' => 'string', + 'minLength' => 1, + ]; + } + + /** + * @return array + */ + protected static function boolean(): array + { + return [ + 'type' => 'boolean', + ]; + } + + /** + * @return array + */ + protected static function string(?string $format = null): array + { + $result = [ + ...self::text(), + 'maxLength' => 255, + ]; + + if (null !== $format) { + $result['format'] = $format; + } + + return $result; + } + + /** + * @return array + */ + protected static function integer(): array + { + return [ + 'type' => 'integer', + ]; + } + + /** + * @return array + */ + protected static function number(): array + { + return [ + 'type' => 'number', + ]; + } + + /** + * @return array + */ + protected static function date(): array + { + return [ + 'type' => 'string', + 'format' => 'date', + ]; + } + + /** + * @return array + */ + protected static function positiveInteger(): array + { + return [ + ...self::integer(), + 'exclusiveMinimum' => 0, + ]; + } + + /** + * @param array ...$schemas + * + * @return array{oneOf: array>} + */ + protected static function oneOf(...$schemas): array + { + return [ + 'oneOf' => $schemas, + ]; + } + + /** + * @return array + */ + abstract protected function getSchema(): array; +} diff --git a/src/ObjectSchema.php b/src/ObjectSchema.php new file mode 100644 index 0000000..2e43ddd --- /dev/null +++ b/src/ObjectSchema.php @@ -0,0 +1,71 @@ + + * @extends JsonSchema + */ +abstract class ObjectSchema extends JsonSchema +{ + /** + * @var array> + */ + private array $properties = []; + + /** + * @var array + */ + private array $required = []; + + public function getExamples(): iterable + { + /** + * @var T + */ + $object = []; + + foreach ($this->properties as $name => $property) { + foreach ($property->getExamples() as $example) { + $object[$name] = $example; + + continue 2; + } + } + + yield $object; + } + + protected function hasAdditionalProperties(): bool + { + return false; + } + + /** + * @template S + * + * @param JsonSchema $schema + */ + protected function addProperty(string $name, JsonSchema $schema, bool $required = true): void + { + $this->properties[$name] = $schema; + + if ($required) { + $this->required[] = $name; + } else { + $this->required = array_diff($this->required, [$name]); + } + } + + protected function getSchema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => $this->hasAdditionalProperties(), + 'properties' => $this->properties, + 'required' => $this->required, + ]; + } +} diff --git a/src/Scalar/UuidSchema.php b/src/Scalar/UuidSchema.php new file mode 100644 index 0000000..227521f --- /dev/null +++ b/src/Scalar/UuidSchema.php @@ -0,0 +1,48 @@ + + */ +final class UuidSchema extends JsonSchema +{ + private const PATTERN = '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$'; + + /** + * {@inheritDoc} + */ + public function getExamples(): iterable + { + yield '123e4567-e89b-12d3-a456-426614174000'; + } + + public function getTitle(): string + { + return 'Universally unique identifier'; + } + + public function getDescription(): string + { + return 'A universally unique identifier (UUID) is a 128-bit label used for information in computer systems.'; + } + + /** + * {@inheritDoc} + */ + protected function getSchema(): array + { + return array_merge( + self::string(), + [ + 'pattern' => self::PATTERN, + 'minLength' => 36, + 'maxLength' => 36, + ] + ); + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..289a6b7 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,18 @@ +>|JsonSchema $schema + */ + public function validate($data, $schema): ?Errors; +} diff --git a/src/Validator/Error.php b/src/Validator/Error.php new file mode 100644 index 0000000..7c978e1 --- /dev/null +++ b/src/Validator/Error.php @@ -0,0 +1,24 @@ +path; + } + + public function getMessage(): string + { + return $this->message; + } +} diff --git a/src/Validator/Errors.php b/src/Validator/Errors.php new file mode 100644 index 0000000..c53b6a4 --- /dev/null +++ b/src/Validator/Errors.php @@ -0,0 +1,39 @@ + + */ +final class Errors implements Countable, IteratorAggregate +{ + /** + * @var array + */ + private array $errors; + + public function __construct(Error ...$errors) + { + $this->errors = $errors; + } + + public function count(): int + { + return \count($this->errors); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): Iterator + { + return new ArrayIterator($this->errors); + } +}