From 85d322a13ec189cb04f3213e1b1ac83054253fdc Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 23 Mar 2026 14:47:03 -0300 Subject: [PATCH 1/2] This fix required field placement for any array item schemas in OpenAPI spec Within the OpenAPI spec, the required array belongs on the object schema inside items, not on the parent array schema. Also adds required field support for unwrapped array-of-object responses, for example, when Laravel APIs return a collection, it's wrapped in a `data` key like `{ "data": [{"name": "foo"}]}` but you can call `JsonResource::withoutWrapping()` to get an array instead: `[{"name": "foo"}]`. So the bug was that the required field logic existed for the wrapped case, but not the unwrapped branch. --- .../OpenApiSpecGenerators/BaseGenerator.php | 20 +++++++++++++------ tests/Unit/OpenAPISpecWriterTest.php | 20 +++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php index 8f492ab8..9b9ce5ad 100644 --- a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php @@ -230,7 +230,7 @@ public function generateSchemaForResponseValue(mixed $value, OutputEndpointData $path ); if ($required) { - $schema['required'] = $required; + $schema['items']['required'] = $required; } } } @@ -239,7 +239,7 @@ public function generateSchemaForResponseValue(mixed $value, OutputEndpointData } /** - * Given an enpoint and a set of object keys at a path, return the properties that are specified as required. + * Given an endpoint and a set of object keys at a path, return the properties that are specified as required. */ public function filterRequiredResponseFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array { @@ -490,14 +490,22 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)]; })->toArray(); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties)); + + $items = [ + 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])), + 'properties' => $this->objectIfEmpty($properties), + ]; + + if ($required) { + $items['required'] = $required; + } + return [ $contentType => [ 'schema' => [ 'type' => 'array', - 'items' => [ - 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])), - 'properties' => $this->objectIfEmpty($properties), - ], + 'items' => $items, 'example' => $decoded, ], ], diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index e7952d31..26f1e97c 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -1175,11 +1175,11 @@ public function adds_required_fields_on_array_of_objects() 'description' => 'Is primary resource', ], ], - ], - 'required' => [ - 'name', - 'uuid', - 'primary', + 'required' => [ + 'name', + 'uuid', + 'primary', + ], ], ], ], @@ -1483,11 +1483,11 @@ public function adds_enum_values_to_response_properties() 'description' => 'Is primary resource', ], ], - ], - 'required' => [ - 'name', - 'uuid', - 'primary', + 'required' => [ + 'name', + 'uuid', + 'primary', + ], ], ], ], From c5861999ade27e5b18de0a01b26bc2937cf52bd9 Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 23 Mar 2026 15:12:41 -0300 Subject: [PATCH 2/2] tests, fix typo, and rename variable for readability --- .../OpenApiSpecGenerators/BaseGenerator.php | 8 +-- tests/Unit/OpenAPISpecWriterTest.php | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php index 9b9ce5ad..46e16340 100644 --- a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php @@ -485,20 +485,20 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE // Non-empty array if (is_object($decoded[0])) { - // If the first item is an object, we assume it's an array of objects' + // If the first item is an object, we assume it's an array of objects $properties = collect($decoded[0])->mapWithKeys(function ($value, $key) use ($endpoint) { return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)]; })->toArray(); - $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties)); + $requiredFields = $this->filterRequiredResponseFields($endpoint, array_keys($properties)); $items = [ 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])), 'properties' => $this->objectIfEmpty($properties), ]; - if ($required) { - $items['required'] = $required; + if ($requiredFields) { + $items['required'] = $requiredFields; } return [ diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index 26f1e97c..49246592 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -884,6 +884,60 @@ public function adds_responses_correctly_as_array_of_objects() ], $results['paths']['/path1']['get']['responses']); } + /** @test */ + public function adds_required_fields_on_bare_array_of_objects() + { + $endpointData = $this->createMockEndpointData([ + 'httpMethods' => ['GET'], + 'uri' => '/path1', + 'responses' => [ + [ + 'status' => 200, + 'description' => 'Successfully.', + 'content' => '[{"id": 1, "name": "John"}, {"id": 2, "name": "Jane"}]', + ], + ], + 'responseFields' => [ + 'id' => [ + 'name' => 'id', + 'type' => 'integer', + 'description' => 'The ID', + 'required' => true, + ], + 'name' => [ + 'name' => 'name', + 'type' => 'string', + 'description' => 'The name', + 'required' => true, + ], + ], + ]); + + $groups = [$this->createGroup([$endpointData])]; + $results = $this->generate($groups); + + $this->assertArraySubset([ + '200' => [ + 'description' => 'Successfully.', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + ], + 'required' => ['id', 'name'], + ], + ], + ], + ], + ], + ], $results['paths']['/path1']['get']['responses']); + } + /** @test */ public function adds_response_content_type_correctly() {