diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..ba0ef4c --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,20 @@ + + */ + private array $shorthand; + + /** + * @param string $schemaProperty + * @param array $shorthand + * @return static + */ + public static function cannotParseProperty(string $schemaProperty, array $shorthand): self + { + $self = new static( + \sprintf( + 'I tried to parse JSONSchema for property: "%s", but it is neither a string nor an object.', + $schemaProperty + ) + ); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @param array $shorthand $shorthand + * @return static + */ + public static function emptyString(array $shorthand): self + { + $self = new static('Shorthand contains an empty or non string property. Cannot deal with that!'); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @param array $shorthand $shorthand + * @return static + */ + public static function refNotString(array $shorthand): self + { + $self = new static( + 'Detected a top level shorthand reference using a "$ref" property, but the value of the property is not a string.' + ); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @param array $shorthand $shorthand + * @return static + */ + public static function itemsNotString(array $shorthand): self + { + $self = new static( + 'Detected a top level shorthand array using an "$items" property, but the value of the property is not a string.' + ); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @param array $shorthand $shorthand + * @return static + */ + public static function refWithOtherProperties(array $shorthand): self + { + $self = new static( + 'Shorthand contains a top level ref property "$ref", but it is not the only property!' + . ' A top level reference cannot have other properties then "$ref".' + ); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @param array $shorthand $shorthand + * @return static + */ + public static function itemsWithOtherProperties(array $shorthand): self + { + $self = new static( + 'Shorthand %s contains a top level array property "$items", but it is not the only property!' + . ' A top level array cannot have other properties then "$items".' + ); + $self->shorthand = $shorthand; + + return $self; + } + + /** + * @return array + */ + public function shorthand(): array + { + return $this->shorthand; + } +} diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php new file mode 100644 index 0000000..451fa62 --- /dev/null +++ b/src/Exception/LogicException.php @@ -0,0 +1,17 @@ + $shorthand + * @return array + */ + public static function convertToJsonSchema(array $shorthand): array + { + $schema = [ + 'type' => 'object', + 'properties' => [ + + ], + 'required' => [], + 'additionalProperties' => false, + ]; + + foreach ($shorthand as $property => $shorthandDefinition) { + if (! \is_string($property) || empty($property)) { + throw InvalidShorthand::emptyString($shorthand); + } + $schemaProperty = $property; + + switch (true) { + case \mb_substr($property, -1) === '?': + $schemaProperty = \mb_substr($property, 0, -1); + break; + case $schemaProperty === '$ref': + if (\count($shorthand) > 1) { + throw InvalidShorthand::refWithOtherProperties($shorthand); + } + + if (! \is_string($shorthandDefinition)) { + throw InvalidShorthand::refNotString($shorthand); + } + + $shorthandDefinition = \str_replace('#/definitions/', '', $shorthandDefinition); + + return [ + '$ref' => "#/definitions/$shorthandDefinition", + ]; + case $schemaProperty === '$items': + if (\count($shorthand) > 1) { + throw InvalidShorthand::itemsWithOtherProperties($shorthand); + } + + if (! \is_string($shorthandDefinition)) { + throw InvalidShorthand::itemsNotString($shorthand); + } + + if (\mb_substr($shorthandDefinition, -2) !== '[]') { + $shorthandDefinition .= '[]'; + } + + return self::convertShorthandStringToJsonSchema($shorthandDefinition); + case $schemaProperty === '$title': + $schema['title'] = $shorthandDefinition; + continue 2; + default: + $schema['required'][] = $schemaProperty; + break; + } + + if (\is_array($shorthandDefinition)) { + $schema['properties'][$schemaProperty] = self::convertToJsonSchema($shorthandDefinition); + } elseif (\is_string($shorthandDefinition)) { + $schema['properties'][$schemaProperty] = self::convertShorthandStringToJsonSchema($shorthandDefinition); + } else { + throw InvalidShorthand::cannotParseProperty($schemaProperty, $shorthand); + } + } + + return $schema; + } + + /** + * @param string $shorthandStr + * @return array + */ + private static function convertShorthandStringToJsonSchema(string $shorthandStr): array + { + if ($shorthandStr === '') { + return ['type' => 'string']; + } + + $parts = \explode('|', $shorthandStr); + + if ($parts[0] === 'enum') { + return ['enum' => \array_slice($parts, 1)]; + } + + if (\mb_substr($parts[0], -2) === '[]') { + $itemsParts = [\mb_substr($parts[0], 0, -2)]; + \array_push($itemsParts, ...\array_slice($parts, 1)); + + return [ + 'type' => 'array', + 'items' => self::convertShorthandStringToJsonSchema(\implode('|', $itemsParts)), + ]; + } + + switch ($parts[0]) { + case 'string': + case 'integer': + case 'number': + case 'boolean': + $type = $parts[0]; + + if (isset($parts[1]) && $parts[1] === 'null') { + $type = [$type, 'null']; + + \array_splice($parts, 1, 1); + } + + $schema = ['type' => $type]; + + if (\count($parts) > 1) { + $parts = \array_slice($parts, 1); + + foreach ($parts as $part) { + [$validationKey, $validationValue] = self::parseShorthandValidation($part); + + $schema[$validationKey] = $validationValue; + } + } + + return $schema; + default: + return [ + '$ref' => '#/definitions/'.$parts[0], + ]; + } + } + + /** + * @param string $shorthandValidation + * @return array + */ + private static function parseShorthandValidation(string $shorthandValidation): array + { + $parts = \explode(':', $shorthandValidation); + + if (\count($parts) !== 2) { + throw new LogicException(\sprintf( + 'Cannot parse shorthand validation: "%s". Expected format "validationKey:value". Please check again!', + $shorthandValidation + )); + } + + [$validationKey, $value] = $parts; + + if ($value === 'true') { + return [$validationKey, true]; + } + + if ($value === 'false') { + return [$validationKey, false]; + } + + if ((string) (int) $value === $value) { + return [$validationKey, (int) $value]; + } + + if ((string) (float) $value === $value) { + return [$validationKey, (float) $value]; + } + + return [$validationKey, $value]; + } +} diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index d472aef..3db65c3 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -10,7 +10,7 @@ namespace OpenCodeModeling\JsonSchemaToPhp\Type; -final class ArrayType implements TypeDefinition +final class ArrayType implements TypeDefinition, TitleAware { use PopulateRequired; @@ -36,6 +36,7 @@ final class ArrayType implements TypeDefinition protected ?string $name = null; protected bool $isRequired = false; protected bool $nullable = false; + protected ?string $title = null; private function __construct() { @@ -231,6 +232,16 @@ public function additionalItems(): ?TypeSet return $this->additionalItems; } + public function title(): ?string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + public static function type(): string { return self::TYPE_ARRAY; diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 24f8823..88bf5fa 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -10,13 +10,14 @@ namespace OpenCodeModeling\JsonSchemaToPhp\Type; -final class ObjectType implements TypeDefinition, NullableAware, RequiredAware +final class ObjectType implements TypeDefinition, NullableAware, RequiredAware, TitleAware { use PopulateRequired; protected ?string $name = null; protected bool $isRequired = false; protected bool $nullable = false; + protected ?string $title = null; /** * @var null|bool|TypeSet @@ -182,6 +183,16 @@ public function required(): array return $this->required; } + public function title(): ?string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + /** * @return array */ diff --git a/src/Type/ReferenceType.php b/src/Type/ReferenceType.php index 8753ee0..04ab708 100644 --- a/src/Type/ReferenceType.php +++ b/src/Type/ReferenceType.php @@ -10,13 +10,14 @@ namespace OpenCodeModeling\JsonSchemaToPhp\Type; -final class ReferenceType implements TypeDefinition, RequiredAware, NullableAware +final class ReferenceType implements TypeDefinition, RequiredAware, NullableAware, TitleAware { protected ?TypeSet $resolvedType = null; protected ?string $name = null; protected ?string $ref = null; protected bool $isRequired = false; protected bool $nullable = false; + protected ?string $title = null; private function __construct() { @@ -109,4 +110,14 @@ public function setNullable(bool $nullable): void } $this->nullable = $nullable; } + + public function title(): ?string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } } diff --git a/src/Type/ScalarType.php b/src/Type/ScalarType.php index 886ca94..3207098 100644 --- a/src/Type/ScalarType.php +++ b/src/Type/ScalarType.php @@ -10,12 +10,13 @@ namespace OpenCodeModeling\JsonSchemaToPhp\Type; -abstract class ScalarType implements TypeDefinition, RequiredAware, NullableAware +abstract class ScalarType implements TypeDefinition, RequiredAware, NullableAware, TitleAware { protected ?string $format = null; protected ?string $name = null; protected bool $isRequired = false; protected bool $nullable = false; + protected ?string $title = null; /** * @var mixed @@ -146,4 +147,14 @@ public function setIsRequired(bool $required): void { $this->isRequired = $required; } + + public function title(): ?string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } } diff --git a/src/Type/TitleAware.php b/src/Type/TitleAware.php new file mode 100644 index 0000000..4c4fbed --- /dev/null +++ b/src/Type/TitleAware.php @@ -0,0 +1,16 @@ + $shorthand + * @param string|null $name + * @return TypeSet + */ + public static function fromShorthand(array $shorthand, ?string $name = null): TypeSet + { + return self::fromDefinition(Shorthand::convertToJsonSchema($shorthand), $name); + } + /** * @param array $definition * @param string|null $name diff --git a/tests/Shorthand/ShorthandTest.php b/tests/Shorthand/ShorthandTest.php new file mode 100644 index 0000000..56f0ea0 --- /dev/null +++ b/tests/Shorthand/ShorthandTest.php @@ -0,0 +1,224 @@ + '']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['type' => 'string']], + ['test'] + ), $schema); + } + + /** + * @test + */ + public function it_converts_enum_shorthand_to_json_schema_string(): void + { + $schema = Shorthand::convertToJsonSchema(['test' => 'enum|available|blocked|bought']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['enum' => ["available", "blocked", "bought"]]], + ['test'] + ), $schema); + } + + /** + * @test + */ + public function it_converts_shorthand_type_to_json_schema(): void + { + foreach (self::SHORTHAND_TYPES as $type) { + $schema = Shorthand::convertToJsonSchema(['test' => $type]); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['type' => $type]], + ['test'] + ), $schema); + } + } + + /** + * @test + */ + public function it_converts_nullable_shorthand_type_to_nullable_json_schema_type(): void + { + foreach (self::SHORTHAND_TYPES as $type) { + $schema = Shorthand::convertToJsonSchema(['test' => $type.'|null']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['type' => [$type, 'null']]], + ['test'] + ), $schema); + } + } + + /** + * @test + */ + public function it_converts_unknown_type_to_json_schema_ref(): void + { + $schema = Shorthand::convertToJsonSchema(['test' => 'User']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['$ref' => '#/definitions/User']], + ['test'] + ), $schema); + } + + /** + * @test + */ + public function it_converts_array_shorthand_type_to_json_schema_array_type(): void + { + foreach (self::SHORTHAND_TYPES as $type) { + $schema = Shorthand::convertToJsonSchema(['test' => $type.'[]']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['type' => 'array', 'items' => ['type' => $type]]], + ['test'] + ), $schema); + } + } + + /** + * @test + */ + public function it_converts_unknown_shorthand_array_type_to_json_schema_array_with_ref_items(): void + { + $schema = Shorthand::convertToJsonSchema(['test' => 'User[]']); + + $this->assertEquals($this->jsonSchemaObject( + ['test' => ['type' => 'array', 'items' => ['$ref' => '#/definitions/User']]], + ['test'] + ), $schema); + } + + /** + * @test + */ + public function it_parses_shorthand_validation_and_adds_it_to_json_schema(): void + { + $schema = Shorthand::convertToJsonSchema([ + 'test1' => 'string|format:email|maxLength:255', + 'test2' => 'number|minimum:0.5|maximum:10', + 'test3' => 'string|null|format:email', + 'test4' => 'boolean|default:false', + 'test5' => 'boolean|null|default:true' + ]); + + $this->assertEquals($this->jsonSchemaObject( + [ + 'test1' => ['type' => 'string', 'format' => 'email', 'maxLength' => 255], + 'test2' => ['type' => 'number', 'minimum' => 0.5, 'maximum' => 10], + 'test3' => ['type' => ['string', 'null'], 'format' => 'email'], + 'test4' => ['type' => 'boolean', 'default' => false], + 'test5' => ['type' => ['boolean', 'null'], 'default' => true], + ], + ['test1', 'test2', 'test3', 'test4', 'test5'] + ), $schema); + } + + /** + * @test + */ + public function it_converts_shorthand_top_level_array_to_json_schema_array(): void + { + $variants = ['Profile', 'Profile[]']; + + foreach ($variants as $variant) { + $schema = Shorthand::convertToJsonSchema(['$items' => $variant]); + + $this->assertEquals(['type' => 'array', 'items' => ['$ref' => '#/definitions/Profile']], $schema); + } + } + + /** + * @test + */ + public function it_converts_top_level_reference_to_json_schema_reference(): void + { + $variants = ['Profile', '#/definitions/Profile']; + + foreach ($variants as $variant) { + $schema = Shorthand::convertToJsonSchema(['$ref' => $variant]); + + $this->assertEquals(['$ref' => '#/definitions/Profile'], $schema); + } + } + + /** + * @test + */ + public function it_converts_shorthand_object_to_json_schema_object(): void + { + $schema = Shorthand::convertToJsonSchema([ + '$title' => 'Prospect', + 'name' => 'string|minLength:1', + 'email' => 'string|format:email', + 'age?' => 'number|minimum:0', + 'address' => [ + 'zip' => 'string|minLength:1', + 'city' => 'string|minLength:1', + ], + 'tags' => 'string[]|minLength:1', + 'searchProfile?' => [ + 'roomsMin' => 'number|null|minimum:0.5', + 'roomsMax' => 'number|null|minimum:0.5', + ] + ]); + + $this->assertEquals( + $this->jsonSchemaObject([ + 'name' => ['type' => 'string', 'minLength' => 1], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'number', 'minimum' => 0], + 'address' => $this->jsonSchemaObject([ + 'zip' => ['type' => 'string', 'minLength' => 1], + 'city' => ['type' => 'string', 'minLength' => 1], + ], ['zip', 'city']), + 'tags' => ['type' => 'array', 'items' => ['type' => 'string', 'minLength' => 1]], + 'searchProfile' => $this->jsonSchemaObject([ + 'roomsMin' => ['type' => ['number', 'null'], 'minimum' => 0.5], + 'roomsMax' => ['type' => ['number', 'null'], 'minimum' => 0.5], + ], ['roomsMin', 'roomsMax']) + ], ['name', 'email', 'address', 'tags'], 'Prospect'), + $schema + ); + } + + /** + * @param array $properties + * @param string[] $required + * @param null|string $title + * @return array + */ + private function jsonSchemaObject(array $properties, array $required = [], string $title = null): array + { + $obj = [ + 'type' => 'object', + 'properties' => $properties, + 'additionalProperties' => false, + 'required' => $required + ]; + + if($title) { + $obj['title'] = $title; + } + + return $obj; + } +} diff --git a/tests/Type/TypeTest.php b/tests/Type/TypeTest.php new file mode 100644 index 0000000..329e84b --- /dev/null +++ b/tests/Type/TypeTest.php @@ -0,0 +1,30 @@ + 'string|minLength:1', '$title' => 'Person'], 'Person'); + + $this->assertCount(1, $typeSet); + + /** @var ObjectType $type */ + $type = $typeSet->first(); + + $properties = $type->properties(); + + $this->assertArrayHasKey('name', $properties); + $this->assertEquals('Person', $type->name()); + $this->assertEquals('Person', $type->title()); + } +}