From 4a2e751591e087427b7736417fcc46b5e70dc8d2 Mon Sep 17 00:00:00 2001 From: Shalvah Date: Mon, 3 Feb 2025 20:40:36 +0100 Subject: [PATCH 1/3] Add Ray in dev --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5fdfb795..5a8393bf 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ "phpstan/phpstan": "^2.1.1", "phpunit/phpunit": "^9.0|^10.0", "symfony/css-selector": "^5.4|^6.0", - "symfony/dom-crawler": "^5.4|^6.0" + "symfony/dom-crawler": "^5.4|^6.0", + "spatie/ray": "^1.41" }, "autoload": { "psr-4": { From 99b71ebf058e679c3020779583be4de6b576ba3b Mon Sep 17 00:00:00 2001 From: Shalvah Date: Mon, 3 Feb 2025 20:50:33 +0100 Subject: [PATCH 2/3] Fix missing required list in OpenAPI for nested body params --- src/Writing/OpenAPISpecWriter.php | 20 +++-- tests/Unit/OpenAPISpecWriterTest.php | 119 +++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 31dc2ea3..48081ddf 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -197,7 +197,7 @@ protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): return $parameters; } - protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint) + protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint): array|\stdClass { $body = []; @@ -405,9 +405,9 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE case 'object': $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) { - return [$key => $this->generateSchemaForValue($value, $endpoint, $key)]; + return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)]; })->toArray(); - $required = $this->filterRequiredFields($endpoint, array_keys($properties)); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties)); $data = [ 'application/json' => [ @@ -551,6 +551,7 @@ public function generateFieldData($field): array 'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) { return [$subfieldName => $this->generateFieldData($subfield)]; })->all()), + 'required' => collect($field->__fields)->filter(fn ($f) => $f['required'])->keys()->toArray(), ]; } else { $schema = [ @@ -589,17 +590,18 @@ protected function objectIfEmpty(array $field): array|\stdClass * object)}, and possibly a description for each property. The $endpoint and $path are used for looking up response * field descriptions. */ - public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoint, string $path): array + public function generateSchemaForResponseValue(mixed $value, OutputEndpointData $endpoint, string $path): array { + // If $value is a JSON object if ($value instanceof \stdClass) { $value = (array)$value; $properties = []; // Recurse into the object foreach ($value as $subField => $subValue) { $subFieldPath = sprintf('%s.%s', $path, $subField); - $properties[$subField] = $this->generateSchemaForValue($subValue, $endpoint, $subFieldPath); + $properties[$subField] = $this->generateSchemaForResponseValue($subValue, $endpoint, $subFieldPath); } - $required = $this->filterRequiredFields($endpoint, array_keys($properties), $path); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties), $path); $schema = [ 'type' => 'object', @@ -633,10 +635,10 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin if ($typeOfEachItem === 'object') { $schema['items']['properties'] = collect($sample)->mapWithKeys(function ($v, $k) use ($endpoint, $path) { - return [$k => $this->generateSchemaForValue($v, $endpoint, "$path.$k")]; + return [$k => $this->generateSchemaForResponseValue($v, $endpoint, "$path.$k")]; })->toArray(); - $required = $this->filterRequiredFields($endpoint, array_keys($schema['items']['properties']), $path); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($schema['items']['properties']), $path); if ($required) { $schema['required'] = $required; } @@ -649,7 +651,7 @@ 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 + public function filterRequiredResponseFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array { $required = []; foreach ($properties as $property) { diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index 98135c86..4b868c8d 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -861,17 +861,39 @@ public function adds_more_than_two_answers_correctly_using_oneOf() public function adds_enum_values_to_response_properties() { $endpointData = $this->createMockEndpointData([ - 'uri' => '/path', - 'httpMethods' => ['POST'], + 'httpMethods' => ['GEt'], + 'uri' => '/path1', 'responses' => [ [ 'status' => 200, - 'description' => 'This one', - 'content' => '{"status": "one"}', + 'description' => 'List of entities', + 'content' => '{"data":[{"name":"Resource name","uuid":"UUID","primary":true}]}', ], ], 'responseFields' => [ - 'status' => ['enumValues' => ['one', 'two', 'three']], + 'data' => [ + 'name' => 'data', + 'type' => 'array', + 'description' => 'Data wrapper', + ], + 'data.name' => [ + 'name' => 'Resource name', + 'type' => 'string', + 'description' => 'Name of the resource object', + 'required' => true, + ], + 'data.uuid' => [ + 'name' => 'Resource UUID', + 'type' => 'string', + 'description' => 'Unique ID for the resource', + 'required' => true, + ], + 'data.primary' => [ + 'name' => 'Is primary', + 'type' => 'bool', + 'description' => 'Is primary resource', + 'required' => true, + ], ], ]); @@ -881,19 +903,100 @@ public function adds_enum_values_to_response_properties() $this->assertArraySubset([ '200' => [ + 'description' => 'List of entities', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'data' => [ + 'type' => 'array', + 'description' => 'Data wrapper', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'Name of the resource object', + ], + 'uuid' => [ + 'type' => 'string', + 'description' => 'Unique ID for the resource', + ], + 'primary' => [ + 'type' => 'boolean', + 'description' => 'Is primary resource', + ], + ], + ], + 'required' => [ + 'name', + 'uuid', + 'primary', + ] + ], + ], + ], + ], + ], + ], + ], $results['paths']['/path1']['get']['responses']); + } + + /** @test */ + public function lists_required_properties_in_request_body() + { + $endpointData = $this->createMockEndpointData([ + 'uri' => '/path', + 'httpMethods' => ['POST'], + 'bodyParameters' => [ + 'my_field' => [ + 'name' => 'my_field', + 'description' => '', + 'required' => true, + 'example' => 'abc', + 'type' => 'string', + 'nullable' => false, + ], + 'other_field.nested_field' => [ + 'name' => 'nested_field', + 'description' => '', + 'required' => true, + 'example' => 'abc', + 'type' => 'string', + 'nullable' => false, + ], + ], + ]); + $groups = [$this->createGroup([$endpointData])]; + $results = $this->generate($groups); + + $this->assertArraySubset([ + 'requestBody' => [ 'content' => [ 'application/json' => [ 'schema' => [ + 'type' => 'object', 'properties' => [ - 'status' => [ - 'enum' => ['one', 'two', 'three'], + 'my_field' => [ + 'type' => 'string', + ], + 'other_field' => [ + 'type' => 'object', + 'properties' => [ + 'nested_field' => [ + 'type' => 'string', + ], + ], + 'required' => ['nested_field'], ], ], + 'required' => ['my_field'] ], ], ], ], - ], $results['paths']['/path']['post']['responses']); + ], $results['paths']['/path']['post']); } protected function createMockEndpointData(array $custom = []): OutputEndpointData From 1ad707f229185bb2413ea9ed2fb01747abe78ff6 Mon Sep 17 00:00:00 2001 From: Shalvah Date: Mon, 3 Feb 2025 21:22:40 +0100 Subject: [PATCH 3/3] 4.40.0 --- CHANGELOG.md | 7 ++++++- src/Commands/GenerateDocumentation.php | 1 + src/Scribe.php | 2 +- src/Writing/OpenAPISpecWriter.php | 7 ++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e75081c..4ca4f9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed +# 4.40.0 (3 February 2024) +## Added +- Correctly list required fields for nested objects in OpenAPI spec (request bodies) [99b71ebf0](https://github.com/knuckleswtf/scribe/commit/99b71ebf058e679c3020779583be4de6b576ba3b) +- Add support for defining Groups and Subgroups as enums [#932](https://github.com/knuckleswtf/scribe/pull/932) + # 4.39.0 (31 December 2024) ## Added -- Correctly list required fields for nested objects in OpenAPI spec [#905](https://github.com/knuckleswtf/scribe/pull/905) +- Correctly list required fields for nested objects in OpenAPI spec (responses) [#905](https://github.com/knuckleswtf/scribe/pull/905) - Cursor pagination support in API responses (`cursorPaginate`/`paginate=cursor`) [#917](https://github.com/knuckleswtf/scribe/pull/917) ## Fixed diff --git a/src/Commands/GenerateDocumentation.php b/src/Commands/GenerateDocumentation.php index ab48547b..2d593840 100644 --- a/src/Commands/GenerateDocumentation.php +++ b/src/Commands/GenerateDocumentation.php @@ -63,6 +63,7 @@ public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFact $this->writeExampleCustomEndpoint(); } + /** @var Writer $writer */ $writer = app(Writer::class, ['config' => $this->docConfig, 'paths' => $this->paths]); $writer->writeDocs($groupedEndpoints); diff --git a/src/Scribe.php b/src/Scribe.php index 80259669..6f7f51d6 100644 --- a/src/Scribe.php +++ b/src/Scribe.php @@ -9,7 +9,7 @@ class Scribe { - public const VERSION = '4.39.0'; + public const VERSION = '4.40.0'; /** * Specify a callback that will be executed just before a response call is made diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 48081ddf..b4b8a70f 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -543,7 +543,7 @@ public function generateFieldData($field): array return $fieldData; } else if ($field->type === 'object') { - return [ + $data = [ 'type' => 'object', 'description' => $field->description ?: '', 'example' => $field->example, @@ -553,6 +553,11 @@ public function generateFieldData($field): array })->all()), 'required' => collect($field->__fields)->filter(fn ($f) => $f['required'])->keys()->toArray(), ]; + // The spec doesn't allow for an empty `required` array. Must have something there. + if (empty($data['required'])) { + unset($data['required']); + } + return $data; } else { $schema = [ 'type' => static::normalizeTypeName($field->type),