diff --git a/camel/Output/OutputEndpointData.php b/camel/Output/OutputEndpointData.php index 93766bba..de4207b9 100644 --- a/camel/Output/OutputEndpointData.php +++ b/camel/Output/OutputEndpointData.php @@ -4,9 +4,11 @@ use Illuminate\Http\UploadedFile; use Illuminate\Routing\Route; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Knuckles\Camel\BaseDTO; use Knuckles\Camel\Extraction\Metadata; +use Knuckles\Camel\Extraction\Parameter; use Knuckles\Camel\Extraction\ResponseCollection; use Knuckles\Camel\Extraction\ResponseField; use Knuckles\Scribe\Extracting\Extractor; @@ -105,8 +107,8 @@ public function __construct(array $parameters = []) $this->cleanBodyParameters = Extractor::cleanParams($this->bodyParameters); $this->cleanQueryParameters = Extractor::cleanParams($this->queryParameters); $this->cleanUrlParameters = Extractor::cleanParams($this->urlParameters); - $this->nestedBodyParameters = Extractor::nestArrayAndObjectFields($this->bodyParameters, $this->cleanBodyParameters); - $this->nestedResponseFields = Extractor::nestArrayAndObjectFields($this->responseFields); + $this->nestedBodyParameters = self::nestArrayAndObjectFields($this->bodyParameters, $this->cleanBodyParameters); + $this->nestedResponseFields = self::nestArrayAndObjectFields($this->responseFields); $this->boundUri = u::getUrlWithBoundParameters($this->uri, $this->cleanUrlParameters); @@ -142,6 +144,133 @@ public static function fromExtractedEndpointArray(array $endpoint): OutputEndpoi return new self($endpoint); } + /** + * Transform body parameters such that object fields have a `fields` property containing a list of all subfields + * Subfields will be removed from the main parameter map + * For instance, if $parameters is [ + * 'dad' => new Parameter(...), + * 'dad.age' => new Parameter(...), + * 'dad.cars[]' => new Parameter(...), + * 'dad.cars[].model' => new Parameter(...), + * 'dad.cars[].price' => new Parameter(...), + * ], + * normalise this into [ + * 'dad' => [ + * ..., + * '__fields' => [ + * 'dad.age' => [...], + * 'dad.cars' => [ + * ..., + * '__fields' => [ + * 'model' => [...], + * 'price' => [...], + * ], + * ], + * ], + * ]] + * + * @param array $parameters + * + * @return array + */ + public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array + { + // First, we'll make sure all object fields have parent fields properly set + $normalisedParameters = []; + foreach ($parameters as $name => $parameter) { + if (Str::contains($name, '.')) { + // If the user didn't add a parent field, we'll helpfully add it for them + $ancestors = []; + + $parts = explode('.', $name); + $fieldName = array_pop($parts); + $parentName = rtrim(join('.', $parts), '[]'); + + // When the body is an array, param names will be "[].paramname", + // so $parentName is empty. Let's fix that. + if (empty($parentName)) { + $parentName = '[]'; + } + + while ($parentName) { + if (!empty($normalisedParameters[$parentName])) { + break; + } + + $details = [ + "name" => $parentName, + "type" => $parentName === '[]' ? "object[]" : "object", + "description" => "", + "required" => false, + ]; + + if ($parameter instanceof ResponseField) { + $ancestors[] = [$parentName, new ResponseField($details)]; + } else { + $lastParentExample = $details["example"] = + [$fieldName => $lastParentExample ?? $parameter->example]; + $ancestors[] = [$parentName, new Parameter($details)]; + } + + $fieldName = array_pop($parts); + $parentName = rtrim(join('.', $parts), '[]'); + } + + // We add ancestors in reverse so we can iterate over parents first in the next section + foreach (array_reverse($ancestors) as [$ancestorName, $ancestor]) { + $normalisedParameters[$ancestorName] = $ancestor; + } + } + + $normalisedParameters[$name] = $parameter; + unset($lastParentExample); + } + + $finalParameters = []; + foreach ($normalisedParameters as $name => $parameter) { + $parameter = $parameter->toArray(); + if (Str::contains($name, '.')) { // An object field + // Get the various pieces of the name + $parts = explode('.', $name); + $fieldName = array_pop($parts); + $baseName = join('.__fields.', $parts); + + // For subfields, the type is indicated in the source object + // eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more + // The difference would be in the parent field's `type` property (object[] vs object) + // So we can get rid of all [] to get the parent name + $dotPathToParent = str_replace('[]', '', $baseName); + // When the body is an array, param names will be "[].paramname", + // so $parts is ['[]'] + if ($parts[0] == '[]') { + $dotPathToParent = '[]' . $dotPathToParent; + } + + $dotPath = $dotPathToParent . '.__fields.' . $fieldName; + Arr::set($finalParameters, $dotPath, $parameter); + } else { // A regular field, not a subfield of anything + // Note: we're assuming any subfields of this field are listed *after* it, + // and will set __fields correctly when we iterate over them + // Hence why we create a new "normalisedParameters" array above and push the parent to that first + $parameter['__fields'] = []; + $finalParameters[$name] = $parameter; + } + + } + + // Finally, if the body is an array, remove any other items. + if (isset($finalParameters['[]'])) { + $finalParameters = ["[]" => $finalParameters['[]']]; + // At this point, the examples are likely [[], []], + // but have been correctly set in clean parameters, so let's update them + if ($finalParameters["[]"]["example"][0] == [] && !empty($cleanParameters)) { + $finalParameters["[]"]["example"] = $cleanParameters; + } + } + + return $finalParameters; + } + public function endpointId(): string { return $this->httpMethods[0] . str_replace(['/', '?', '{', '}', ':', '\\', '+', '|', '.'], '-', $this->uri); diff --git a/src/Extracting/Extractor.php b/src/Extracting/Extractor.php index 9eef2cf1..ad38009f 100644 --- a/src/Extracting/Extractor.php +++ b/src/Extracting/Extractor.php @@ -14,8 +14,8 @@ use Knuckles\Camel\Extraction\ResponseCollection; use Knuckles\Camel\Extraction\ResponseField; use Knuckles\Camel\Output\OutputEndpointData; -use Knuckles\Scribe\Extracting\Strategies\Strategy; use Knuckles\Scribe\Tools\DocumentationConfig; +use Knuckles\Scribe\Tools\RoutePatternMatcher; class Extractor { @@ -199,7 +199,9 @@ protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, arra * @param callable $handler Function to run after each strategy returns its results (an array). * */ - protected function iterateThroughStrategies(string $stage, ExtractedEndpointData $endpointData, array $rulesToApply, callable $handler): void + protected function iterateThroughStrategies( + string $stage, ExtractedEndpointData $endpointData, array $rulesToApply, callable $handler + ): void { $strategies = $this->config->get("strategies.$stage", []); @@ -418,133 +420,6 @@ protected static function convertStringValueToUploadedFileInstance(string $fileP return new File($fileName, fopen($filePath, 'r')); } - /** - * Transform body parameters such that object fields have a `fields` property containing a list of all subfields - * Subfields will be removed from the main parameter map - * For instance, if $parameters is [ - * 'dad' => new Parameter(...), - * 'dad.age' => new Parameter(...), - * 'dad.cars[]' => new Parameter(...), - * 'dad.cars[].model' => new Parameter(...), - * 'dad.cars[].price' => new Parameter(...), - * ], - * normalise this into [ - * 'dad' => [ - * ..., - * '__fields' => [ - * 'dad.age' => [...], - * 'dad.cars' => [ - * ..., - * '__fields' => [ - * 'model' => [...], - * 'price' => [...], - * ], - * ], - * ], - * ]] - * - * @param array $parameters - * - * @return array - */ - public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array - { - // First, we'll make sure all object fields have parent fields properly set - $normalisedParameters = []; - foreach ($parameters as $name => $parameter) { - if (Str::contains($name, '.')) { - // If the user didn't add a parent field, we'll helpfully add it for them - $ancestors = []; - - $parts = explode('.', $name); - $fieldName = array_pop($parts); - $parentName = rtrim(join('.', $parts), '[]'); - - // When the body is an array, param names will be "[].paramname", - // so $parentName is empty. Let's fix that. - if (empty($parentName)) { - $parentName = '[]'; - } - - while ($parentName) { - if (!empty($normalisedParameters[$parentName])) { - break; - } - - $details = [ - "name" => $parentName, - "type" => $parentName === '[]' ? "object[]" : "object", - "description" => "", - "required" => false, - ]; - - if ($parameter instanceof ResponseField) { - $ancestors[] = [$parentName, new ResponseField($details)]; - } else { - $lastParentExample = $details["example"] = - [$fieldName => $lastParentExample ?? $parameter->example]; - $ancestors[] = [$parentName, new Parameter($details)]; - } - - $fieldName = array_pop($parts); - $parentName = rtrim(join('.', $parts), '[]'); - } - - // We add ancestors in reverse so we can iterate over parents first in the next section - foreach (array_reverse($ancestors) as [$ancestorName, $ancestor]) { - $normalisedParameters[$ancestorName] = $ancestor; - } - } - - $normalisedParameters[$name] = $parameter; - unset($lastParentExample); - } - - $finalParameters = []; - foreach ($normalisedParameters as $name => $parameter) { - $parameter = $parameter->toArray(); - if (Str::contains($name, '.')) { // An object field - // Get the various pieces of the name - $parts = explode('.', $name); - $fieldName = array_pop($parts); - $baseName = join('.__fields.', $parts); - - // For subfields, the type is indicated in the source object - // eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more - // The difference would be in the parent field's `type` property (object[] vs object) - // So we can get rid of all [] to get the parent name - $dotPathToParent = str_replace('[]', '', $baseName); - // When the body is an array, param names will be "[].paramname", - // so $parts is ['[]'] - if ($parts[0] == '[]') { - $dotPathToParent = '[]' . $dotPathToParent; - } - - $dotPath = $dotPathToParent . '.__fields.' . $fieldName; - Arr::set($finalParameters, $dotPath, $parameter); - } else { // A regular field, not a subfield of anything - // Note: we're assuming any subfields of this field are listed *after* it, - // and will set __fields correctly when we iterate over them - // Hence why we create a new "normalisedParameters" array above and push the parent to that first - $parameter['__fields'] = []; - $finalParameters[$name] = $parameter; - } - - } - - // Finally, if the body is an array, remove any other items. - if (isset($finalParameters['[]'])) { - $finalParameters = ["[]" => $finalParameters['[]']]; - // At this point, the examples are likely [[], []], - // but have been correctly set in clean parameters, so let's update them - if ($finalParameters["[]"]["example"][0] == [] && !empty($cleanParameters)) { - $finalParameters["[]"]["example"] = $cleanParameters; - } - } - - return $finalParameters; - } - protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void { $overrides = $inheritedDocsOverrides[$stage] ?? []; diff --git a/tests/BaseLaravelTest.php b/tests/BaseLaravelTest.php index 19f2f00c..256c65a1 100644 --- a/tests/BaseLaravelTest.php +++ b/tests/BaseLaravelTest.php @@ -2,12 +2,14 @@ namespace Knuckles\Scribe\Tests; +use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Knuckles\Scribe\ScribeServiceProvider; use Orchestra\Testbench\TestCase; class BaseLaravelTest extends TestCase { use TestHelpers; + use ArraySubsetAsserts; protected function getEnvironmentSetUp($app) { @@ -41,4 +43,12 @@ protected function getPackageProviders($app) } return $providers; } + + protected function setConfig($configValues): void + { + foreach ($configValues as $key => $value) { + config(["scribe.$key" => $value]); + config(["scribe_new.$key" => $value]); + } + } } diff --git a/tests/BaseUnitTest.php b/tests/BaseUnitTest.php new file mode 100644 index 00000000..7c8c446a --- /dev/null +++ b/tests/BaseUnitTest.php @@ -0,0 +1,11 @@ +assertArraySubset(["type" => "string"], $inherited->queryParameters["queryThing"]->toArray()); } - /** @test */ - public function can_nest_array_and_object_parameters_correctly() - { - $parameters = [ - "dad" => Parameter::create([ - "name" => 'dad', - ]), - "dad.age" => ResponseField::create([ - "name" => 'dad.age', - ]), - "dad.cars[]" => Parameter::create([ - "name" => 'dad.cars[]', - ]), - "dad.cars[].model" => Parameter::create([ - "name" => 'dad.cars[].model', - ]), - "dad.cars[].price" => ResponseField::create([ - "name" => 'dad.cars[].price', - ]), - ]; - $cleanParameters = []; - - $nested = Extractor::nestArrayAndObjectFields($parameters, $cleanParameters); - - $this->assertEquals(["dad"], array_keys($nested)); - $this->assertArraySubset([ - "dad" => [ - "name" => "dad", - "__fields" => [ - "age" => [ - "name" => "dad.age", - ], - "cars" => [ - "name" => "dad.cars", - "__fields" => [ - "model" => [ - "name" => "dad.cars[].model", - ], - "price" => [ - "name" => "dad.cars[].price", - ], - ], - ], - ], - ], - ], $nested); - } - - /** @test */ - public function sets_missing_ancestors_for_object_fields_properly() - { - $parameters = [ - "dad.cars[]" => Parameter::create([ - "name" => 'dad.cars[]', - ]), - "dad.cars[].model" => Parameter::create([ - "name" => 'dad.cars[].model', - ]), - "parent.not.specified" => Parameter::create([ - "name" => "parent.not.specified", - ]), - ]; - $cleanParameters = []; - - $nested = Extractor::nestArrayAndObjectFields($parameters, $cleanParameters); - - $this->assertEquals(["dad", "parent"], array_keys($nested)); - $this->assertArraySubset([ - "dad" => [ - "name" => "dad", - "__fields" => [ - "cars" => [ - "name" => "dad.cars", - "__fields" => [ - "model" => [ - "name" => "dad.cars[].model", - ], - ], - ], - ], - ], - "parent" => [ - "name" => "parent", - "__fields" => [ - "not" => [ - "name" => "parent.not", - "__fields" => [ - "specified" => [ - "name" => "parent.not.specified", - ], - ], - ], - ], - ], - ], $nested); - } - public function createRoute(string $httpMethod, string $path, string $controllerMethod, $class = TestController::class) { return new Route([$httpMethod], $path, ['uses' => [$class, $controllerMethod]]); diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index 41990675..6d2c94a5 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -2,22 +2,19 @@ namespace Knuckles\Scribe\Tests\Unit; -use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Faker\Factory; use Illuminate\Support\Arr; use Knuckles\Camel\Camel; use Knuckles\Camel\Output\OutputEndpointData; +use Knuckles\Scribe\Tests\BaseUnitTest; use Knuckles\Scribe\Tools\DocumentationConfig; use Knuckles\Scribe\Writing\OpenAPISpecWriter; -use PHPUnit\Framework\TestCase; /** * See https://swagger.io/specification/ */ -class OpenAPISpecWriterTest extends TestCase +class OpenAPISpecWriterTest extends BaseUnitTest { - use ArraySubsetAsserts; - protected $config = [ 'title' => 'My Testy Testes API', 'description' => 'All about testy testes.', diff --git a/tests/Unit/OutputEndpointDataTest.php b/tests/Unit/OutputEndpointDataTest.php new file mode 100644 index 00000000..c8dfd38e --- /dev/null +++ b/tests/Unit/OutputEndpointDataTest.php @@ -0,0 +1,108 @@ + Parameter::create([ + "name" => 'dad', + ]), + "dad.age" => ResponseField::create([ + "name" => 'dad.age', + ]), + "dad.cars[]" => Parameter::create([ + "name" => 'dad.cars[]', + ]), + "dad.cars[].model" => Parameter::create([ + "name" => 'dad.cars[].model', + ]), + "dad.cars[].price" => ResponseField::create([ + "name" => 'dad.cars[].price', + ]), + ]; + $cleanParameters = []; + + $nested = OutputEndpointData::nestArrayAndObjectFields($parameters, $cleanParameters); + + $this->assertEquals(["dad"], array_keys($nested)); + $this->assertArraySubset([ + "dad" => [ + "name" => "dad", + "__fields" => [ + "age" => [ + "name" => "dad.age", + ], + "cars" => [ + "name" => "dad.cars", + "__fields" => [ + "model" => [ + "name" => "dad.cars[].model", + ], + "price" => [ + "name" => "dad.cars[].price", + ], + ], + ], + ], + ], + ], $nested); + } + + /** @test */ + public function sets_missing_ancestors_for_object_fields_properly() + { + $parameters = [ + "dad.cars[]" => Parameter::create([ + "name" => 'dad.cars[]', + ]), + "dad.cars[].model" => Parameter::create([ + "name" => 'dad.cars[].model', + ]), + "parent.not.specified" => Parameter::create([ + "name" => "parent.not.specified", + ]), + ]; + $cleanParameters = []; + + $nested = OutputEndpointData::nestArrayAndObjectFields($parameters, $cleanParameters); + + $this->assertEquals(["dad", "parent"], array_keys($nested)); + $this->assertArraySubset([ + "dad" => [ + "name" => "dad", + "__fields" => [ + "cars" => [ + "name" => "dad.cars", + "__fields" => [ + "model" => [ + "name" => "dad.cars[].model", + ], + ], + ], + ], + ], + "parent" => [ + "name" => "parent", + "__fields" => [ + "not" => [ + "name" => "parent.not", + "__fields" => [ + "specified" => [ + "name" => "parent.not.specified", + ], + ], + ], + ], + ], + ], $nested); + } +} diff --git a/tests/Unit/PathConfigurationTest.php b/tests/Unit/PathConfigurationTest.php index 28ad3026..f65bfcd1 100644 --- a/tests/Unit/PathConfigurationTest.php +++ b/tests/Unit/PathConfigurationTest.php @@ -2,10 +2,10 @@ namespace Knuckles\Scribe\Tests\Unit; +use Knuckles\Scribe\Tests\BaseUnitTest; use Knuckles\Scribe\Tools\PathConfig; -use PHPUnit\Framework\TestCase; -class PathConfigurationTest extends TestCase +class PathConfigurationTest extends BaseUnitTest { /** @test */ public function resolves_default_cache_path() diff --git a/tests/Unit/PostmanCollectionWriterTest.php b/tests/Unit/PostmanCollectionWriterTest.php index fdb044bf..c6f63c49 100644 --- a/tests/Unit/PostmanCollectionWriterTest.php +++ b/tests/Unit/PostmanCollectionWriterTest.php @@ -2,18 +2,15 @@ namespace Knuckles\Scribe\Tests\Unit; -use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Knuckles\Camel\Output\OutputEndpointData; use Knuckles\Camel\Output\Parameter; use Knuckles\Scribe\Extracting\Extractor; +use Knuckles\Scribe\Tests\BaseUnitTest; use Knuckles\Scribe\Tools\DocumentationConfig; use Knuckles\Scribe\Writing\PostmanCollectionWriter; -use PHPUnit\Framework\TestCase; -class PostmanCollectionWriterTest extends TestCase +class PostmanCollectionWriterTest extends BaseUnitTest { - use ArraySubsetAsserts; - /** @test */ public function correct_structure_is_followed() { diff --git a/tests/Unit/RoutePatternMatcherTest.php b/tests/Unit/RoutePatternMatcherTest.php index db9000be..747cb2da 100644 --- a/tests/Unit/RoutePatternMatcherTest.php +++ b/tests/Unit/RoutePatternMatcherTest.php @@ -3,10 +3,10 @@ namespace Knuckles\Scribe\Tests\Unit; use Illuminate\Routing\Route; +use Knuckles\Scribe\Tests\BaseUnitTest; use Knuckles\Scribe\Tools\RoutePatternMatcher; -use PHPUnit\Framework\TestCase; -class RoutePatternMatcherTest extends TestCase +class RoutePatternMatcherTest extends BaseUnitTest { /** @test */ public function matches_by_route_name() @@ -42,6 +42,7 @@ public function matches_by_route_path() $this->assertTrue(RoutePatternMatcher::matches($route, ["*"])); $this->assertFalse(RoutePatternMatcher::matches($route, ["/d*"])); + $this->assertFalse(RoutePatternMatcher::matches($route, ["d*"])); } } diff --git a/tests/Unit/ExtractorPluginSystemTest.php b/tests/Unit/StrategyInvocationTest.php similarity index 99% rename from tests/Unit/ExtractorPluginSystemTest.php rename to tests/Unit/StrategyInvocationTest.php index 5a49f783..faa1ae2b 100644 --- a/tests/Unit/ExtractorPluginSystemTest.php +++ b/tests/Unit/StrategyInvocationTest.php @@ -8,11 +8,11 @@ use Knuckles\Scribe\Extracting\Extractor; use Knuckles\Scribe\Extracting\Strategies\Strategy; use Knuckles\Scribe\ScribeServiceProvider; +use Knuckles\Scribe\Tests\BaseUnitTest; use Knuckles\Scribe\Tests\Fixtures\TestController; use Knuckles\Scribe\Tools\DocumentationConfig; -use PHPUnit\Framework\TestCase; -class ExtractorPluginSystemTest extends TestCase +class StrategyInvocationTest extends BaseUnitTest { use ArraySubsetAsserts;