diff --git a/composer.json b/composer.json index 99f8872..5fb8891 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ ], "require": { "php": ">=7.0", - "phpdocumentor/reflection-docblock": "^4.3", + "phpdocumentor/reflection-docblock": "^4.3 || ^5.0", "justinrainbow/json-schema": "^5.2" }, "require-dev": { diff --git a/src/ApiInterface.php b/src/ApiInterface.php index dfc22e3..cc62577 100644 --- a/src/ApiInterface.php +++ b/src/ApiInterface.php @@ -9,11 +9,17 @@ */ interface ApiInterface { + /** + * @return mixed + */ public static function parameters(); + /** + * @return mixed + */ public static function requestBody(); - public static function responses(); + public static function responses(): array; - public static function permissions(); + public static function permissions(): array; } diff --git a/src/InputValidator.php b/src/InputValidator.php index 1614dc4..664f121 100644 --- a/src/InputValidator.php +++ b/src/InputValidator.php @@ -3,7 +3,9 @@ namespace rethink\typedphp; use JsonSchema\Constraints\Constraint; +use JsonSchema\Constraints\Factory; use JsonSchema\Validator; +use rethink\typedphp\constraints\TypeConstraint; /** * Class InputValidator @@ -37,8 +39,11 @@ protected function validateInternal($definition, $data, &$result) } $schema = $definition['schema']; + + $factory = new Factory(); + $factory->setConstraintClass('type', TypeConstraint::class); - $validator = new Validator(); + $validator = new Validator($factory); $result = $data[$definition['name']]; diff --git a/src/TypeParser.php b/src/TypeParser.php index a745916..ee5c314 100644 --- a/src/TypeParser.php +++ b/src/TypeParser.php @@ -18,6 +18,7 @@ use rethink\typedphp\types\TimestampType; use rethink\typedphp\types\TimeType; use rethink\typedphp\types\Type; +use rethink\typedphp\types\UnionType; /** * Class TypeParser @@ -240,11 +241,6 @@ protected function isVersion31() protected function makeNullableSchema(array $schema, $nullable) { - if (($this->mode & self::MODE_OPEN_API) && ! $this->isVersion31()) { - // OpenAPI specficition does not support this, just ingore the nullable setting. - return $schema; - } - if (! $nullable) { return $schema; } @@ -268,12 +264,8 @@ protected function parseScalar($definition) $typeClass = $this->getValidTypeClass($definition); $schema = $typeClass::toArray(); - if (($this->mode & self::MODE_JSON_SCHEMA) && $nullable) { - $schema['type'] = [$schema['type'], 'null']; - } elseif (($this->mode & self::MODE_OPEN_API) && $this->isVersion31() && $nullable) { + if ($nullable) { $schema['type'] = [$schema['type'], 'null']; - } elseif (($this->mode & self::MODE_OPEN_API) && $nullable) { - $schema['nullable'] = $nullable; } return $schema; @@ -312,18 +304,58 @@ protected function parseMap(string $definition): array return $this->makeNullableSchema($schema, $nullable); } + protected function parseUnion(string $definition): array + { + $nullable = false; + if ($this->isNullable($definition)) { + $nullable = true; + $definition = trim($definition, '?'); + } + + assert(is_subclass_of($definition, UnionType::class)); + + $types = []; + foreach ($definition::allowedTypes() as $allowedType) { + $types[] = $this->parse($allowedType); + } + + if ($nullable) { + $types[] = ['type' => 'null']; + } + + return [ + 'oneOf' => $types, + ]; + } + protected function parseString($definition) { + static $cached = []; $newDefinition = trim($definition, '?'); + + $key = $definition; + if (is_subclass_of($newDefinition, MapType::class)) { + $key = $newDefinition; + } + + $key = $this->mode & self::MODE_REF_SCHEMA ? 'ref:' . $key : $key; + + if (isset($cached[$key])) { + return $cached[$key]; + } + if (is_subclass_of($newDefinition, ProductType::class)) { - return $this->parseObject($definition); + $cached[$key] = $this->parseObject($definition); } elseif (is_subclass_of($newDefinition, SumType::class)) { - return $this->parseEnum($definition); + $cached[$key]= $this->parseEnum($definition); } elseif (is_subclass_of($newDefinition, MapType::class)) { - return $this->parseMap($newDefinition); + $cached[$key] = $this->parseMap($definition); + } elseif (is_subclass_of($newDefinition, UnionType::class)) { + $cached[$key] = $this->parseUnion($definition); } else { - return $this->parseScalar($definition); + $cached[$key] = $this->parseScalar($definition); } + return $cached[$key]; } /** diff --git a/src/constraints/TypeConstraint.php b/src/constraints/TypeConstraint.php new file mode 100644 index 0000000..45cd833 --- /dev/null +++ b/src/constraints/TypeConstraint.php @@ -0,0 +1,21 @@ +parse(); + throw new \Exception('Should not be called'); } } diff --git a/src/types/UnionType.php b/src/types/UnionType.php new file mode 100644 index 0000000..97e6802 --- /dev/null +++ b/src/types/UnionType.php @@ -0,0 +1,24 @@ + 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'is_admin' => ['type' => 'boolean'], + 'file' => ['type' => 'string', 'format' => 'binary',], + 'nullable_field' => ['type' => ['string', 'null']], + 'date' => [ + 'type' => ['string', 'null'], + 'format' => 'date', + 'pattern' => '^\d{4}-\d{2}-\d{2}$', + ], + 'time' => [ + 'type' => 'string', + 'pattern' => '^\d{2}:\d{2}:\d{2}$', + ], + ], + 'required' => ['id', 'file'], + ]; + } + public function typeToArrayCases() { + $product001Schema = $this->product001Schema(); + return [ [ Map001Type::class, @@ -28,7 +56,6 @@ public function typeToArrayCases() 'type' => 'object', 'example' => ['id' => 1, 'name' => 'INFO'], ], - null, ], [ Map002Type::class, @@ -39,7 +66,6 @@ public function typeToArrayCases() ], 'example' => ['优' => 90, '良' => 80, '中' => 60], ], - null, ], [ Map003Type::class, @@ -54,14 +80,12 @@ public function typeToArrayCases() ], ], ], - null, ], [ 'string', [ 'type' => 'string', ], - null, ], [ ['string'], @@ -71,7 +95,6 @@ public function typeToArrayCases() 'type' => 'string', ], ], - null, ], [ @@ -79,10 +102,6 @@ public function typeToArrayCases() [ 'type' => ['string', 'null'], ], - [ - 'type' => 'string', - 'nullable' => true, - ], ], [ @@ -93,13 +112,6 @@ public function typeToArrayCases() 'type' => ['string', 'null'], ], ], - [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'nullable' => true, - ], - ], ], [ @@ -111,13 +123,6 @@ public function typeToArrayCases() 'bar', ], ], - [ - 'type' => 'string', - 'enum' => [ - 'foo', - 'bar', - ], - ], ], [ @@ -142,27 +147,6 @@ public function typeToArrayCases() ], 'required' => ['id', 'file'], ], - [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string'], - 'is_admin' => ['type' => 'boolean'], - 'file' => ['type' => 'string', 'format' => 'binary',], - 'nullable_field' => ['type' => 'string', 'nullable' => true], - 'date' => [ - 'type' => 'string', - 'format' => 'date', - 'nullable' => true, - 'pattern' => '^\d{4}-\d{2}-\d{2}$', - ], - 'time' => [ - 'type' => 'string', - 'pattern' => '^\d{2}:\d{2}:\d{2}$', - ], - ], - 'required' => ['id', 'file'], - ], ], [ @@ -184,24 +168,6 @@ public function typeToArrayCases() ], ], ], - [ - 'type' => 'object', - 'properties' => [ - 'field1' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - ], - ], - 'field2' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'nullable' => true, - ], - ], - ], - ], ], [ Product005Type::class, @@ -231,33 +197,6 @@ public function typeToArrayCases() ], ], ], - [ - 'required' => ['related1'], - 'type' => 'object', - 'properties' => [ - 'related1' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'field1' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - ], - ], - 'field2' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'nullable' => true, - ], - ], - ], - ], - ], - ], - ], ], [ Product003Type::class, @@ -304,51 +243,6 @@ public function typeToArrayCases() ], ], ], - [ - 'type' => 'object', - 'properties' => [ - 'related1' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string'], - 'is_admin' => ['type' => 'boolean'], - 'file' => ['type' => 'string', 'format' => 'binary',], - 'nullable_field' => ['type' => 'string', 'nullable' => true], - 'date' => [ - 'type' => 'string', - 'format' => 'date', - 'nullable' => true, - 'pattern' => '^\d{4}-\d{2}-\d{2}$', - ], - 'time' => [ - 'type' => 'string', - 'pattern' => '^\d{2}:\d{2}:\d{2}$', - ], - ], - 'required' => ['id', 'file'], - - ], - 'related2' => [ - 'type' => 'object', - 'properties' => [ - 'field1' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - ], - ], - 'field2' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'nullable' => true, - ], - ], - ], - ], - ], - ], ], [ @@ -404,52 +298,6 @@ public function typeToArrayCases() ], 'required' => ['related1'], ], - [ - 'type' => 'object', - 'properties' => [ - 'related1' => [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string'], - 'is_admin' => ['type' => 'boolean'], - 'file' => ['type' => 'string', 'format' => 'binary'], - 'nullable_field' => ['type' => 'string', 'nullable' => true], - 'date' => [ - 'type' => 'string', - 'format' => 'date', - 'nullable' => true, - 'pattern' => '^\d{4}-\d{2}-\d{2}$', - ], - 'time' => [ - 'type' => 'string', - 'pattern' => '^\d{2}:\d{2}:\d{2}$', - ], - ], - 'required' => ['id', 'file'], - - ], - 'related2' => [ - 'type' => 'object', - 'properties' => [ - 'field1' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - ], - ], - 'field2' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'nullable' => true, - ], - ], - ], - ], - ], - 'required' => ['related1'], - ], ], [ @@ -478,29 +326,7 @@ public function typeToArrayCases() 'schema' => ['type' => 'number'], ], ], - - [ - [ - 'name' => 'limit', - 'in' => 'query', - 'required' => true, - 'schema' => ['type' => 'number'], - ], - [ - 'name' => 'offset', - 'in' => 'query', - 'required' => false, - 'schema' => ['type' => 'number', 'nullable' => true], - ], - [ - 'name' => 'default_in', - 'in' => 'query', - 'required' => false, - 'schema' => ['type' => 'number'], - ], - ], ], - [ [Product001Type::class], [ @@ -537,40 +363,18 @@ public function typeToArrayCases() 'required' => ['id', 'file'], ], ], + ], + [ + Union001Type::class, [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'integer', - ], - 'name' => [ - 'type' => 'string', - ], - 'is_admin' => [ - 'type' => 'boolean', - ], - 'file' => [ - 'type' => 'string', - 'format' => 'binary', - ], - 'nullable_field' => [ - 'type' => 'string', - 'nullable' => true, - ], - 'date' => [ - 'type' => 'string', - 'format' => 'date', - 'nullable' => true, - 'pattern' => '^\d{4}-\d{2}-\d{2}$', - ], - 'time' => [ - 'type' => 'string', - 'pattern' => '^\d{2}:\d{2}:\d{2}$', - ], + 'oneOf' => [ + [ + 'type' => 'string', ], - 'required' => ['id', 'file'], + [ + 'type' => 'integer', + ], + $product001Schema, ], ], ], @@ -581,17 +385,13 @@ public function typeToArrayCases() * @dataProvider typeToArrayCases * @param $type * @param $expect1 - * @param $expect2 */ - public function testTypeToArray($type, $expect1, $expect2) + public function testTypeToArray($type, $expect1) { $parser = new TypeParser(TypeParser::MODE_JSON_SCHEMA); $this->assertEquals($expect1, $parser->parse($type)); $parser = new TypeParser(TypeParser::MODE_OPEN_API); - $this->assertEquals($expect2 ?? $expect1, $parser->parse($type)); - - $parser = new TypeParser(TypeParser::MODE_OPEN_API | TypeParser::MODE_OPEN_API_31); $this->assertEquals($expect1, $parser->parse($type)); } @@ -616,14 +416,35 @@ public function typeToArrayWithRefCases() 'field2' => [ 'type' => 'array', 'items' => [ - 'type' => 'string', - 'nullable' => true, + 'type' => ['string', 'null'], ], ], ], ], ], ], + [ + Union001Type::class . '?', + [ + 'oneOf' => [ + [ + 'type' => 'string', + ], + [ + 'type' => 'integer', + ], + [ + '$ref' => '#/components/schemas/Product001', + ], + [ + 'type' => 'null', + ], + ], + ], + [ + 'Product001' => $this->product001Schema(), + ] + ], ]; } @@ -641,6 +462,121 @@ public function testTypeToArrayWithRef($type, $expect, $schema) public function inputDataCases() { return [ + // cast integer to bool values + [ + [ + 'query' => ['a' => '1'], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => true], + ], + ], + [ + [ + 'query' => ['a' => '0'], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => false], + ], + ], + [ + [ + 'query' => ['a' => 1], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => true], + ], + ], + [ + [ + 'query' => ['a' => 0], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => false], + ], + ], + [ + [ + 'query' => ['a' => 'true'], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => true], + ], + ], + [ + [ + 'query' => ['a' => 'false'], + ], + [ + [ + 'name' => 'a', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'boolean', + ], + ], + ], + [], + [ + 'query' => ['a' => false], + ], + ], // missing required input field [ [ @@ -919,7 +855,6 @@ class Dict003ItemType extends ProductType class Map003Type extends MapType { - public static function valueType(): string { return Dict003ItemType::class; @@ -929,5 +864,16 @@ public static function example(): array { return []; } +} +class Union001Type extends UnionType +{ + public static function allowedTypes(): array + { + return [ + 'string', + 'integer', + Product001Type::class, + ]; + } }