From 734e8cd343e0390b5b4bf5f48e394f69ebd1f602 Mon Sep 17 00:00:00 2001 From: Sergei Shiriaev Date: Wed, 8 Apr 2026 18:59:43 +0300 Subject: [PATCH] Add `afterExtracting` hook to modify endpoint data after extraction --- src/Extracting/Extractor.php | 5 ++ src/Scribe.php | 11 +++ src/Tools/Globals.php | 2 + tests/Unit/AfterExtractingHookTest.php | 100 +++++++++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 tests/Unit/AfterExtractingHookTest.php diff --git a/src/Extracting/Extractor.php b/src/Extracting/Extractor.php index e18ed741..af474442 100644 --- a/src/Extracting/Extractor.php +++ b/src/Extracting/Extractor.php @@ -17,6 +17,7 @@ use Knuckles\Scribe\Extracting\Strategies\StaticData; use Knuckles\Scribe\Tools\ConsoleOutputUtils as c; use Knuckles\Scribe\Tools\DocumentationConfig; +use Knuckles\Scribe\Tools\Globals; use Knuckles\Scribe\Tools\RoutePatternMatcher; class Extractor @@ -96,6 +97,10 @@ public function processRoute(Route $route, array $routeRules = []): ExtractedEnd $this->fetchResponseFields($endpointData, $routeRules); $this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides); + if (is_callable(Globals::$__afterExtracting)) { + call_user_func_array(Globals::$__afterExtracting, [$endpointData]); + } + self::$routeBeingProcessed = null; return $endpointData; diff --git a/src/Scribe.php b/src/Scribe.php index f71d7101..6750ae56 100644 --- a/src/Scribe.php +++ b/src/Scribe.php @@ -95,4 +95,15 @@ public static function normalizeEndpointUrlUsing(?callable $callable) { Globals::$__normalizeEndpointUrlUsing = $callable; } + + /** + * Specify a callback that will be executed after all extraction strategies have run for a route. + * This allows you to modify the extracted endpoint data before it is saved. + * + * @param callable(ExtractedEndpointData): void $callable + */ + public static function afterExtracting(callable $callable) + { + Globals::$__afterExtracting = $callable; + } } diff --git a/src/Tools/Globals.php b/src/Tools/Globals.php index d655a426..09d19187 100644 --- a/src/Tools/Globals.php +++ b/src/Tools/Globals.php @@ -19,4 +19,6 @@ class Globals public static $__instantiateFormRequestUsing; public static $__normalizeEndpointUrlUsing; + + public static $__afterExtracting; } diff --git a/tests/Unit/AfterExtractingHookTest.php b/tests/Unit/AfterExtractingHookTest.php new file mode 100644 index 00000000..8e2c47d1 --- /dev/null +++ b/tests/Unit/AfterExtractingHookTest.php @@ -0,0 +1,100 @@ + [ + 'metadata' => [ + ...Defaults::METADATA_STRATEGIES, + ], + 'headers' => [ + ...Defaults::HEADERS_STRATEGIES, + ], + 'urlParameters' => [ + ...Defaults::URL_PARAMETERS_STRATEGIES, + ], + 'queryParameters' => [ + ...Defaults::QUERY_PARAMETERS_STRATEGIES, + ], + 'bodyParameters' => [ + ...Defaults::BODY_PARAMETERS_STRATEGIES, + ], + 'responses' => [], + 'responseFields' => [ + ...Defaults::RESPONSE_FIELDS_STRATEGIES, + ], + ], + ]; + + protected function tearDown(): void + { + Globals::$__afterExtracting = null; + parent::tearDown(); + } + + /** @test */ + public function can_use_after_extracting_hook() + { + $route = new Route(['GET'], 'api/test', ['uses' => [TestController::class, 'withEndpointDescription']]); + + Scribe::afterExtracting(static function (ExtractedEndpointData $endpointData) { + $endpointData->metadata->title = 'Modified Title'; + $endpointData->headers['X-Modified-By'] = 'Hook'; + }); + + $extractor = new Extractor(new DocumentationConfig($this->config)); + $parsed = $extractor->processRoute($route); + + $this->assertSame('Modified Title', $parsed->metadata->title); + $this->assertSame('Hook', $parsed->headers['X-Modified-By']); + } + + /** @test */ + public function after_extracting_hook_is_called_only_once_per_route() + { + $route = new Route(['GET'], 'api/test', ['uses' => [TestController::class, 'withEndpointDescription']]); + + $callCount = 0; + Scribe::afterExtracting(function (ExtractedEndpointData $endpointData) use (&$callCount) { + $callCount++; + }); + + $extractor = new Extractor(new DocumentationConfig($this->config)); + $extractor->processRoute($route); + + $this->assertSame(1, $callCount); + } + + /** @test */ + public function after_extracting_hook_can_access_route_middlewares() + { + $route = new Route(['GET'], 'api/test', ['uses' => [TestController::class, 'withEndpointDescription']]); + $route->middleware(['auth:agent_api', 'throttle:60,1']); + + Scribe::afterExtracting(static function (ExtractedEndpointData $endpointData) { + $middlewares = $endpointData->route->gatherMiddleware(); + if (in_array('auth:agent_api', $middlewares, true)) { + $endpointData->metadata->description .= 'Requires authentication: `Agent`'; + } elseif (in_array('auth:api', $middlewares, true)) { + $endpointData->metadata->description .= 'Requires authentication: `User`'; + } + }); + + $extractor = new Extractor(new DocumentationConfig($this->config)); + $parsed = $extractor->processRoute($route); + + $this->assertStringContainsString('Requires authentication: `Agent`', $parsed->metadata->description); + } +}