diff --git a/composer.json b/composer.json index edcb41d..df5d467 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ } ], "require": { - "php": "^8.3" + "php": "^8.3", + "nikic/php-parser": "^5.8" }, "require-dev": { "chubbyphp/chubbyphp-dev-helper": "dev-master", diff --git a/src/JsonSchemaToCode.php b/src/JsonSchemaToCode.php new file mode 100644 index 0000000..ee82f62 --- /dev/null +++ b/src/JsonSchemaToCode.php @@ -0,0 +1,309 @@ +, + * keywords: array, + * }> + */ + private const array TYPES = [ + 'string' => [ + 'method' => 'string', + 'keywords' => [ + 'minLength' => self::ARG_INT, + 'maxLength' => self::ARG_INT, + 'pattern' => self::ARG_PATTERN, + 'format' => self::ARG_FORMAT, + ], + ], + 'number' => [ + 'method' => 'float', + 'keywords' => [ + 'minimum' => self::ARG_FLOAT, + 'maximum' => self::ARG_FLOAT, + 'exclusiveMinimum' => self::ARG_FLOAT, + 'exclusiveMaximum' => self::ARG_FLOAT, + 'multipleOf' => self::ARG_FLOAT, + ], + ], + 'integer' => [ + 'method' => 'int', + 'keywords' => [ + 'minimum' => self::ARG_INT, + 'maximum' => self::ARG_INT, + 'exclusiveMinimum' => self::ARG_INT, + 'exclusiveMaximum' => self::ARG_INT, + 'multipleOf' => self::ARG_INT, + ], + ], + 'boolean' => [ + 'method' => 'bool', + 'keywords' => [], + ], + 'array' => [ + 'factories' => [ + 'items' => ['method' => 'array', 'list' => false], + 'prefixItems' => ['method' => 'tuple', 'list' => true], + ], + 'keywords' => [ + 'minItems' => self::ARG_INT, + 'maxItems' => self::ARG_INT, + 'uniqueItems' => self::ARG_FLAG, + ], + ], + ]; + + /** + * @var array + */ + private const array FORMAT_TO_METHOD = [ + 'date' => 'date', + 'date-time' => 'dateTime', + 'duration' => 'duration', + 'email' => 'email', + 'hostname' => 'hostname', + 'idn-email' => 'idnEmail', + 'idn-hostname' => 'idnHostname', + 'ipv4' => 'ipV4', + 'ipv6' => 'ipV6', + 'iri' => 'iri', + 'iri-reference' => 'iriReference', + 'json-pointer' => 'jsonPointer', + 'mac' => 'mac', + 'relative-json-pointer' => 'relativeJsonPointer', + 'time' => 'time', + 'uri' => 'uri', + 'uri-reference' => 'uriReference', + 'uri-template' => 'uriTemplate', + 'url' => 'url', + 'uuid' => 'uuid', + ]; + + private Standard $prettyPrinter; + + public function __construct() + { + $this->prettyPrinter = new Standard(); + } + + public function convert(string $jsonSchema): string + { + /** @var mixed $schema */ + $schema = json_decode($jsonSchema, true, 512, JSON_THROW_ON_ERROR); + + if (!\is_array($schema)) { + throw new \InvalidArgumentException('Json schema must decode to an object'); + } + + $statement = new Expression(new Assign(new Variable('schema'), $this->convertSchema($schema))); + + return $this->prettyPrinter->prettyPrint([$statement]); + } + + /** + * @param array $schema + */ + private function convertSchema(array $schema): Expr + { + $type = $schema['type'] ?? null; + + if ('object' === $type) { + return $this->convertObject($schema); + } + + if (!\is_string($type) || !isset(self::TYPES[$type])) { + throw new \InvalidArgumentException( + \sprintf('Unsupported type "%s"', \is_string($type) ? $type : \gettype($type)) + ); + } + + $typeDefinition = self::TYPES[$type]; + + $expr = $this->createSchema($type, $typeDefinition, $schema); + + foreach ($typeDefinition['keywords'] as $keyword => $argType) { + if (isset($schema[$keyword])) { + $expr = $this->addKeyword($expr, $keyword, $argType, $schema[$keyword]); + } + } + + return $expr; + } + + /** + * @param array $schema + */ + private function convertObject(array $schema): Expr + { + $required = []; + foreach ($this->subSchema($schema['required'] ?? [], 'required') as $fieldName) { + $required[] = $this->string($fieldName, 'required'); + } + + $fields = []; + $optional = []; + foreach ($this->subSchema($schema['properties'] ?? [], 'properties') as $fieldName => $fieldSchema) { + if (!\is_string($fieldName)) { + throw new \InvalidArgumentException(\sprintf('Unsupported numeric property name "%s"', $fieldName)); + } + + $fields[] = new ArrayItem( + $this->convertSchema($this->subSchema($fieldSchema, 'properties')), + new String_($fieldName) + ); + + if (!\in_array($fieldName, $required, true)) { + $optional[] = new ArrayItem(new String_($fieldName)); + } + } + + $expr = new MethodCall(new Variable('p'), 'object', [new Arg(new Array_($fields))]); + + if ([] !== $optional) { + $expr = new MethodCall($expr, 'optional', [new Arg(new Array_($optional))]); + } + + if (isset($schema['additionalProperties']) && !$this->bool($schema['additionalProperties'], 'additionalProperties')) { + $expr = new MethodCall($expr, 'strict'); + } + + return $expr; + } + + /** + * @param array{ + * method?: string, + * factories?: array, + * keywords: array, + * } $typeDefinition + * @param array $schema + */ + private function createSchema(string $type, array $typeDefinition, array $schema): Expr + { + $parser = new Variable('p'); + + foreach ($typeDefinition['factories'] ?? [] as $keyword => $factory) { + if (!isset($schema[$keyword])) { + continue; + } + + $subSchema = $this->subSchema($schema[$keyword], $keyword); + + $arg = $factory['list'] + ? new Array_(array_map( + fn (mixed $itemSchema) => new ArrayItem($this->convertSchema($this->subSchema($itemSchema, $keyword))), + $subSchema + )) + : $this->convertSchema($subSchema); + + return new MethodCall($parser, $factory['method'], [new Arg($arg)]); + } + + return new MethodCall( + $parser, + $typeDefinition['method'] ?? throw new \InvalidArgumentException( + \sprintf('Type "%s" requires one of: %s', $type, implode(', ', array_keys($typeDefinition['factories'] ?? []))) + ) + ); + } + + /** + * @param self::ARG_* $argType + */ + private function addKeyword(Expr $expr, string $keyword, string $argType, mixed $value): Expr + { + return match ($argType) { + self::ARG_INT => new MethodCall($expr, $keyword, [ + new Arg(new Int_($this->int($value, $keyword))), + ]), + self::ARG_FLOAT => new MethodCall($expr, $keyword, [ + new Arg(new Float_($this->float($value, $keyword))), + ]), + self::ARG_PATTERN => new MethodCall($expr, $keyword, [ + new Arg(new String_('/'.str_replace('/', '\/', $this->string($value, $keyword)).'/')), + ]), + self::ARG_FORMAT => new MethodCall($expr, $this->formatMethod($this->string($value, $keyword))), + self::ARG_FLAG => $this->bool($value, $keyword) ? new MethodCall($expr, $keyword) : $expr, + }; + } + + private function formatMethod(string $format): string + { + return self::FORMAT_TO_METHOD[$format] + ?? throw new \InvalidArgumentException(\sprintf('Unsupported format "%s"', $format)); + } + + /** + * @return array + */ + private function subSchema(mixed $value, string $keyword): array + { + if (!\is_array($value)) { + throw new \InvalidArgumentException(\sprintf('Keyword "%s" must be a schema or a list of schemas', $keyword)); + } + + return $value; + } + + private function int(mixed $value, string $keyword): int + { + if (!\is_int($value)) { + throw new \InvalidArgumentException(\sprintf('Keyword "%s" must be an integer', $keyword)); + } + + return $value; + } + + private function float(mixed $value, string $keyword): float + { + if (!\is_int($value) && !\is_float($value)) { + throw new \InvalidArgumentException(\sprintf('Keyword "%s" must be a number', $keyword)); + } + + return (float) $value; + } + + private function bool(mixed $value, string $keyword): bool + { + if (!\is_bool($value)) { + throw new \InvalidArgumentException(\sprintf('Keyword "%s" must be a boolean', $keyword)); + } + + return $value; + } + + private function string(mixed $value, string $keyword): string + { + if (!\is_string($value)) { + throw new \InvalidArgumentException(\sprintf('Keyword "%s" must be a string', $keyword)); + } + + return $value; + } +} diff --git a/tests/Unit/JsonSchemaToCodeTest.php b/tests/Unit/JsonSchemaToCodeTest.php new file mode 100644 index 0000000..fc49198 --- /dev/null +++ b/tests/Unit/JsonSchemaToCodeTest.php @@ -0,0 +1,687 @@ +convert($jsonSchema); + + self::assertSame($expectedPhp, $php); + + $p = new Parser(); + $schema = null; + + eval($php); + + self::assertInstanceOf(SchemaInterface::class, $schema); + } + + /** + * @return iterable + */ + public static function provideJsonSchemaToParsingCodeCases(): iterable + { + yield 'string' => [ + '{"type":"string"}', + '$schema = $p->string();', + ]; + + yield 'string: minLength' => [ + '{"type":"string","minLength":3}', + '$schema = $p->string()->minLength(3);', + ]; + + yield 'string: maxLength' => [ + '{"type":"string","maxLength":64}', + '$schema = $p->string()->maxLength(64);', + ]; + + yield 'string: pattern' => [ + '{"type":"string","pattern":"^[a-z][a-z0-9-]*$"}', + '$schema = $p->string()->pattern(\'/^[a-z][a-z0-9-]*$/\');', + ]; + + yield 'string: pattern with slashes' => [ + '{"type":"string","pattern":"^/api/[a-z]+$"}', + '$schema = $p->string()->pattern(\'/^\/api\/[a-z]+$/\');', + ]; + + yield 'string: format date' => [ + '{"type":"string","format":"date"}', + '$schema = $p->string()->date();', + ]; + + yield 'string: format date-time' => [ + '{"type":"string","format":"date-time"}', + '$schema = $p->string()->dateTime();', + ]; + + yield 'string: format duration' => [ + '{"type":"string","format":"duration"}', + '$schema = $p->string()->duration();', + ]; + + yield 'string: format email' => [ + '{"type":"string","format":"email"}', + '$schema = $p->string()->email();', + ]; + + yield 'string: format idn-email' => [ + '{"type":"string","format":"idn-email"}', + '$schema = $p->string()->idnEmail();', + ]; + + yield 'string: format idn-hostname' => [ + '{"type":"string","format":"idn-hostname"}', + '$schema = $p->string()->idnHostname();', + ]; + + yield 'string: format time' => [ + '{"type":"string","format":"time"}', + '$schema = $p->string()->time();', + ]; + + yield 'string: format hostname' => [ + '{"type":"string","format":"hostname"}', + '$schema = $p->string()->hostname();', + ]; + + yield 'string: format ipv4' => [ + '{"type":"string","format":"ipv4"}', + '$schema = $p->string()->ipV4();', + ]; + + yield 'string: format ipv6' => [ + '{"type":"string","format":"ipv6"}', + '$schema = $p->string()->ipV6();', + ]; + + yield 'string: format iri' => [ + '{"type":"string","format":"iri"}', + '$schema = $p->string()->iri();', + ]; + + yield 'string: format iri-reference' => [ + '{"type":"string","format":"iri-reference"}', + '$schema = $p->string()->iriReference();', + ]; + + yield 'string: format json-pointer' => [ + '{"type":"string","format":"json-pointer"}', + '$schema = $p->string()->jsonPointer();', + ]; + + yield 'string: format mac' => [ + '{"type":"string","format":"mac"}', + '$schema = $p->string()->mac();', + ]; + + yield 'string: format relative-json-pointer' => [ + '{"type":"string","format":"relative-json-pointer"}', + '$schema = $p->string()->relativeJsonPointer();', + ]; + + yield 'string: format uri' => [ + '{"type":"string","format":"uri"}', + '$schema = $p->string()->uri();', + ]; + + yield 'string: format uri-reference' => [ + '{"type":"string","format":"uri-reference"}', + '$schema = $p->string()->uriReference();', + ]; + + yield 'string: format uri-template' => [ + '{"type":"string","format":"uri-template"}', + '$schema = $p->string()->uriTemplate();', + ]; + + yield 'string: format url' => [ + '{"type":"string","format":"url"}', + '$schema = $p->string()->url();', + ]; + + yield 'string: format uuid' => [ + '{"type":"string","format":"uuid"}', + '$schema = $p->string()->uuid();', + ]; + + yield 'string: minLength + maxLength' => [ + '{"type":"string","minLength":3,"maxLength":64}', + '$schema = $p->string()->minLength(3)->maxLength(64);', + ]; + + yield 'string: minLength + pattern' => [ + '{"type":"string","minLength":3,"pattern":"^[a-z][a-z0-9-]*$"}', + '$schema = $p->string()->minLength(3)->pattern(\'/^[a-z][a-z0-9-]*$/\');', + ]; + + yield 'string: minLength + format' => [ + '{"type":"string","minLength":3,"format":"email"}', + '$schema = $p->string()->minLength(3)->email();', + ]; + + yield 'string: maxLength + pattern' => [ + '{"type":"string","maxLength":64,"pattern":"^[a-z][a-z0-9-]*$"}', + '$schema = $p->string()->maxLength(64)->pattern(\'/^[a-z][a-z0-9-]*$/\');', + ]; + + yield 'string: maxLength + format' => [ + '{"type":"string","maxLength":64,"format":"email"}', + '$schema = $p->string()->maxLength(64)->email();', + ]; + + yield 'string: pattern + format' => [ + '{"type":"string","pattern":"^[a-z][a-z0-9-]*$","format":"email"}', + '$schema = $p->string()->pattern(\'/^[a-z][a-z0-9-]*$/\')->email();', + ]; + + yield 'string: minLength + maxLength + pattern' => [ + '{"type":"string","minLength":3,"maxLength":64,"pattern":"^[a-z][a-z0-9-]*$"}', + '$schema = $p->string()->minLength(3)->maxLength(64)->pattern(\'/^[a-z][a-z0-9-]*$/\');', + ]; + + yield 'string: minLength + maxLength + format' => [ + '{"type":"string","minLength":3,"maxLength":64,"format":"email"}', + '$schema = $p->string()->minLength(3)->maxLength(64)->email();', + ]; + + yield 'string: minLength + pattern + format' => [ + '{"type":"string","minLength":3,"pattern":"^[a-z][a-z0-9-]*$","format":"email"}', + '$schema = $p->string()->minLength(3)->pattern(\'/^[a-z][a-z0-9-]*$/\')->email();', + ]; + + yield 'string: maxLength + pattern + format' => [ + '{"type":"string","maxLength":64,"pattern":"^[a-z][a-z0-9-]*$","format":"email"}', + '$schema = $p->string()->maxLength(64)->pattern(\'/^[a-z][a-z0-9-]*$/\')->email();', + ]; + + yield 'string: minLength + maxLength + pattern + format' => [ + '{"type":"string","minLength":3,"maxLength":64,"pattern":"^[a-z][a-z0-9-]*$","format":"email"}', + '$schema = $p->string()->minLength(3)->maxLength(64)->pattern(\'/^[a-z][a-z0-9-]*$/\')->email();', + ]; + + yield 'number' => [ + '{"type":"number"}', + '$schema = $p->float();', + ]; + + yield 'number: minimum' => [ + '{"type":"number","minimum":0}', + '$schema = $p->float()->minimum(0.0);', + ]; + + yield 'number: maximum' => [ + '{"type":"number","maximum":100}', + '$schema = $p->float()->maximum(100.0);', + ]; + + yield 'number: exclusiveMinimum' => [ + '{"type":"number","exclusiveMinimum":0}', + '$schema = $p->float()->exclusiveMinimum(0.0);', + ]; + + yield 'number: exclusiveMaximum' => [ + '{"type":"number","exclusiveMaximum":100}', + '$schema = $p->float()->exclusiveMaximum(100.0);', + ]; + + yield 'number: multipleOf' => [ + '{"type":"number","multipleOf":0.5}', + '$schema = $p->float()->multipleOf(0.5);', + ]; + + yield 'number: minimum + maximum' => [ + '{"type":"number","minimum":0,"maximum":100}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0);', + ]; + + yield 'number: minimum + exclusiveMinimum' => [ + '{"type":"number","minimum":0,"exclusiveMinimum":0}', + '$schema = $p->float()->minimum(0.0)->exclusiveMinimum(0.0);', + ]; + + yield 'number: minimum + exclusiveMaximum' => [ + '{"type":"number","minimum":0,"exclusiveMaximum":100}', + '$schema = $p->float()->minimum(0.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: minimum + multipleOf' => [ + '{"type":"number","minimum":0,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->multipleOf(0.5);', + ]; + + yield 'number: maximum + exclusiveMinimum' => [ + '{"type":"number","maximum":100,"exclusiveMinimum":0}', + '$schema = $p->float()->maximum(100.0)->exclusiveMinimum(0.0);', + ]; + + yield 'number: maximum + exclusiveMaximum' => [ + '{"type":"number","maximum":100,"exclusiveMaximum":100}', + '$schema = $p->float()->maximum(100.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: maximum + multipleOf' => [ + '{"type":"number","maximum":100,"multipleOf":0.5}', + '$schema = $p->float()->maximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->float()->exclusiveMinimum(0.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: exclusiveMinimum + multipleOf' => [ + '{"type":"number","exclusiveMinimum":0,"multipleOf":0.5}', + '$schema = $p->float()->exclusiveMinimum(0.0)->multipleOf(0.5);', + ]; + + yield 'number: exclusiveMaximum + multipleOf' => [ + '{"type":"number","exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + maximum + exclusiveMinimum' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMinimum":0}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMinimum(0.0);', + ]; + + yield 'number: minimum + maximum + exclusiveMaximum' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMaximum":100}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: minimum + maximum + multipleOf' => [ + '{"type":"number","minimum":0,"maximum":100,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"number","minimum":0,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->float()->minimum(0.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: minimum + exclusiveMinimum + multipleOf' => [ + '{"type":"number","minimum":0,"exclusiveMinimum":0,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->exclusiveMinimum(0.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","minimum":0,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: maximum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"number","maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->float()->maximum(100.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: maximum + exclusiveMinimum + multipleOf' => [ + '{"type":"number","maximum":100,"exclusiveMinimum":0,"multipleOf":0.5}', + '$schema = $p->float()->maximum(100.0)->exclusiveMinimum(0.0)->multipleOf(0.5);', + ]; + + yield 'number: maximum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","maximum":100,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->maximum(100.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->exclusiveMinimum(0.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + maximum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0);', + ]; + + yield 'number: minimum + maximum + exclusiveMinimum + multipleOf' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMinimum":0,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMinimum(0.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + maximum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","minimum":0,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: maximum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->maximum(100.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'number: minimum + maximum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"number","minimum":0,"maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":0.5}', + '$schema = $p->float()->minimum(0.0)->maximum(100.0)->exclusiveMinimum(0.0)->exclusiveMaximum(100.0)->multipleOf(0.5);', + ]; + + yield 'integer' => [ + '{"type":"integer"}', + '$schema = $p->int();', + ]; + + yield 'integer: minimum' => [ + '{"type":"integer","minimum":0}', + '$schema = $p->int()->minimum(0);', + ]; + + yield 'integer: maximum' => [ + '{"type":"integer","maximum":100}', + '$schema = $p->int()->maximum(100);', + ]; + + yield 'integer: exclusiveMinimum' => [ + '{"type":"integer","exclusiveMinimum":0}', + '$schema = $p->int()->exclusiveMinimum(0);', + ]; + + yield 'integer: exclusiveMaximum' => [ + '{"type":"integer","exclusiveMaximum":100}', + '$schema = $p->int()->exclusiveMaximum(100);', + ]; + + yield 'integer: multipleOf' => [ + '{"type":"integer","multipleOf":2}', + '$schema = $p->int()->multipleOf(2);', + ]; + + yield 'integer: minimum + maximum' => [ + '{"type":"integer","minimum":0,"maximum":100}', + '$schema = $p->int()->minimum(0)->maximum(100);', + ]; + + yield 'integer: minimum + exclusiveMinimum' => [ + '{"type":"integer","minimum":0,"exclusiveMinimum":0}', + '$schema = $p->int()->minimum(0)->exclusiveMinimum(0);', + ]; + + yield 'integer: minimum + exclusiveMaximum' => [ + '{"type":"integer","minimum":0,"exclusiveMaximum":100}', + '$schema = $p->int()->minimum(0)->exclusiveMaximum(100);', + ]; + + yield 'integer: minimum + multipleOf' => [ + '{"type":"integer","minimum":0,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->multipleOf(2);', + ]; + + yield 'integer: maximum + exclusiveMinimum' => [ + '{"type":"integer","maximum":100,"exclusiveMinimum":0}', + '$schema = $p->int()->maximum(100)->exclusiveMinimum(0);', + ]; + + yield 'integer: maximum + exclusiveMaximum' => [ + '{"type":"integer","maximum":100,"exclusiveMaximum":100}', + '$schema = $p->int()->maximum(100)->exclusiveMaximum(100);', + ]; + + yield 'integer: maximum + multipleOf' => [ + '{"type":"integer","maximum":100,"multipleOf":2}', + '$schema = $p->int()->maximum(100)->multipleOf(2);', + ]; + + yield 'integer: exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->int()->exclusiveMinimum(0)->exclusiveMaximum(100);', + ]; + + yield 'integer: exclusiveMinimum + multipleOf' => [ + '{"type":"integer","exclusiveMinimum":0,"multipleOf":2}', + '$schema = $p->int()->exclusiveMinimum(0)->multipleOf(2);', + ]; + + yield 'integer: exclusiveMaximum + multipleOf' => [ + '{"type":"integer","exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: minimum + maximum + exclusiveMinimum' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMinimum":0}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMinimum(0);', + ]; + + yield 'integer: minimum + maximum + exclusiveMaximum' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMaximum":100}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMaximum(100);', + ]; + + yield 'integer: minimum + maximum + multipleOf' => [ + '{"type":"integer","minimum":0,"maximum":100,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->maximum(100)->multipleOf(2);', + ]; + + yield 'integer: minimum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"integer","minimum":0,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->int()->minimum(0)->exclusiveMinimum(0)->exclusiveMaximum(100);', + ]; + + yield 'integer: minimum + exclusiveMinimum + multipleOf' => [ + '{"type":"integer","minimum":0,"exclusiveMinimum":0,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->exclusiveMinimum(0)->multipleOf(2);', + ]; + + yield 'integer: minimum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","minimum":0,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: maximum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"integer","maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->int()->maximum(100)->exclusiveMinimum(0)->exclusiveMaximum(100);', + ]; + + yield 'integer: maximum + exclusiveMinimum + multipleOf' => [ + '{"type":"integer","maximum":100,"exclusiveMinimum":0,"multipleOf":2}', + '$schema = $p->int()->maximum(100)->exclusiveMinimum(0)->multipleOf(2);', + ]; + + yield 'integer: maximum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","maximum":100,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->maximum(100)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->exclusiveMinimum(0)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: minimum + maximum + exclusiveMinimum + exclusiveMaximum' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMinimum(0)->exclusiveMaximum(100);', + ]; + + yield 'integer: minimum + maximum + exclusiveMinimum + multipleOf' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMinimum":0,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMinimum(0)->multipleOf(2);', + ]; + + yield 'integer: minimum + maximum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: minimum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","minimum":0,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->exclusiveMinimum(0)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: maximum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->maximum(100)->exclusiveMinimum(0)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'integer: minimum + maximum + exclusiveMinimum + exclusiveMaximum + multipleOf' => [ + '{"type":"integer","minimum":0,"maximum":100,"exclusiveMinimum":0,"exclusiveMaximum":100,"multipleOf":2}', + '$schema = $p->int()->minimum(0)->maximum(100)->exclusiveMinimum(0)->exclusiveMaximum(100)->multipleOf(2);', + ]; + + yield 'boolean' => [ + '{"type":"boolean"}', + '$schema = $p->bool();', + ]; + + yield 'array: items' => [ + '{"type":"array","items":{"type":"string"}}', + '$schema = $p->array($p->string());', + ]; + + yield 'array: prefixItems' => [ + '{"type":"array","prefixItems":[{"type":"number"},{"type":"string"}]}', + '$schema = $p->tuple([$p->float(), $p->string()]);', + ]; + + yield 'array: items + minItems' => [ + '{"type":"array","items":{"type":"string"},"minItems":1}', + '$schema = $p->array($p->string())->minItems(1);', + ]; + + yield 'array: items + maxItems' => [ + '{"type":"array","items":{"type":"string"},"maxItems":10}', + '$schema = $p->array($p->string())->maxItems(10);', + ]; + + yield 'array: items + uniqueItems' => [ + '{"type":"array","items":{"type":"string"},"uniqueItems":true}', + '$schema = $p->array($p->string())->uniqueItems();', + ]; + + yield 'array: items + minItems + maxItems' => [ + '{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10}', + '$schema = $p->array($p->string())->minItems(1)->maxItems(10);', + ]; + + yield 'array: items + minItems + uniqueItems' => [ + '{"type":"array","items":{"type":"string"},"minItems":1,"uniqueItems":true}', + '$schema = $p->array($p->string())->minItems(1)->uniqueItems();', + ]; + + yield 'array: items + maxItems + uniqueItems' => [ + '{"type":"array","items":{"type":"string"},"maxItems":10,"uniqueItems":true}', + '$schema = $p->array($p->string())->maxItems(10)->uniqueItems();', + ]; + + yield 'array: items + minItems + maxItems + uniqueItems' => [ + '{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":10,"uniqueItems":true}', + '$schema = $p->array($p->string())->minItems(1)->maxItems(10)->uniqueItems();', + ]; + + yield 'object: properties' => [ + '{"type":"object","properties":{"name":{"type":"string"}}}', + '$schema = $p->object([\'name\' => $p->string()])->optional([\'name\']);', + ]; + + yield 'object: additionalProperties' => [ + '{"type":"object","additionalProperties":false}', + '$schema = $p->object([])->strict();', + ]; + + yield 'object: properties + additionalProperties' => [ + '{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":false}', + '$schema = $p->object([\'name\' => $p->string()])->optional([\'name\'])->strict();', + ]; + + yield 'object: properties + required' => [ + '{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + '$schema = $p->object([\'name\' => $p->string()]);', + ]; + + yield 'object: properties + additionalProperties + required' => [ + '{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":false,"required":["name"]}', + '$schema = $p->object([\'name\' => $p->string()])->strict();', + ]; + } + + public function testJsonSchemaToParsingCodeWithMaxDepth(): void + { + $jsonSchema = '{"type":"string","x-ignored":'.str_repeat('[', 510).'1'.str_repeat(']', 510).'}'; + + self::assertSame('$schema = $p->string();', (new JsonSchemaToCode())->convert($jsonSchema)); + } + + public function testJsonSchemaToParsingCodeWithTooDeepJsonSchema(): void + { + $jsonSchema = '{"type":"string","x-ignored":'.str_repeat('[', 511).'1'.str_repeat(']', 511).'}'; + + $this->expectException(\JsonException::class); + + (new JsonSchemaToCode())->convert($jsonSchema); + } + + public function testJsonSchemaToParsingCodeWithNonObjectJsonSchema(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Json schema must decode to an object'); + + (new JsonSchemaToCode())->convert('"string"'); + } + + public function testJsonSchemaToParsingCodeWithUnknownType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported type "unknown"'); + + (new JsonSchemaToCode())->convert('{"type":"unknown"}'); + } + + public function testJsonSchemaToParsingCodeWithNonStringType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported type "integer"'); + + (new JsonSchemaToCode())->convert('{"type":1}'); + } + + public function testJsonSchemaToParsingCodeWithArrayWithoutItemsAndPrefixItems(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Type "array" requires one of: items, prefixItems'); + + (new JsonSchemaToCode())->convert('{"type":"array"}'); + } + + public function testJsonSchemaToParsingCodeWithUnknownFormat(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported format "unknown"'); + + (new JsonSchemaToCode())->convert('{"type":"string","format":"unknown"}'); + } + + public function testJsonSchemaToParsingCodeWithNumericPropertyName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported numeric property name "0"'); + + (new JsonSchemaToCode())->convert('{"type":"object","properties":{"0":{"type":"string"}}}'); + } + + public function testJsonSchemaToParsingCodeWithNonNumericMinimum(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Keyword "minimum" must be a number'); + + (new JsonSchemaToCode())->convert('{"type":"number","minimum":"0"}'); + } +}