From 4d4a44e48afd4f1f236c9e75cf3cbe8b6b515eb3 Mon Sep 17 00:00:00 2001 From: Mertcan Dinler Date: Thu, 3 Oct 2024 14:56:47 +0300 Subject: [PATCH] added feature/nullable (#834) Co-authored-by: Shalvah --- camel/Extraction/Parameter.php | 1 + src/Attributes/GenericParam.php | 2 + src/Attributes/ResponseField.php | 3 +- src/Extracting/ParsesValidationRules.php | 9 ++++ .../GetParamsFromAttributeStrategy.php | 4 ++ src/Writing/OpenAPISpecWriter.php | 8 +++ tests/Fixtures/openapi.yaml | 14 +++-- tests/GenerateDocumentation/OutputTest.php | 1 + .../GetFromBodyParamAttributeTest.php | 34 ++++++++++++- tests/Unit/OpenAPISpecWriterTest.php | 14 +++++ tests/Unit/ValidationRuleParsingTest.php | 51 +++++++++++++++++++ 11 files changed, 136 insertions(+), 5 deletions(-) diff --git a/camel/Extraction/Parameter.php b/camel/Extraction/Parameter.php index 0d78f6bb..7e1f275a 100644 --- a/camel/Extraction/Parameter.php +++ b/camel/Extraction/Parameter.php @@ -14,6 +14,7 @@ class Parameter extends BaseDTO public string $type = 'string'; public array $enumValues = []; public bool $exampleWasSpecified = false; + public bool $nullable = false; public function __construct(array $parameters = []) { diff --git a/src/Attributes/GenericParam.php b/src/Attributes/GenericParam.php index 55e13703..21dd83a1 100644 --- a/src/Attributes/GenericParam.php +++ b/src/Attributes/GenericParam.php @@ -14,6 +14,7 @@ public function __construct( public ?bool $required = true, public mixed $example = null, /* Pass 'No-example' to omit the example */ public mixed $enum = null, // Can pass a list of values, or a native PHP enum + public ?bool $nullable = false, ) { } @@ -26,6 +27,7 @@ public function toArray() "required" => $this->required, "example" => $this->example, "enumValues" => $this->getEnumValues(), + 'nullable' => $this->nullable, ]; } diff --git a/src/Attributes/ResponseField.php b/src/Attributes/ResponseField.php index a11c790e..dd7fa643 100644 --- a/src/Attributes/ResponseField.php +++ b/src/Attributes/ResponseField.php @@ -15,7 +15,8 @@ public function __construct( public ?string $description = '', public ?bool $required = true, public mixed $example = null, /* Pass 'No-example' to omit the example */ - public mixed $enum = null, // Can pass a list of values, or a native PHP enum + public mixed $enum = null, // Can pass a list of values, or a native PHP enum, + public ?bool $nullable = false, ) { } } diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index 0dd9038c..58a2c9ad 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -50,6 +50,7 @@ public function getParametersFromValidationRules(array $validationRules, array $ 'type' => null, 'example' => self::$MISSING_VALUE, 'description' => $description, + 'nullable' => false, ]; $dependentRules[$parameter] = []; @@ -69,6 +70,11 @@ public function getParametersFromValidationRules(array $validationRules, array $ } $parameterData['name'] = $parameter; + + if ($parameterData['required'] === true){ + $parameterData['nullable'] = false; + } + $parameters[$parameter] = $parameterData; } catch (Throwable $e) { if ($e instanceof ScribeException) { @@ -531,6 +537,9 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly case 'different': $parameterData['description'] .= " The value and {$arguments[0]} must be different."; break; + case 'nullable': + $parameterData['nullable'] = true; + break; case 'exists': $parameterData['description'] .= " The {$arguments[1]} of an existing record in the {$arguments[0]} table."; break; diff --git a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php index d6e1264d..a5924d83 100644 --- a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php +++ b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php @@ -38,6 +38,10 @@ protected function normalizeParameterData(array $data): array $data['example'] = null; } + if ($data['required']){ + $data['nullable'] = false; + } + $data['description'] = trim($data['description'] ?? ''); return $data; } diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 11a6a84d..52708b2a 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -488,6 +488,7 @@ public function generateFieldData($field): array 'type' => 'string', 'format' => 'binary', 'description' => $field->description ?: '', + 'nullable' => $field->nullable, ]; } else if (Utils::isArrayType($field->type)) { $baseType = Utils::getBaseTypeFromArrayType($field->type); @@ -500,6 +501,10 @@ public function generateFieldData($field): array $baseItem['enum'] = $field->enumValues; } + if ($field->nullable) { + $baseItem['nullable'] = true; + } + $fieldData = [ 'type' => 'array', 'description' => $field->description ?: '', @@ -509,6 +514,7 @@ public function generateFieldData($field): array 'name' => '', 'type' => $baseType, 'example' => ($field->example ?: [null])[0], + 'nullable' => $field->nullable, ]) : $baseItem, ]; @@ -535,6 +541,7 @@ public function generateFieldData($field): array 'type' => 'object', 'description' => $field->description ?: '', 'example' => $field->example, + 'nullable'=> $field->nullable, 'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) { return [$subfieldName => $this->generateFieldData($subfield)]; })->all()), @@ -544,6 +551,7 @@ public function generateFieldData($field): array 'type' => static::normalizeTypeName($field->type), 'description' => $field->description ?: '', 'example' => $field->example, + 'nullable' => $field->nullable, ]; if (!empty($field->enumValues)) { $schema['enum'] = $field->enumValues; diff --git a/tests/Fixtures/openapi.yaml b/tests/Fixtures/openapi.yaml index 9ea3a135..97e8b4ca 100644 --- a/tests/Fixtures/openapi.yaml +++ b/tests/Fixtures/openapi.yaml @@ -34,10 +34,12 @@ paths: type: string description: 'Name of image.' example: cat.jpg + nullable: false image: type: string format: binary description: 'The image.' + nullable: false required: - name - image @@ -95,6 +97,7 @@ paths: type: string description: 'The id of the location.' example: consequatur + nullable: false - in: query name: user_id @@ -105,6 +108,7 @@ paths: type: string description: 'The id of the user.' example: me + nullable: false - in: query name: page @@ -115,6 +119,7 @@ paths: type: string description: 'The page number.' example: '4' + nullable: false - in: query name: filters @@ -125,6 +130,7 @@ paths: type: string description: 'The filters.' example: consequatur + nullable: false - in: query name: url_encoded @@ -135,6 +141,7 @@ paths: type: string description: 'Used for testing that URL parameters will be URL-encoded where needed.' example: '+ []&=' + nullable: false - in: header name: Custom-Header @@ -192,6 +199,7 @@ paths: type: string description: '' example: consequatur + nullable: false - in: header name: Custom-Header @@ -293,9 +301,9 @@ paths: items: type: object properties: - first_name: { type: string, description: 'The first name of the user.', example: John } - last_name: { type: string, description: 'The last name of the user.', example: Doe } - contacts: { type: array, description: 'Contact info', example: [ [ ] ], items: { type: object, properties: { first_name: { type: string, description: 'The first name of the contact.', example: Janelle }, last_name: { type: string, description: 'The last name of the contact.', example: Monáe } }, required: [ first_name, last_name ] } } + first_name: { type: string, description: 'The first name of the user.', example: John, nullable: false } + last_name: { type: string, description: 'The last name of the user.', example: Doe, nullable: false} + contacts: { type: array, description: 'Contact info', example: [ [ ] ], items: { type: object, properties: { first_name: { type: string, description: 'The first name of the contact.', example: Janelle, nullable: false }, last_name: { type: string, description: 'The last name of the contact.', example: Monáe, nullable: false } }, required: [ first_name, last_name ] } } roles: { type: array, description: 'The name of the role.', example: [ Admin ], items: { type: string } } required: - first_name diff --git a/tests/GenerateDocumentation/OutputTest.php b/tests/GenerateDocumentation/OutputTest.php index 763d789e..448bf8a5 100644 --- a/tests/GenerateDocumentation/OutputTest.php +++ b/tests/GenerateDocumentation/OutputTest.php @@ -475,6 +475,7 @@ public function will_not_overwrite_manually_modified_content_unless_force_flag_i 'enumValues' => [], 'custom' => [], 'exampleWasSpecified' => false, + 'nullable' => false, ]; $group['endpoints'][0]['urlParameters']['a_param'] = $extraParam; file_put_contents($firstGroupFilePath, Yaml::dump( diff --git a/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php b/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php index 93c90be0..cc3470f0 100644 --- a/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php +++ b/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php @@ -32,85 +32,115 @@ public function can_fetch_from_bodyparam_attribute() 'required' => true, 'description' => 'The id of the user.', 'example' => 9, + 'nullable' => false, ], 'room_id' => [ 'type' => 'string', 'required' => false, 'description' => 'The id of the room.', + 'nullable' => false, ], 'forever' => [ 'type' => 'boolean', 'required' => false, 'description' => 'Whether to ban the user forever.', 'example' => false, + 'nullable' => false, ], 'another_one' => [ 'type' => 'number', 'required' => false, 'description' => 'Just need something here.', + 'nullable' => false, ], 'yet_another_param' => [ 'type' => 'object', 'required' => true, 'description' => 'Some object params.', + 'nullable' => false, ], 'yet_another_param.name' => [ 'type' => 'string', 'description' => '', 'required' => true, + 'nullable' => false, ], 'even_more_param' => [ 'type' => 'number[]', 'description' => 'A list of numbers', 'required' => false, + 'nullable' => false, ], 'book' => [ 'type' => 'object', 'description' => 'Book information', 'required' => false, + 'nullable' => false, ], 'book.name' => [ 'type' => 'string', 'description' => '', 'required' => true, + 'nullable' => false, ], 'book.author_id' => [ 'type' => 'integer', 'description' => '', 'required' => true, + 'nullable' => false, ], 'book.pages_count' => [ 'type' => 'integer', 'description' => '', 'required' => true, + 'nullable' => false, ], 'ids' => [ 'type' => 'integer[]', 'description' => '', 'required' => true, + 'nullable' => false, ], 'state' => [ 'type' => 'string', 'description' => '', 'required' => true, - 'enumValues' => ["active", "pending"] + 'enumValues' => ["active", "pending"], + 'nullable' => false, ], 'users' => [ 'type' => 'object[]', 'description' => 'Users\' details', 'required' => false, + 'nullable' => false, ], 'users[].first_name' => [ 'type' => 'string', 'description' => 'The first name of the user.', 'required' => false, 'example' => 'John', + 'nullable' => false, ], 'users[].last_name' => [ 'type' => 'string', 'description' => 'The last name of the user.', 'required' => false, 'example' => 'Doe', + 'nullable' => false, + ], + 'note' => [ + 'type' => 'string', + 'description' => '', + 'required' => false, + 'example' => 'This is a note.', + 'nullable' => true, + ], + 'required_note' => [ + 'type' => 'string', + 'description' => '', + 'required' => true, + 'example' => 'This is a note.', + 'nullable' => false, ], ], $results); } @@ -226,6 +256,8 @@ class BodyParamAttributeTestController #[BodyParam("users", "object[]", "Users' details", required: false)] #[BodyParam("users[].first_name", "string", "The first name of the user.", example: "John", required: false)] #[BodyParam("users[].last_name", "string", "The last name of the user.", example: "Doe", required: false)] + #[BodyParam("note", example: "This is a note.", required: false, nullable: true)] + #[BodyParam("required_note", example: "This is a note.", required: true, nullable: true)] public function methodWithAttributes() { diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index d906dc6d..019415c8 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -210,6 +210,7 @@ public function adds_query_parameters_correctly_as_parameters_on_operation_objec 'example' => 'hahoho', 'type' => 'string', 'name' => 'param', + 'nullable' => false ], ], ]); @@ -231,6 +232,7 @@ public function adds_query_parameters_correctly_as_parameters_on_operation_objec 'type' => 'string', 'description' => 'A query param', 'example' => 'hahoho', + 'nullable' => false ], ], $results['paths']['/path1']['get']['parameters'][0]); } @@ -248,6 +250,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'required' => false, 'example' => 'hahoho', 'type' => 'string', + 'nullable' => false, ], 'integerParam' => [ 'name' => 'integerParam', @@ -255,6 +258,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'required' => true, 'example' => 99, 'type' => 'integer', + 'nullable' => false, ], 'booleanParam' => [ 'name' => 'booleanParam', @@ -262,6 +266,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'required' => true, 'example' => false, 'type' => 'boolean', + 'nullable' => false, ], 'objectParam' => [ 'name' => 'objectParam', @@ -269,6 +274,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'required' => false, 'example' => [], 'type' => 'object', + 'nullable' => false, ], 'objectParam.field' => [ 'name' => 'objectParam.field', @@ -276,6 +282,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'required' => false, 'example' => 119.0, 'type' => 'number', + 'nullable' => false, ], ], ]); @@ -338,26 +345,31 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'description' => 'String param', 'example' => 'hahoho', 'type' => 'string', + 'nullable' => false, ], 'booleanParam' => [ 'description' => 'Boolean param', 'example' => false, 'type' => 'boolean', + 'nullable' => false, ], 'integerParam' => [ 'description' => 'Integer param', 'example' => 99, 'type' => 'integer', + 'nullable' => false, ], 'objectParam' => [ 'description' => 'Object param', 'example' => [], 'type' => 'object', + 'nullable' => false, 'properties' => [ 'field' => [ 'description' => 'Object param field', 'example' => 119.0, 'type' => 'number', + 'nullable' => false, ], ], ], @@ -381,6 +393,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'description' => 'File param', 'type' => 'string', 'format' => 'binary', + 'nullable' => false, ], 'numberArrayParam' => [ 'description' => 'Number array param', @@ -410,6 +423,7 @@ public function adds_body_parameters_correctly_as_requestBody_on_operation_objec 'type' => 'string', 'description' => '', 'example' => "hi", + 'nullable' => false, ], ], ], diff --git a/tests/Unit/ValidationRuleParsingTest.php b/tests/Unit/ValidationRuleParsingTest.php index 5a877ac9..0bfda300 100644 --- a/tests/Unit/ValidationRuleParsingTest.php +++ b/tests/Unit/ValidationRuleParsingTest.php @@ -639,6 +639,57 @@ public function can_translate_validation_rules_with_types_with_translator_withou $this->assertEquals('successfully translated by concatenated string.', $results['nested']['description']); } + + /** @test */ + public function can_valid_parse_nullable_rules() + { + $ruleset = [ + 'nullable_param' => 'nullable|string', + ]; + + $results = $this->strategy->parse($ruleset); + + $this->assertEquals(true, $results['nullable_param']['nullable']); + + $ruleset = [ + 'nullable_param' => 'string', + ]; + + $results = $this->strategy->parse($ruleset); + + $this->assertEquals(false, $results['nullable_param']['nullable']); + + $ruleset = [ + 'required_param' => 'required|nullable|string', + ]; + + $results = $this->strategy->parse($ruleset); + + $this->assertEquals(false, $results['required_param']['nullable']); + + + $ruleset = [ + 'array_param' => 'array', + 'array_param.*.field' => 'nullable|string', + ]; + + $results = $this->strategy->parse($ruleset); + + $this->assertEquals(false, $results['array_param']['nullable']); + $this->assertEquals(true, $results['array_param[].field']['nullable']); + + $ruleset = [ + 'object' => 'array', + 'object.field1' => 'string', + 'object.field2' => 'nullable|string', + ]; + + $results = $this->strategy->parse($ruleset); + + $this->assertEquals(false, $results['object']['nullable']); + $this->assertEquals(false, $results['object.field1']['nullable']); + $this->assertEquals(true, $results['object.field2']['nullable']); + } } class DummyValidationRule implements \Illuminate\Contracts\Validation\Rule