Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions docs/generating-api-docs/setting-up-scribe.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.<status>.content.<media-type>.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.
Expand Down Expand Up @@ -92,4 +226,3 @@ php artisan scribe:generate

And your API docs will be magically created with sensible documentation.


51 changes: 51 additions & 0 deletions src/Scribe/Attributes/ResponseScenario.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Javaabu\QueryBuilder\Scribe\Attributes;

use Attribute;
use Javaabu\QueryBuilder\Scribe\Contracts\ResponseScenarioSetup;

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class ResponseScenario
{
/**
* Using a class string means this works with both attributes and apiDocScenarios().
*
* @param array<string, scalar|null> $url
* @param array<string, mixed> $body
* @param array<string, mixed> $query
* @param array<string, string> $files
* @param array<string, string> $cookies
* @param array<string, mixed> $config
* @param class-string<ResponseScenarioSetup>|list<class-string<ResponseScenarioSetup>>|null $setup
* @param array<string, mixed> $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<class-string<ResponseScenarioSetup>>
*/
public function setups(): array
{
return match (true) {
$this->setup === null => [],
is_string($this->setup) => [$this->setup],
default => array_values($this->setup),
};
}
}
18 changes: 18 additions & 0 deletions src/Scribe/Contracts/ResponseScenarioSetup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Javaabu\QueryBuilder\Scribe\Contracts;

use Illuminate\Http\Request;
use Javaabu\QueryBuilder\Scribe\Attributes\ResponseScenario;
use Knuckles\Camel\Extraction\ExtractedEndpointData;

interface ResponseScenarioSetup
{
public function __invoke(
Request $request,
ExtractedEndpointData $endpoint_data,
ResponseScenario $scenario,
): void;
}
163 changes: 163 additions & 0 deletions src/Scribe/ResponseExamplesOpenApiGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

namespace Javaabu\QueryBuilder\Scribe;

use Illuminate\Support\Str;
use Knuckles\Camel\Extraction\Response;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator;

final class ResponseExamplesOpenApiGenerator extends OpenApiGenerator
{
/**
* Convert responses sharing the same status code into named OpenAPI
* examples so external UIs such as Scalar can display every scenario.
*
* @param array<int, array{
* description: string,
* name: string,
* endpoints: OutputEndpointData[]
* }> $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, '<<binary>>')
) {
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<string, mixed> $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<string, mixed> $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;
}
}
Loading
Loading