diff --git a/camel/Extraction/ResponseField.php b/camel/Extraction/ResponseField.php index 8a51da8b..bedf56a8 100644 --- a/camel/Extraction/ResponseField.php +++ b/camel/Extraction/ResponseField.php @@ -19,6 +19,9 @@ class ResponseField extends BaseDTO /** @var string */ public $type; + /** @var boolean */ + public $required; + /** @var mixed */ public $example; diff --git a/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php b/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php index d955132b..395d9d22 100644 --- a/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php +++ b/src/Extracting/Strategies/ResponseFields/GetFromResponseFieldTag.php @@ -16,22 +16,28 @@ class GetFromResponseFieldTag extends GetFieldsFromTagStrategy protected function parseTag(string $tagContent): array { // Format: - // @responseField + // @responseField <"required" (optional)> // Examples: - // @responseField text string The text. + // @responseField text string required The text. // @responseField user_id integer The ID of the user. - preg_match('/(.+?)\s+(.+?)\s+([\s\S]*)/', $tagContent, $content); + preg_match('/(.+?)\s+(.+?)\s+(.+?)\s+([\s\S]*)/', $tagContent, $content); if (empty($content)) { // This means only name and type were supplied [$name, $type] = preg_split('/\s+/', $tagContent); $description = ''; + $required = false; } else { - [$_, $name, $type, $description] = $content; + [$_, $name, $type, $required, $description] = $content; + if($required !== "required"){ + $description = $required . " " . $description; + } + + $required = $required === "required"; $description = trim($description); } $type = static::normalizeTypeName($type); - $data = compact('name', 'type', 'description'); + $data = compact('name', 'type', 'required', 'description'); // Support optional type in annotation // The type can also be a union or nullable type (eg ?string or string|null) diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 52708b2a..b22bf54f 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -400,15 +400,16 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE ], 'example' => $decoded, ], - ], + ], ]; case 'object': $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) { return [$key => $this->generateSchemaForValue($value, $endpoint, $key)]; })->toArray(); + $required = $this->filterRequiredFields($endpoint, array_keys($properties)); - return [ + $data = [ 'application/json' => [ 'schema' => [ 'type' => 'object', @@ -417,6 +418,11 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE ], ], ]; + if ($required) { + $data['application/json']['schema']['required'] = $required; + } + + return $data; } } @@ -593,11 +599,15 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin $subFieldPath = sprintf('%s.%s', $path, $subField); $properties[$subField] = $this->generateSchemaForValue($subValue, $endpoint, $subFieldPath); } + $required = $this->filterRequiredFields($endpoint, array_keys($properties), $path); $schema = [ 'type' => 'object', 'properties' => $this->objectIfEmpty($properties), ]; + if ($required) { + $schema['required'] = $required; + } $this->setDescription($schema, $endpoint, $path); return $schema; @@ -627,6 +637,22 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin } /** + * Given an enpoint and a set of object keys at a path, return the properties that are specified as required. + */ + public function filterRequiredFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array + { + $required = []; + foreach ($properties as $property) { + $responseField = $endpoint->responseFields["$path.$property"] ?? $endpoint->responseFields[$property] ?? null; + if ($responseField && $responseField->required) { + $required[] = $property; + } + } + + return $required; + } + + /* * Set the description for the schema. If the field has a description, it is set in the schema. */ private function setDescription(array &$schema, OutputEndpointData $endpoint, string $path): void diff --git a/tests/Strategies/ResponseFields/GetFromResponseFieldAttributesTest.php b/tests/Strategies/ResponseFields/GetFromResponseFieldAttributesTest.php index 490e8936..f9c44947 100644 --- a/tests/Strategies/ResponseFields/GetFromResponseFieldAttributesTest.php +++ b/tests/Strategies/ResponseFields/GetFromResponseFieldAttributesTest.php @@ -46,11 +46,19 @@ public function can_fetch_from_responsefield_attribute() 'id' => [ 'type' => 'integer', 'description' => 'The id of the newly created user.', + 'required' => true, ], 'other' => [ 'type' => 'string', 'description' => '', + 'required' => true, ], + 'required_attribute' => [ + 'required' => true, + ], + 'not_required_attribute' => [ + 'required' => false, + ] ], $results); } @@ -98,6 +106,8 @@ class ResponseFieldAttributeTestController { #[ResponseField('id', description: 'The id of the newly created user.')] #[ResponseField('other', 'string')] + #[ResponseField('required_attribute', required: true)] + #[ResponseField('not_required_attribute', required: false)] public function methodWithAttributes() { } diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index 019415c8..c8283f37 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -450,7 +450,7 @@ public function adds_responses_correctly_as_responses_on_operation_object() [ 'status' => 201, 'description' => '', - 'content' => '{"this": "shouldn\'t be ignored", "and this": "too", "sub level 0": { "sub level 1 key 1": "sl0_sl1k1", "sub level 1 key 2": [ { "sub level 2 key 1": "sl0_sl1k2_sl2k1", "sub level 2 key 2": { "sub level 3 key 1": "sl0_sl1k2_sl2k2_sl3k1" } } ], "sub level 1 key 3": { "sub level 2 key 1": "sl0_sl1k3_sl2k2", "sub level 2 key 2": { "sub level 3 key 1": "sl0_sl1k3_sl2k2_sl3k1", "sub level 3 key null": null, "sub level 3 key integer": 99 } } } }', + 'content' => '{"this": "shouldn\'t be ignored", "and this": "too", "also this": "too", "sub level 0": { "sub level 1 key 1": "sl0_sl1k1", "sub level 1 key 2": [ { "sub level 2 key 1": "sl0_sl1k2_sl2k1", "sub level 2 key 2": { "sub level 3 key 1": "sl0_sl1k2_sl2k2_sl3k1" } } ], "sub level 1 key 3": { "sub level 2 key 1": "sl0_sl1k3_sl2k2", "sub level 2 key 2": { "sub level 3 key 1": "sl0_sl1k3_sl2k2_sl3k1", "sub level 3 key null": null, "sub level 3 key integer": 99 }, "sub level 2 key 3 required" : "sl0_sl1k3_sl2k3" } } }', ], ], 'responseFields' => [ @@ -459,9 +459,19 @@ public function adds_responses_correctly_as_responses_on_operation_object() 'type' => 'string', 'description' => 'Parameter description, ha!', ], + 'also this' => [ + 'name' => 'also this', + 'type' => 'string', + 'description' => 'This response parameter is required.', + 'required' => true, + ], 'sub level 0.sub level 1 key 3.sub level 2 key 1' => [ - 'description' => 'This is description of nested object', - ] + 'description' => 'This is a description of a nested object', + ], + 'sub level 0.sub level 1 key 3.sub level 2 key 3 required' => [ + 'description' => 'This is a description of a required nested object', + 'required' => true, + ], ], ]); $endpointData2 = $this->createMockEndpointData([ @@ -499,6 +509,11 @@ public function adds_responses_correctly_as_responses_on_operation_object() 'example' => "too", 'type' => 'string', ], + 'also this' => [ + 'description' => 'This response parameter is required.', + 'example' => "too", + 'type' => 'string', + ], 'sub level 0' => [ 'type' => 'object', 'properties' => [ @@ -526,7 +541,7 @@ public function adds_responses_correctly_as_responses_on_operation_object() 'sub level 2 key 1' => [ 'type' => 'string', 'example' => 'sl0_sl1k3_sl2k2', - 'description' => 'This is description of nested object' + 'description' => 'This is a description of a nested object' ], 'sub level 2 key 2' => [ 'type' => 'object', @@ -544,12 +559,24 @@ public function adds_responses_correctly_as_responses_on_operation_object() 'example' => 99 ] ] - ] + ], + 'sub level 2 key 3 required' => [ + 'type' => 'string', + 'example' => 'sl0_sl1k3_sl2k3', + 'description' => 'This is a description of a required nested object' + ], + + ], + 'required' => [ + 'sub level 2 key 3 required' ] ] ] ] ], + 'required' => [ + 'also this' + ] ], ], ],