From 335318eed6999dfbb2c0ffc0669480a4f5ca02fc Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Mon, 5 Oct 2026 17:41:34 +0500 Subject: [PATCH 1/2] feat: Add strategy to dynamically create scribe scenarios from api controllers on the fly --- docs/generating-api-docs/setting-up-scribe.md | 137 +++++- src/Scribe/Attributes/ResponseScenario.php | 51 ++ .../Contracts/ResponseScenarioSetup.php | 18 + .../ResponseExamplesOpenApiGenerator.php | 163 +++++++ .../Strategies/ResponseScenarioCalls.php | 452 ++++++++++++++++++ .../InstanceResponseScenariosController.php | 26 + .../ResponseScenariosController.php | 58 +++ .../ResponseExamplesOpenApiGeneratorTest.php | 69 +++ .../Strategies/ResponseScenarioCallsTest.php | 349 ++++++++++++++ tests/Scribe/Setups/CreateProductSetup.php | 19 + tests/Scribe/Setups/RecordScenarioSetup.php | 22 + 11 files changed, 1362 insertions(+), 2 deletions(-) create mode 100644 src/Scribe/Attributes/ResponseScenario.php create mode 100644 src/Scribe/Contracts/ResponseScenarioSetup.php create mode 100644 src/Scribe/ResponseExamplesOpenApiGenerator.php create mode 100644 src/Scribe/Strategies/ResponseScenarioCalls.php create mode 100644 tests/Controllers/InstanceResponseScenariosController.php create mode 100644 tests/Controllers/ResponseScenariosController.php create mode 100644 tests/Feature/Scribe/ResponseExamplesOpenApiGeneratorTest.php create mode 100644 tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php create mode 100644 tests/Scribe/Setups/CreateProductSetup.php create mode 100644 tests/Scribe/Setups/RecordScenarioSetup.php diff --git a/docs/generating-api-docs/setting-up-scribe.md b/docs/generating-api-docs/setting-up-scribe.md index 552879c..75648c9 100644 --- a/docs/generating-api-docs/setting-up-scribe.md +++ b/docs/generating-api-docs/setting-up-scribe.md @@ -22,7 +22,7 @@ Then publish the Scribe config. php artisan vendor:publish --tag=scribe-config ``` -## Add custom Sribe Strategies +## Add custom Scribe Strategies Now add the following Strategies provided by this package to the `scribe.php` config file. @@ -43,6 +43,140 @@ Now add the following Strategies provided by this package to the `scribe.php` co ], ``` +## Generate response scenarios + +The package also includes `ResponseScenarioCalls` for generating multiple real +responses per endpoint. These classes require Scribe 5.3 or later; Scribe remains +an optional dependency of Query Builder. + +Replace Scribe's default `ResponseCalls` strategy in `config/scribe.php`: + +```php +use Javaabu\QueryBuilder\Scribe\Strategies\ResponseScenarioCalls; +use Knuckles\Scribe\Config\Defaults; +use Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls; + +'strategies' => [ + // Keep your other extraction stages. + 'responses' => [ + ...array_filter( + Defaults::RESPONSES_STRATEGIES, + static fn (string $strategy): bool => $strategy !== ResponseCalls::class, + ), + ResponseScenarioCalls::withSettings(config: ['app.debug' => false]), + ], +], +``` + +Declare scenarios with repeatable method attributes or an `apiDocScenarios()` +provider keyed by controller action name. Providers may be public static methods +or public instance methods resolved through Laravel's container. Each action +accepts a single scenario or a list; attributes and provider scenarios are combined. + +```php +use Javaabu\QueryBuilder\Scribe\Attributes\ResponseScenario; + +public static function apiDocScenarios(): array +{ + return [ + 'store' => [ + new ResponseScenario( + name: 'Created', + body: ['name' => 'Island Life'], + expected_status: 201, + ), + new ResponseScenario( + name: 'Validation failed', + body: [], + expected_status: 422, + ), + ], + ]; +} + +#[ResponseScenario(name: 'Not found', url: ['id' => 999999], expected_status: 404)] +#[ResponseScenario(name: 'Unauthenticated', without_authentication: true, expected_status: 401)] +public function show(string $id) +{ + // Your endpoint implementation. +} +``` + +The constructor supports `url`, `body`, `query`, `files` (local upload paths), +`cookies`, `config`, `expected_status`, `description`, `without_authentication`, +`setup`, and `setup_data`. Body and file input replace extracted examples so an +empty body can exercise validation. Query, cookie, and config overrides merge with +global response-call settings. URL keys must match route placeholders, including +optional placeholders. Each scenario uses a cloned endpoint, preserving the +documented example URL. + +The response description defaults to `name`. Give scenarios meaningful names, +and store them as lists so scenarios sharing an HTTP status are retained. A status +mismatch or an unsuccessful explicit response call fails generation. Explicit +scenarios run for any HTTP method; endpoints without scenarios retain Scribe's +GET-only fallback and existing-success-response behavior. + +### Prepare scenario state + +Keep application-specific setup classes in `app/Support/Scribe/Setups` and +implement the package contract: + +```php +namespace App\Support\Scribe\Setups; + +use App\Models\Product; +use Illuminate\Http\Request; +use Javaabu\QueryBuilder\Scribe\Attributes\ResponseScenario; +use Javaabu\QueryBuilder\Scribe\Contracts\ResponseScenarioSetup; +use Knuckles\Camel\Extraction\ExtractedEndpointData; + +class CreateProductSetup implements ResponseScenarioSetup +{ + public function __invoke( + Request $request, + ExtractedEndpointData $endpoint_data, + ResponseScenario $scenario, + ): void { + Product::factory()->create(['name' => $scenario->setup_data['name']]); + } +} +``` + +Set `setup: CreateProductSetup::class` and `setup_data: ['name' => 'Island Life']` +on a scenario, or pass an ordered list of setup classes. Setups are container +resolved, receive the same scenario data, and run after Scribe's +`beforeResponseCall` hook inside its database transaction. Authentication guards +are cleared between calls; `without_authentication` removes authorization headers +even when a hook or setup adds them. + +List every mutated connection in `database_connections_to_transact`. Use a +documentation database and fake external effects such as mail, notifications, +queues, payments, and filesystem writes; database rollback cannot undo them. + +### Expose named examples in OpenAPI + +To make same-status scenarios selectable in external UIs such as Scalar, register +the package generator after any other custom OpenAPI generators: + +```php +'openapi' => [ + 'enabled' => true, + 'overrides' => [], + 'generators' => [ + // Your other generators first. + \Javaabu\QueryBuilder\Scribe\ResponseExamplesOpenApiGenerator::class, + ], +], +``` + +It preserves generated schemas and adds uniquely named examples under +`responses..content..examples`. Binary bodies are skipped; +JSON is decoded and plain text is retained. OAuth grant schemas and application +setup classes remain application customizations. + +After generation, check `.scribe/endpoints` for all scenarios and `openapi.yaml` +for their named examples. Generate twice to check that no scenario state leaks. + ## Configure Auth You would most probably need to configure auth for Scribe. Add the following recommended auth config to `scribe.php` config file. @@ -92,4 +226,3 @@ php artisan scribe:generate And your API docs will be magically created with sensible documentation. - diff --git a/src/Scribe/Attributes/ResponseScenario.php b/src/Scribe/Attributes/ResponseScenario.php new file mode 100644 index 0000000..0d1f0dd --- /dev/null +++ b/src/Scribe/Attributes/ResponseScenario.php @@ -0,0 +1,51 @@ + $url + * @param array $body + * @param array $query + * @param array $files + * @param array $cookies + * @param array $config + * @param class-string|list>|null $setup + * @param array $setup_data + */ + public function __construct( + public string $name, + public array $url = [], + public array $body = [], + public array $query = [], + public array $files = [], + public array $cookies = [], + public array $config = [], + public ?int $expected_status = null, + public ?string $description = null, + public bool $without_authentication = false, + public string|array|null $setup = null, + public array $setup_data = [], + ) {} + + /** + * @return list> + */ + public function setups(): array + { + return match (true) { + $this->setup === null => [], + is_string($this->setup) => [$this->setup], + default => array_values($this->setup), + }; + } +} diff --git a/src/Scribe/Contracts/ResponseScenarioSetup.php b/src/Scribe/Contracts/ResponseScenarioSetup.php new file mode 100644 index 0000000..2cf08ab --- /dev/null +++ b/src/Scribe/Contracts/ResponseScenarioSetup.php @@ -0,0 +1,18 @@ + $grouped_endpoints + */ + public function pathItem( + array $path_item, + array $grouped_endpoints, + OutputEndpointData $endpoint, + ): array { + $responses_by_status = $endpoint->responses->groupBy( + static fn (Response $response): string => (string) $response->status, + ); + + foreach ($responses_by_status as $status => $responses) { + /* + * A named examples collection is only necessary when more than one + * scenario shares the same response status. + */ + if ($responses->count() < 2) { + continue; + } + + if (! isset($path_item['responses'][$status]['content'])) { + continue; + } + + foreach ($responses->values() as $index => $response) { + /* + * Binary responses cannot be represented as inline OpenAPI + * examples. + */ + if ( + $response->content !== null + && str_starts_with($response->content, '<>') + ) { + continue; + } + + $content_type = $this->resolveContentType( + $path_item['responses'][$status]['content'], + $response, + ); + + if ($content_type === null) { + continue; + } + + $summary = trim((string) $response->description); + + if ($summary === '') { + $summary = sprintf('Scenario %d', $index + 1); + } + + $examples = &$path_item['responses'][$status]['content'][$content_type]['examples']; + + $key = $this->uniqueExampleKey( + $summary, + $index + 1, + $examples ?? [], + ); + + $examples[$key] = [ + 'summary' => $summary, + 'value' => $this->decodeContent($response->content), + ]; + + unset($examples); + } + } + + return $path_item; + } + + /** + * Find the media type that Scribe generated for this response. + * + * @param array $content + */ + private function resolveContentType( + array $content, + Response $response, + ): ?string { + $headers = array_change_key_case( + $response->headers, + CASE_LOWER, + ); + + $declared_content_type = $headers['content-type'] + ?? 'application/json'; + + if (array_key_exists($declared_content_type, $content)) { + return $declared_content_type; + } + + /* + * Fall back to Scribe's generated media type. This handles values such + * as "application/json; charset=UTF-8". + */ + return array_key_first($content); + } + + /** + * Generate a unique OpenAPI example key from the scenario description. + * + * @param array $existing_examples + */ + private function uniqueExampleKey( + string $summary, + int $position, + array $existing_examples, + ): string { + $base_key = Str::snake(Str::ascii($summary)); + + if ($base_key === '') { + $base_key = sprintf('scenario_%d', $position); + } + + $key = $base_key; + $suffix = 2; + + while (array_key_exists($key, $existing_examples)) { + $key = sprintf('%s_%d', $base_key, $suffix); + $suffix++; + } + + return $key; + } + + /** + * Decode JSON responses while preserving plain-text response content. + */ + private function decodeContent(?string $content): mixed + { + if ($content === null) { + return null; + } + + $decoded = json_decode($content, true); + + return json_last_error() === JSON_ERROR_NONE + ? $decoded + : $content; + } +} diff --git a/src/Scribe/Strategies/ResponseScenarioCalls.php b/src/Scribe/Strategies/ResponseScenarioCalls.php new file mode 100644 index 0000000..e17628b --- /dev/null +++ b/src/Scribe/Strategies/ResponseScenarioCalls.php @@ -0,0 +1,452 @@ + $settings + * @return list>|null + */ + public function makeResponseCall(ExtractedEndpointData $endpoint_data, array $settings): ?array + { + $connections = []; + + foreach ($this->getConfig()->get('database_connections_to_transact', []) as $connection_name) { + $connection = app('db')->connection($connection_name); + $connections[] = [$connection, $connection->transactionLevel()]; + } + + $this->previousConfigs = []; + + try { + return parent::makeResponseCall($endpoint_data, $settings); + } finally { + foreach ($connections as [$connection, $transaction_level]) { + if ($connection->transactionLevel() > $transaction_level) { + $connection->rollBack($transaction_level); + } + } + + foreach ($this->previousConfigs as $name => $value) { + config([$name => $value]); + } + + $this->previousConfigs = []; + Auth::forgetGuards(); + } + } + + /** + * Generate responses for every ResponseScenario attribute on the endpoint. + * + * For endpoints without a ResponseScenario, preserve Scribe's default + * behaviour of making a response call only for GET endpoints. + * + * @return array>|null + * + * @throws ReflectionException + */ + public function __invoke( + ExtractedEndpointData $endpoint_data, + array $settings = [], + ): ?array { + $scenarios = $this->scenariosForEndpoint($endpoint_data); + + /* + * Preserve Scribe's normal GET response-call behaviour for endpoints + * that do not use ResponseScenario. + */ + if ($scenarios === []) { + if (! in_array( + 'GET', + $this->getMethods($endpoint_data->route), + true, + )) { + return null; + } + + return parent::__invoke($endpoint_data, $settings); + } + + $responses = []; + + foreach ($scenarios as $scenario) { + $scenario_endpoint_data = $this->endpointDataForScenario( + $endpoint_data, + $scenario, + ); + + $this->current_scenario = $scenario; + $this->without_authentication = $scenario->without_authentication; + + try { + $generated_responses = $this->makeResponseCall( + $scenario_endpoint_data, + $this->settingsForScenario($settings, $scenario), + ); + } finally { + $this->current_scenario = null; + $this->without_authentication = false; + } + + if ($generated_responses === null) { + throw new RuntimeException(sprintf( + 'Scribe could not generate the "%s" response scenario for [%s] %s.', + $scenario->name, + implode('|', $this->getMethods($endpoint_data->route)), + $endpoint_data->uri, + )); + } + + foreach ($generated_responses as $generated_response) { + $this->validateStatus($scenario, $generated_response); + + $generated_response['description'] = $this->description( + $scenario, + ); + + $responses[] = $generated_response; + } + } + + return $responses; + } + + /** + * Collect scenarios declared as PHP attributes and scenarios returned by + * the controller's optional apiDocScenarios() method. + * + * The apiDocScenarios() return value is keyed by controller action name, + * such as show, store, update, destroy, or __invoke. + * + * @return array + * + * @throws ReflectionException + */ + private function scenariosForEndpoint( + ExtractedEndpointData $endpoint_data, + ): array { + // Get all attribute scenarios + $scenarios = array_map( + static fn ( + ReflectionAttribute $attribute, + ): ResponseScenario => $attribute->newInstance(), + $endpoint_data->method->getAttributes( + ResponseScenario::class, + ReflectionAttribute::IS_INSTANCEOF, + ), + ); + + $controller = $endpoint_data->controller; + + if ( + $controller === null + || ! $controller->hasMethod('apiDocScenarios') + ) { + return $scenarios; + } + + $provider = $controller->getMethod('apiDocScenarios'); + + if (! $provider->isPublic()) { + throw new InvalidArgumentException(sprintf( + '%s::apiDocScenarios() must be public.', + $controller->getName(), + )); + } + + /* + * Static providers are recommended because they do not require a + * controller instance. Non-static providers are also supported and + * are resolved through Laravel's service container. + */ + $controller_instance = $provider->isStatic() + ? null + : app($controller->getName()); + + $definitions = $provider->invoke($controller_instance); + + if (! is_array($definitions)) { + throw new InvalidArgumentException(sprintf( + '%s::apiDocScenarios() must return an array.', + $controller->getName(), + )); + } + + $action = $endpoint_data->method->getName(); + $action_scenarios = $definitions[$action] ?? []; + + if ($action_scenarios instanceof ResponseScenario) { + $action_scenarios = [$action_scenarios]; + } + + if (! is_array($action_scenarios)) { + throw new InvalidArgumentException(sprintf( + 'The scenarios for %s::%s must be a ResponseScenario or an array of ResponseScenario objects.', + $controller->getName(), + $action, + )); + } + + foreach ($action_scenarios as $index => $scenario) { + if (! $scenario instanceof ResponseScenario) { + throw new InvalidArgumentException(sprintf( + 'Scenario %s for %s::%s must be an instance of %s.', + (string) $index, + $controller->getName(), + $action, + ResponseScenario::class, + )); + } + + $scenarios[] = $scenario; + } + + return $scenarios; + } + + /** + * Run Scribe's configured beforeResponseCall hook, then enforce the + * scenario's authentication setting. + * + * Removing the header after the hook is important because an application + * hook may add an Authorization header unconditionally. + */ + protected function runPreRequestHook( + Request $request, + ExtractedEndpointData $endpoint_data, + ): void { + /* + * Scribe executes several HTTP requests within the same PHP process. + * Forget guards resolved by an earlier scenario so their cached user + * cannot leak into this request. + */ + Auth::forgetGuards(); + + /* + * Runs Scribe's/AppServiceProvider's beforeResponseCall hook. + * Your bearer token is attached here. + */ + parent::runPreRequestHook($request, $endpoint_data); + + /* + * Prepare the scenario-specific database state. + */ + $this->prepareScenario($request, $endpoint_data); + + // Setups can issue a scenario-specific token or otherwise resolve auth. + Auth::forgetGuards(); + + if (! $this->without_authentication) { + return; + } + + $request->headers->remove('Authorization'); + $request->server->remove('HTTP_AUTHORIZATION'); + $request->server->remove('REDIRECT_HTTP_AUTHORIZATION'); + $request->setUserResolver(static fn () => null); + + /* + * A beforeResponseCall hook may itself resolve a guard, so forget the + * guards again after all pre-request hooks have completed. + */ + Auth::forgetGuards(); + } + + private function prepareScenario( + Request $request, + ExtractedEndpointData $endpoint_data, + ): void { + $scenario = $this->current_scenario; + + if ($scenario === null) { + return; + } + + foreach ($scenario->setups() as $setup_class) { + if (! is_string($setup_class) || $setup_class === '') { + throw new InvalidArgumentException(sprintf( + 'Every setup for the Scribe scenario "%s" must be a non-empty class-string.', + $scenario->name, + )); + } + + $setup = app($setup_class); + + if (! $setup instanceof ResponseScenarioSetup) { + throw new InvalidArgumentException(sprintf( + 'The Scribe scenario setup %s must implement %s.', + $setup_class, + ResponseScenarioSetup::class, + )); + } + + $setup($request, $endpoint_data, $scenario); + } + } + + /** + * Clear resolved guards after every response call so authentication state + * cannot leak into the next scenario. + */ + protected function runPostRequestHook( + Request $request, + ExtractedEndpointData $endpoint_data, + mixed $response, + ): void { + try { + parent::runPostRequestHook( + $request, + $endpoint_data, + $response, + ); + } finally { + Auth::forgetGuards(); + } + } + + /** + * Clone the extracted endpoint and apply scenario-specific route and + * authentication changes. + * + * Route placeholders are replaced directly in the cloned URI. This changes + * only the internal response call; it does not change the primary example + * URL displayed for the endpoint. + */ + private function endpointDataForScenario( + ExtractedEndpointData $endpoint_data, + ResponseScenario $scenario, + ): ExtractedEndpointData { + $scenario_endpoint_data = clone $endpoint_data; + + foreach ($scenario->url as $name => $value) { + $pattern = sprintf( + '/\{%s\??\}/', + preg_quote((string) $name, '/'), + ); + + if (! preg_match($pattern, $scenario_endpoint_data->uri)) { + throw new InvalidArgumentException(sprintf( + 'The route parameter {%s} does not exist in the URI "%s".', + $name, + $scenario_endpoint_data->uri, + )); + } + + $scenario_endpoint_data->uri = preg_replace_callback( + $pattern, + static fn (): string => rawurlencode((string) $value), + $scenario_endpoint_data->uri, + ); + } + + if ($scenario->without_authentication) { + /* + * Scribe represents an endpoint without authentication using an + * empty array. ResponseCalls checks this value as a boolean before + * attempting to add authentication to the request. + */ + $scenario_endpoint_data->auth = []; + } + + $scenario_endpoint_data->cleanBodyParameters = []; + $scenario_endpoint_data->fileParameters = []; + + return $scenario_endpoint_data; + } + + /** + * Merge scenario input with the response-call settings. + * + * Values declared on the scenario take precedence over global strategy + * settings and examples extracted from the endpoint. + * + * @return array + */ + private function settingsForScenario( + array $settings, + ResponseScenario $scenario, + ): array { + $settings['bodyParams'] = $scenario->body; + + $settings['queryParams'] = array_replace_recursive( + $settings['queryParams'] ?? [], + $scenario->query, + ); + + $settings['fileParams'] = $scenario->files; + + $settings['cookies'] = array_replace_recursive( + $settings['cookies'] ?? [], + $scenario->cookies, + ); + + $settings['config'] = array_replace_recursive( + $settings['config'] ?? [], + $scenario->config, + ); + + return $settings; + } + + /** + * Verify that Laravel returned the status expected by the scenario. + * + * Omitting expected_status allows any status to be documented. + * + * @param array $response + */ + private function validateStatus( + ResponseScenario $scenario, + array $response, + ): void { + if ($scenario->expected_status === null) { + return; + } + + $actual_status = (int) $response['status']; + + if ($actual_status !== $scenario->expected_status) { + throw new UnexpectedValueException(sprintf( + 'The Scribe scenario "%s" expected HTTP %d but received HTTP %d.', + $scenario->name, + $scenario->expected_status, + $actual_status, + )); + } + } + + /** + * Resolve the description shown for the generated response. + */ + private function description(ResponseScenario $scenario): string + { + return $scenario->description ?? $scenario->name; + } +} diff --git a/tests/Controllers/InstanceResponseScenariosController.php b/tests/Controllers/InstanceResponseScenariosController.php new file mode 100644 index 0000000..f2beba9 --- /dev/null +++ b/tests/Controllers/InstanceResponseScenariosController.php @@ -0,0 +1,26 @@ + */ + public function apiDocScenarios(): array + { + return ['show' => $this->scenario]; + } + + public function show(Request $request): JsonResponse + { + return response()->json(['body' => $request->request->all()]); + } +} diff --git a/tests/Controllers/ResponseScenariosController.php b/tests/Controllers/ResponseScenariosController.php new file mode 100644 index 0000000..52cd468 --- /dev/null +++ b/tests/Controllers/ResponseScenariosController.php @@ -0,0 +1,58 @@ +json([ + 'id' => $id, + 'body' => $request->request->all(), + 'query' => $request->query->all(), + 'cookie' => $request->cookie('example'), + 'authorization' => $request->header('Authorization'), + 'server_authorization' => $request->server('HTTP_AUTHORIZATION'), + 'redirect_authorization' => $request->server('REDIRECT_HTTP_AUTHORIZATION'), + 'config' => config('app.name'), + 'file' => $request->file('attachment')?->getClientOriginalName(), + 'setups' => $request->attributes->get('setups', []), + 'user' => $request->user()?->id, + ]); + } + + #[ResponseScenario(name: 'Attribute response', body: ['source' => 'attribute'], expected_status: 200)] + #[ResponseScenario(name: 'Second attribute', body: ['source' => 'second attribute'], expected_status: 200)] + public function attribute(Request $request): JsonResponse + { + return $this->show($request); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate(['name' => ['required', 'string']]); + $product = Product::factory()->create($data); + + return response()->json(['name' => $product->name], 201); + } + + public function products(): JsonResponse + { + return response()->json(['names' => Product::query()->pluck('name')->all()]); + } +} diff --git a/tests/Feature/Scribe/ResponseExamplesOpenApiGeneratorTest.php b/tests/Feature/Scribe/ResponseExamplesOpenApiGeneratorTest.php new file mode 100644 index 0000000..28d097e --- /dev/null +++ b/tests/Feature/Scribe/ResponseExamplesOpenApiGeneratorTest.php @@ -0,0 +1,69 @@ +endpoint('api/v1/profiles', [ + ['status' => 200, 'description' => 'Reader found', 'content' => '{"name":"Aisha"}', 'headers' => ['Content-Type' => 'application/json']], + ['status' => 200, 'description' => 'Reader found', 'content' => '{"name":"Ali"}', 'headers' => ['content-type' => 'application/json; charset=UTF-8']], + ['status' => 200, 'description' => '', 'content' => 'plain text'], + ['status' => 200, 'description' => 'No content', 'content' => null], + ['status' => 200, 'description' => 'File', 'content' => '<> image'], + ['status' => 404, 'description' => 'Missing', 'content' => '{}'], + ]); + $path = ['responses' => [200 => ['content' => ['application/json' => ['schema' => ['type' => 'object'], 'examples' => ['reader_found' => ['value' => 'existing']]]]], 404 => ['description' => 'Missing']]]; + + $result = (new ResponseExamplesOpenApiGenerator(new DocumentationConfig))->pathItem($path, [], $endpoint); + + $content = $result['responses'][200]['content']['application/json']; + $this->assertSame(['type' => 'object'], $content['schema']); + $this->assertSame([ + 'reader_found' => ['value' => 'existing'], + 'reader_found_2' => ['summary' => 'Reader found', 'value' => ['name' => 'Aisha']], + 'reader_found_3' => ['summary' => 'Reader found', 'value' => ['name' => 'Ali']], + 'scenario3' => ['summary' => 'Scenario 3', 'value' => 'plain text'], + 'no_content' => ['summary' => 'No content', 'value' => null], + ], $content['examples']); + $this->assertSame($path['responses'][404], $result['responses'][404]); + } + + #[Test] + public function it_skips_responses_without_a_generated_content_type(): void + { + $endpoint = $this->endpoint('api/v1/profiles', [ + ['status' => 200, 'description' => 'One', 'content' => '{}'], + ['status' => 200, 'description' => 'Two', 'content' => '{}'], + ['status' => 204, 'description' => 'Empty one', 'content' => null], + ['status' => 204, 'description' => 'Empty two', 'content' => null], + ]); + $path = ['responses' => [200 => ['content' => []], 204 => ['description' => 'Empty']]]; + + $result = (new ResponseExamplesOpenApiGenerator(new DocumentationConfig))->pathItem($path, [], $endpoint); + + $this->assertSame($path, $result); + } + + /** @param list> $responses */ + private function endpoint(string $uri, array $responses = []): OutputEndpointData + { + return new OutputEndpointData([ + 'uri' => $uri, + 'httpMethods' => ['POST'], + 'metadata' => new Metadata, + 'responses' => $responses, + ]); + } +} diff --git a/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php b/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php new file mode 100644 index 0000000..6f7dbb7 --- /dev/null +++ b/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php @@ -0,0 +1,349 @@ + 'scenario', + 'auth.guards.scenario' => [ + 'driver' => 'scenario', + ] + ]); + Auth::viaRequest('scenario', static fn(Request $request): ?GenericUser => $request->bearerToken() + ? new GenericUser(['id' => 7]) + : null); + } + + protected function tearDown(): void + { + ResponseScenariosController::$definitions = []; + Globals::$__beforeResponseCall = null; + Globals::$__afterResponseCall = null; + + parent::tearDown(); + } + + #[Test] + public function it_merges_repeatable_attributes_and_provider_scenarios_without_dropping_duplicate_statuses(): void + { + ResponseScenariosController::$definitions = ['attribute' => [ + new ResponseScenario(name: 'Provider response', body: ['source' => 'provider'], expected_status: 200), + new ResponseScenario(name: 'Third response', body: ['source' => 'third'], description: 'Custom description'), + ]]; + + // The helper registers a `POST /scenarios` route pointing to `ResponseScenariosController::attribute()`, then returns Scribe’s endpoint data for that route + $endpoint = $this->endpoint('POST', 'attribute'); + + // This creates a `ResponseScenarioCalls` object and immediately calls it with `$endpoint`. PHP allows an object to be called like a function when it has an `__invoke()` method. + $responses = ($this->strategy())($endpoint); + + $this->assertSame([200, 200, 200, 200], array_column($responses, 'status')); + $this->assertSame(['Attribute response', 'Second attribute', 'Provider response', 'Custom description'], array_column($responses, 'description')); + $this->assertSame(['attribute', 'second attribute', 'provider', 'third'], array_map(fn(array $response): string => $this->body($response)['body']['source'], $responses)); + } + + #[Test] + public function it_resolves_instance_providers_and_single_scenarios_through_the_container(): void + { + $this->app->instance(ResponseScenario::class, new ResponseScenario(name: 'Injected scenario', body: ['name' => 'Aisha'])); + $route = Route::post('/instance', [InstanceResponseScenariosController::class, 'show']); + $endpoint = ExtractedEndpointData::fromRoute($route); + + $responses = ($this->strategy())($endpoint); + +// [ +// 0 => [ +// 'status' => 200, +// 'description' => 'Injected scenario', +// 'content' => "{"body":{"name":"Aisha"}}" +// 'headers' => [...] +// ], +// ] + $this->assertSame('Injected scenario', $responses[0]['description']); + $this->assertSame(['body' => ['name' => 'Aisha']], $this->body($responses[0])); + } + + #[Test] + public function it_calls_get_endpoints_and_skips_writes_without_explicit_scenarios(): void + { + $strategy = $this->strategy(); + + $get_responses = $strategy($this->endpoint('GET', 'show')); + $post_responses = $strategy($this->endpoint('POST', 'store', '/writes')); + + $this->assertSame(200, $get_responses[0]['status']); + $this->assertNull($post_responses); + } + + #[Test] + public function it_preserves_existing_success_responses_without_scenarios(): void + { + $endpoint = $this->endpoint('GET', 'show'); + // We are mimicking a documented response that was already present in the endpoint data before Scribe runs its scenario strategy + $endpoint->responses->add(new Response(['status' => 200, 'content' => '{"documented":true}'])); + + $responses = ($this->strategy())($endpoint); + + $this->assertNull($responses); + } + + #[Test] + public function it_executes_explicit_write_scenarios_and_rolls_back_every_request(): void + { + $this->runMigrations(); + ResponseScenariosController::$definitions = ['store' => [ + new ResponseScenario(name: 'Created', body: ['name' => 'Apple'], expected_status: 201), + new ResponseScenario(name: 'Validation failed', body: [], expected_status: 422), + ]]; + $endpoint = $this->endpoint('POST', 'store'); + $endpoint->cleanBodyParameters = ['name' => 'Extracted example']; + + $responses = ($this->strategy([config('database.default')]))($endpoint); + + $this->assertSame([201, 422], array_column($responses, 'status')); + $this->assertSame(['name' => 'Apple'], $this->body($responses[0])); + $this->assertArrayHasKey('name', $this->body($responses[1])['errors']); + $this->assertDatabaseCount('products', 0); // Meaning it rolls back + $this->assertSame(0, DB::connection()->transactionLevel()); + } + + #[Test] + public function it_runs_setups_inside_each_scenario_transaction(): void + { + $this->runMigrations(); + ResponseScenariosController::$definitions = ['products' => [ + new ResponseScenario(name: 'Prepared', setup: CreateProductSetup::class, setup_data: ['name' => 'Apple']), + new ResponseScenario(name: 'Empty'), + ]]; + + $responses = ($this->strategy([config('database.default')]))($this->endpoint('GET', 'products')); + + $this->assertSame(['names' => ['Apple']], $this->body($responses[0])); + $this->assertSame(['names' => []], $this->body($responses[1])); + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_generates_repeatable_named_examples_through_scribes_openapi_writer(): void + { + $this->runMigrations(); + ResponseScenariosController::$definitions = ['store' => [ + new ResponseScenario(name: 'Created', body: ['name' => 'Apple'], expected_status: 201), + new ResponseScenario(name: 'Missing name', body: [], expected_status: 422), + new ResponseScenario(name: 'Invalid name', body: ['name' => false], expected_status: 422), + ]]; + $endpoint = $this->endpoint('POST', 'store'); + $strategy = $this->strategy([config('database.default')]); + $writer = new OpenAPISpecWriter(new DocumentationConfig([ + 'openapi' => ['generators' => [ResponseExamplesOpenApiGenerator::class]], + ])); + $specs = []; + + for ($generation = 0; $generation < 2; $generation++) { + $responses = $strategy($endpoint); + $output = new OutputEndpointData([ + ...$endpoint->forSerialisation(), + 'responses' => $responses, + ]); + $specs[] = $writer->generateSpecContent([ + ['name' => 'Products', 'description' => '', 'endpoints' => [$output]], + ]); + } + + $this->assertSame(json_encode($specs[0], JSON_THROW_ON_ERROR), json_encode($specs[1], JSON_THROW_ON_ERROR)); + $content = $specs[0]['paths']['/scenarios']['post']['responses'][422]['content']['application/json']; + $this->assertCount(2, $content['schema']['oneOf']); + $this->assertSame(['missing_name', 'invalid_name'], array_keys($content['examples'])); + $this->assertSame(['The name field is required.'], $content['examples']['missing_name']['value']['errors']['name']); + $this->assertSame(['The name field must be a string.'], $content['examples']['invalid_name']['value']['errors']['name']); + $this->assertDatabaseCount('products', 0); + } + + #[Test] + public function it_runs_ordered_setups_after_the_hook_and_isolates_unauthenticated_calls(): void + { + Globals::$__beforeResponseCall = static function (Request $request): void { + $request->attributes->set('setups', ['hook']); + $request->headers->set('Authorization', 'Bearer hook-token'); + $request->server->set('HTTP_AUTHORIZATION', 'Bearer hook-token'); + $request->server->set('REDIRECT_HTTP_AUTHORIZATION', 'Bearer hook-token'); + Auth::guard()->setUser(new GenericUser(['id' => 99])); + }; + ResponseScenariosController::$definitions = ['show' => [ + new ResponseScenario(name: 'Authenticated', setup: RecordScenarioSetup::class, setup_data: ['marker' => 'setup']), + new ResponseScenario(name: 'Anonymous', without_authentication: true, setup: [RecordScenarioSetup::class, RecordScenarioSetup::class], setup_data: ['marker' => 'setup']), + new ResponseScenario(name: 'Authenticated again'), + ]]; + + $responses = ($this->strategy())($this->endpoint('GET', 'show')); + + $this->assertSame(['hook', 'setup'], $this->body($responses[0])['setups']); + $this->assertSame('Bearer setup-token', $this->body($responses[0])['authorization']); + $this->assertSame(7, $this->body($responses[0])['user']); + $anonymous = $this->body($responses[1]); + $this->assertSame(['hook', 'setup', 'setup'], $anonymous['setups']); + foreach (['authorization', 'server_authorization', 'redirect_authorization', 'user'] as $key) { + $this->assertNull($anonymous[$key]); + } + $this->assertSame(7, $this->body($responses[2])['user']); + } + + #[Test] + public function it_rejects_unknown_route_parameters(): void + { + ResponseScenariosController::$definitions = ['show' => new ResponseScenario(name: 'Invalid URL', url: ['missing' => 1])]; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The route parameter {missing} does not exist'); + + ($this->strategy())($this->endpoint('GET', 'show')); + } + + #[Test] + public function it_fails_on_a_status_mismatch_after_restoring_configuration(): void + { + $original_name = config('app.name'); + ResponseScenariosController::$definitions = ['show' => new ResponseScenario(name: 'Mismatch', expected_status: 404, config: ['app.name' => 'Temporary'])]; + + try { + ($this->strategy())($this->endpoint('GET', 'show')); + $this->fail('A mismatched status must fail generation.'); + } catch (UnexpectedValueException $exception) { + $this->assertSame('The Scribe scenario "Mismatch" expected HTTP 404 but received HTTP 200.', $exception->getMessage()); + } + + $this->assertSame($original_name, config('app.name')); + } + + #[Test] + #[DataProvider('invalidDefinitions')] + public function it_rejects_invalid_provider_definitions(mixed $definitions, string $message): void + { + ResponseScenariosController::$definitions = $definitions; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ($this->strategy())($this->endpoint('GET', 'show')); + } + + /** @return array */ + public static function invalidDefinitions(): array + { + return [ + 'provider return type' => ['invalid', 'must return an array'], + 'action return type' => [['show' => 'invalid'], 'must be a ResponseScenario or an array'], + 'scenario type' => [['show' => ['invalid']], 'must be an instance of'], + ]; + } + + #[Test] + #[DataProvider('invalidSetups')] + public function it_rejects_invalid_setups_and_restores_the_open_transaction(mixed $setup, string $message): void + { + $this->runMigrations(); + $original_name = config('app.name'); + ResponseScenariosController::$definitions = ['products' => new ResponseScenario( + name: 'Invalid setup', + setup: [CreateProductSetup::class, $setup], + setup_data: ['name' => 'Apple'], + config: ['app.name' => 'Temporary'], + )]; + + try { + ($this->strategy([config('database.default')]))($this->endpoint('GET', 'products')); + $this->fail('An invalid setup must fail generation.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString($message, $exception->getMessage()); + } + + $this->assertSame($original_name, config('app.name')); + $this->assertSame(0, DB::connection()->transactionLevel()); + $this->assertDatabaseCount('products', 0); + } + + /** @return array */ + public static function invalidSetups(): array + { + return [ + 'empty class string' => ['', 'must be a non-empty class-string'], + 'non-string class' => [123, 'must be a non-empty class-string'], + 'wrong contract' => [\stdClass::class, 'must implement'], + ]; + } + + #[Test] + public function it_fails_when_an_explicit_response_call_cannot_be_generated(): void + { + Globals::$__afterResponseCall = static function (): void { + throw new RuntimeException('Failed post-request hook'); + }; + ResponseScenariosController::$definitions = ['show' => new ResponseScenario(name: 'Failed response')]; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Scribe could not generate the "Failed response" response scenario'); + + ($this->strategy())($this->endpoint('GET', 'show')); + } + + /** @param list $connections */ + private function strategy(array $connections = []): ResponseScenarioCalls + { + // Telling the strategy **which database connections to wrap in transactions**. + // Each response call starts a transaction on those connections and rolls it back afterward, + // undoing records created by the endpoint or its scenario setups. + // An empty array means Scribe opens no database transactions. + return new ResponseScenarioCalls(new DocumentationConfig(['database_connections_to_transact' => $connections])); + } + + private function endpoint(string $method, string $action, string $uri = '/scenarios'): ExtractedEndpointData + { + $route = Route::match([$method], $uri, [ResponseScenariosController::class, $action]); + + return ExtractedEndpointData::fromRoute($route, ['headers' => ['Accept' => 'application/json', 'Content-Type' => 'application/json']]); + } + + /** + * @param array $response + * @return array + */ + private function body(array $response): array + { + return json_decode($response['content'], true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Scribe/Setups/CreateProductSetup.php b/tests/Scribe/Setups/CreateProductSetup.php new file mode 100644 index 0000000..ed009d1 --- /dev/null +++ b/tests/Scribe/Setups/CreateProductSetup.php @@ -0,0 +1,19 @@ +create(['name' => $scenario->setup_data['name']]); + } +} diff --git a/tests/Scribe/Setups/RecordScenarioSetup.php b/tests/Scribe/Setups/RecordScenarioSetup.php new file mode 100644 index 0000000..f7f8ea6 --- /dev/null +++ b/tests/Scribe/Setups/RecordScenarioSetup.php @@ -0,0 +1,22 @@ +attributes->set('setups', [ + ...$request->attributes->get('setups', []), + $scenario->setup_data['marker'], + ]); + $request->headers->set('Authorization', 'Bearer setup-token'); + } +} From 0a27a6a8cefced6981c72a37bd7a7173a61df46d Mon Sep 17 00:00:00 2001 From: Naffah Abdulla Rasheed Date: Mon, 5 Oct 2026 17:54:59 +0500 Subject: [PATCH 2/2] fix: spread operator causing error in tests for other php versions --- .../Feature/Scribe/Strategies/ResponseScenarioCallsTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php b/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php index 6f7dbb7..2f60f45 100644 --- a/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php +++ b/tests/Feature/Scribe/Strategies/ResponseScenarioCallsTest.php @@ -180,7 +180,10 @@ public function it_generates_repeatable_named_examples_through_scribes_openapi_w for ($generation = 0; $generation < 2; $generation++) { $responses = $strategy($endpoint); $output = new OutputEndpointData([ - ...$endpoint->forSerialisation(), + 'uri' => $endpoint->uri, + 'httpMethods' => $endpoint->httpMethods, + 'metadata' => $endpoint->metadata, + 'headers' => $endpoint->headers, 'responses' => $responses, ]); $specs[] = $writer->generateSpecContent([