diff --git a/camel/BaseDTO.php b/camel/BaseDTO.php index 9e6c1e40..d568bd47 100644 --- a/camel/BaseDTO.php +++ b/camel/BaseDTO.php @@ -6,7 +6,7 @@ use Spatie\DataTransferObject\DataTransferObject; -class BaseDTO extends DataTransferObject implements Arrayable +class BaseDTO extends DataTransferObject implements Arrayable, \ArrayAccess { /** * @var array $custom @@ -48,4 +48,29 @@ protected function parseArray(array $array): array return $array; } + + public static function make(array|self $data): static + { + return $data instanceof static ? $data : new static($data); + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->$offset); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->$offset; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->$offset = $value; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->$offset); + } } diff --git a/src/Extracting/Extractor.php b/src/Extracting/Extractor.php index 0f492613..772a8af3 100644 --- a/src/Extracting/Extractor.php +++ b/src/Extracting/Extractor.php @@ -11,6 +11,7 @@ use Illuminate\Routing\Route; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Knuckles\Camel\Extraction\ResponseCollection; use Knuckles\Camel\Extraction\ResponseField; use Knuckles\Camel\Output\OutputEndpointData; use Knuckles\Scribe\Extracting\Strategies\Strategy; @@ -43,7 +44,6 @@ public static function getRouteBeingProcessed(): ?Route * @param array $routeRules Rules to apply when generating documentation for this route * * @return ExtractedEndpointData - * @throws \ReflectionException * */ public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData @@ -51,20 +51,32 @@ public function processRoute(Route $route, array $routeRules = []): ExtractedEnd self::$routeBeingProcessed = $route; $endpointData = ExtractedEndpointData::fromRoute($route); + + $inheritedDocsOverrides = []; + if ($endpointData?->controller->hasMethod('inheritedDocsOverrides')) { + $inheritedDocsOverrides = call_user_func([$endpointData->controller->getName(), 'inheritedDocsOverrides']); + $inheritedDocsOverrides = $inheritedDocsOverrides[$endpointData->method->getName()] ?? []; + } + $this->fetchMetadata($endpointData, $routeRules); + $this->mergeInheritedMethodsData('metadata', $endpointData, $inheritedDocsOverrides); $this->fetchUrlParameters($endpointData, $routeRules); + $this->mergeInheritedMethodsData('urlParameters', $endpointData, $inheritedDocsOverrides); $endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters); $this->addAuthField($endpointData); $this->fetchQueryParameters($endpointData, $routeRules); + $this->mergeInheritedMethodsData('queryParameters', $endpointData, $inheritedDocsOverrides); $endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters); $this->fetchRequestHeaders($endpointData, $routeRules); + $this->mergeInheritedMethodsData('headers', $endpointData, $inheritedDocsOverrides); $this->fetchBodyParameters($endpointData, $routeRules); $endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters); + $this->mergeInheritedMethodsData('bodyParameters', $endpointData, $inheritedDocsOverrides); if (count($endpointData->cleanBodyParameters) && !isset($endpointData->headers['Content-Type'])) { // Set content type if the user forgot to set it @@ -81,8 +93,10 @@ public function processRoute(Route $route, array $routeRules = []): ExtractedEnd $endpointData->cleanBodyParameters = $regularParameters; $this->fetchResponses($endpointData, $routeRules); + $this->mergeInheritedMethodsData('responses', $endpointData, $inheritedDocsOverrides); $this->fetchResponseFields($endpointData, $routeRules); + $this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides); self::$routeBeingProcessed = null; @@ -93,7 +107,7 @@ protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rul { $endpointData->metadata = new Metadata([ 'groupName' => $this->config->get('groups.default', ''), - "authenticated" => $this->config->get("auth.default", false) + "authenticated" => $this->config->get("auth.default", false), ]); $this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) { @@ -444,7 +458,7 @@ public static function nestArrayAndObjectFields(array $parameters, array $cleanP // When the body is an array, param names will be "[].paramname", // so $parts is ['[]'] if ($parts[0] == '[]') { - $dotPathToParent = '[]'.$dotPathToParent; + $dotPathToParent = '[]' . $dotPathToParent; } $dotPath = $dotPathToParent . '.__fields.' . $fieldName; @@ -471,4 +485,39 @@ public static function nestArrayAndObjectFields(array $parameters, array $cleanP return $finalParameters; } + + protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void + { + $overrides = $inheritedDocsOverrides[$stage] ?? []; + $normalizeparamData = fn($data, $key) => array_merge($data, ["name" => $key]); + if (is_array($overrides)) { + foreach ($overrides as $key => $item) { + switch ($stage) { + case "responses": + $endpointData->responses->concat($overrides); + $endpointData->responses->sortBy('status'); + break; + case "urlParameters": + case "bodyParameters": + case "queryParameters": + $endpointData->$stage[$key] = Parameter::make($normalizeparamData($item, $key)); + break; + case "responseFields": + $endpointData->$stage[$key] = ResponseField::make($normalizeparamData($item, $key)); + break; + default: + $endpointData->$stage[$key] = $item; + } + } + } else if (is_callable($overrides)) { + $results = $overrides($endpointData); + + $endpointData->$stage = match ($stage) { + "responses" => ResponseCollection::make($results), + "urlParameters", "bodyParameters", "queryParameters" => collect($results)->map(fn($param, $name) => Parameter::make($normalizeparamData($param, $name)))->all(), + "responseFields" => collect($results)->map(fn($field, $name) => ResponseField::make($normalizeparamData($field, $name)))->all(), + default => $results, + }; + } + } } diff --git a/tests/Unit/ExtractorTest.php b/tests/Unit/ExtractorTest.php index 9db28219..76b14137 100644 --- a/tests/Unit/ExtractorTest.php +++ b/tests/Unit/ExtractorTest.php @@ -4,6 +4,7 @@ use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Illuminate\Routing\Route; +use Knuckles\Camel\Extraction\ExtractedEndpointData; use Knuckles\Camel\Extraction\Parameter; use Knuckles\Scribe\Attributes\UrlParam; use Knuckles\Scribe\Extracting\Extractor; @@ -266,6 +267,32 @@ public function endpoint_metadata_supports_custom_declarations() $this->assertSame('some custom metadata', $parsed->metadata->custom['myProperty']); } + /** @test */ + public function can_override_data_for_inherited_methods() + { + $route = $this->createRoute('POST', '/api/test', 'endpoint', TestParentController::class); + $parent = $this->extractor->processRoute($route); + $this->assertSame('Parent title', $parent->metadata->title); + $this->assertSame('Parent group name', $parent->metadata->groupName); + $this->assertSame('Parent description', $parent->metadata->description); + $this->assertCount(1, $parent->responses); + $this->assertCount(1, $parent->bodyParameters); + $this->assertArraySubset(["type" => "integer"], $parent->bodyParameters['thing']->toArray()); + $this->assertEmpty($parent->queryParameters); + + $inheritedRoute = $this->createRoute('POST', '/api/test', 'endpoint', TestInheritedController::class); + $inherited = $this->extractor->processRoute($inheritedRoute); + $this->assertSame('Overridden title', $inherited->metadata->title); + $this->assertSame('Overridden group name', $inherited->metadata->groupName); + $this->assertSame('Parent description', $inherited->metadata->description); + $this->assertCount(0, $inherited->responses); + $this->assertCount(2, $inherited->bodyParameters); + $this->assertArraySubset(["type" => "integer"], $inherited->bodyParameters['thing']->toArray()); + $this->assertArraySubset(["type" => "string"], $inherited->bodyParameters["other_thing"]->toArray()); + $this->assertCount(1, $inherited->queryParameters); + $this->assertArraySubset(["type" => "string"], $inherited->queryParameters["queryThing"]->toArray()); + } + public function createRoute(string $httpMethod, string $path, string $controllerMethod, $class = TestController::class) { return new Route([$httpMethod], $path, ['uses' => [$class, $controllerMethod]]); @@ -352,3 +379,55 @@ public function authRules() ]; } } + + +class TestParentController +{ + /** + * Parent title + * + * Parent description + * + * @group Parent group name + * + * @bodyParam thing integer + * @response {"hello":"there"} + */ + public function endpoint() + { + + } +} + +class TestInheritedController extends TestParentController +{ + public static function inheritedDocsOverrides() + { + return [ + "endpoint" => [ + "metadata" => [ + "title" => "Overridden title", + "groupName" => "Overridden group name", + ], + "queryParameters" => function (ExtractedEndpointData $endpointData) { + // Overrides + return [ + 'queryThing' => [ + 'type' => 'string', + ], + ]; + }, + "bodyParameters" => [ + // Merges + "other_thing" => [ + "type" => "string", + ], + ], + "responses" => function (ExtractedEndpointData $endpointData) { + // Completely overrides responses + return []; + }, + ], + ]; + } +}