From 38cbc6ce700d138aac601deedbfff906ddfaf758 Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Tue, 7 Jul 2026 20:25:03 +0200 Subject: [PATCH 1/4] json-schema-to-parser --- composer.json | 3 +- src/JsonSchemaToCode.php | 294 +++++++++++++++ tests/Unit/JsonSchemaToCodeTest.php | 550 ++++++++++++++++++++++++++++ 3 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 src/JsonSchemaToCode.php create mode 100644 tests/Unit/JsonSchemaToCodeTest.php diff --git a/composer.json b/composer.json index 0f545c2..99e11aa 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..79c5a85 --- /dev/null +++ b/src/JsonSchemaToCode.php @@ -0,0 +1,294 @@ +, + * 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 = [ + 'email' => 'email', + 'hostname' => 'hostname', + 'ipv4' => 'ipV4', + 'ipv6' => 'ipV6', + 'mac' => 'mac', + 'uri' => 'uri', + '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) { + $fieldName = (string) $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))), + array_values($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..cfca6ac --- /dev/null +++ b/tests/Unit/JsonSchemaToCodeTest.php @@ -0,0 +1,550 @@ +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: format email' => [ + '{"type":"string","format":"email"}', + '$schema = $p->string()->email();', + ]; + + 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 mac' => [ + '{"type":"string","format":"mac"}', + '$schema = $p->string()->mac();', + ]; + + yield 'string: format uri' => [ + '{"type":"string","format":"uri"}', + '$schema = $p->string()->uri();', + ]; + + 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();', + ]; + } +} From b956ce6522ef5f5953de4f51d94cf3fee5eb20e0 Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Tue, 7 Jul 2026 20:25:03 +0200 Subject: [PATCH 2/4] json-schema-to-parser --- composer.json | 3 +- src/JsonSchemaToCode.php | 300 ++++++++++++++ tests/Unit/JsonSchemaToCodeTest.php | 580 ++++++++++++++++++++++++++++ 3 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 src/JsonSchemaToCode.php create mode 100644 tests/Unit/JsonSchemaToCodeTest.php diff --git a/composer.json b/composer.json index 0f545c2..99e11aa 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..dd3b644 --- /dev/null +++ b/src/JsonSchemaToCode.php @@ -0,0 +1,300 @@ +, + * 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', + 'mac' => 'mac', + 'time' => 'time', + 'uri' => 'uri', + '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) { + $fieldName = (string) $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))), + array_values($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..3efd38f --- /dev/null +++ b/tests/Unit/JsonSchemaToCodeTest.php @@ -0,0 +1,580 @@ +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: 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 mac' => [ + '{"type":"string","format":"mac"}', + '$schema = $p->string()->mac();', + ]; + + yield 'string: format uri' => [ + '{"type":"string","format":"uri"}', + '$schema = $p->string()->uri();', + ]; + + 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();', + ]; + } +} From 8c82787ba9c43017fb0728050bff5fd10ea0195d Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Tue, 7 Jul 2026 21:48:46 +0200 Subject: [PATCH 3/4] add more formats --- src/JsonSchemaToCode.php | 13 +++- tests/Unit/JsonSchemaToCodeTest.php | 107 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/JsonSchemaToCode.php b/src/JsonSchemaToCode.php index dd3b644..ee82f62 100644 --- a/src/JsonSchemaToCode.php +++ b/src/JsonSchemaToCode.php @@ -92,9 +92,15 @@ final class JsonSchemaToCode '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', ]; @@ -163,7 +169,10 @@ private function convertObject(array $schema): Expr $fields = []; $optional = []; foreach ($this->subSchema($schema['properties'] ?? [], 'properties') as $fieldName => $fieldSchema) { - $fieldName = (string) $fieldName; + 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) @@ -209,7 +218,7 @@ private function createSchema(string $type, array $typeDefinition, array $schema $arg = $factory['list'] ? new Array_(array_map( fn (mixed $itemSchema) => new ArrayItem($this->convertSchema($this->subSchema($itemSchema, $keyword))), - array_values($subSchema) + $subSchema )) : $this->convertSchema($subSchema); diff --git a/tests/Unit/JsonSchemaToCodeTest.php b/tests/Unit/JsonSchemaToCodeTest.php index 3efd38f..fc49198 100644 --- a/tests/Unit/JsonSchemaToCodeTest.php +++ b/tests/Unit/JsonSchemaToCodeTest.php @@ -57,6 +57,11 @@ public static function provideJsonSchemaToParsingCodeCases(): iterable '$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();', @@ -107,16 +112,46 @@ public static function provideJsonSchemaToParsingCodeCases(): iterable '$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();', @@ -577,4 +612,76 @@ public static function provideJsonSchemaToParsingCodeCases(): iterable '$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"}'); + } } From cfae664c9ca451395fe213499dc832fc9e775f5f Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Wed, 8 Jul 2026 21:24:34 +0200 Subject: [PATCH 4/4] float schema should accept integer but convert them to float --- src/Schema/FloatSchema.php | 4 ++ tests/Unit/Schema/FloatSchemaTest.php | 66 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/Schema/FloatSchema.php b/src/Schema/FloatSchema.php index f76bb8a..5b82318 100644 --- a/src/Schema/FloatSchema.php +++ b/src/Schema/FloatSchema.php @@ -311,6 +311,10 @@ protected function innerParse(mixed $input): mixed return $input; } + if (\is_int($input)) { + return (float) $input; + } + throw new ErrorsException( new Error( self::ERROR_TYPE_CODE, diff --git a/tests/Unit/Schema/FloatSchemaTest.php b/tests/Unit/Schema/FloatSchemaTest.php index ebf87e2..aa59e54 100644 --- a/tests/Unit/Schema/FloatSchemaTest.php +++ b/tests/Unit/Schema/FloatSchemaTest.php @@ -41,6 +41,15 @@ public function testParseSuccess(): void self::assertSame($input, $schema->parse($input)); } + public function testParseSuccessWithInt(): void + { + $input = 1; + + $schema = new FloatSchema(); + + self::assertSame(1.0, $schema->parse($input)); + } + public function testParseSuccessWithDefault(): void { $input1 = 1.5; @@ -192,6 +201,44 @@ public function testParseWithInvalidMinimum(): void } } + public function testParseWithValidMinimumWithInt(): void + { + $input = 4; + $minimum = 4.0; + + $schema = (new FloatSchema())->minimum($minimum); + + self::assertSame(4.0, $schema->parse($input)); + } + + public function testParseWithInvalidMinimumWithInt(): void + { + $input = 4; + $minimum = 4.1; + + $schema = (new FloatSchema())->minimum($minimum); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (ErrorsException $errorsException) { + self::assertSame([ + [ + 'path' => '', + 'error' => [ + 'code' => 'float.minimum', + 'template' => 'Value should be minimum {{minimum}}, {{given}} given', + 'variables' => [ + 'minimum' => $minimum, + 'given' => 4, + ], + ], + ], + ], $errorsException->errors->jsonSerialize()); + } + } + public function testParseWithValidExclusiveMinimum(): void { $input = 4.2; @@ -406,6 +453,16 @@ public function testParseWithInvalidMultipleOf(): void } } + public function testParseWithValidMultipleOfWithInt(): void + { + $input = 3; + $multipleOf = 0.5; + + $schema = (new FloatSchema())->multipleOf($multipleOf); + + self::assertSame(3.0, $schema->parse($input)); + } + public function testMultipleOfWithInvalidMultipleOf(): void { $this->expectException(\InvalidArgumentException::class); @@ -886,6 +943,15 @@ public function testParseWithValidtoInt(): void self::assertSame((int) $input, $schema->parse($input)); } + public function testParseWithValidToIntWithInt(): void + { + $input = 4; + + $schema = (new FloatSchema())->toInt(); + + self::assertSame($input, $schema->parse($input)); + } + public function testParseWithToIntNullable(): void { $schema = (new FloatSchema())->nullable()->toInt();