Skip to content
Open
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
26 changes: 26 additions & 0 deletions src/Attributes/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,31 @@ public function __construct(
* by the name (with `SORT_LOCALE_STRING` sorting flag).
*/
public readonly int $weight = PHP_INT_MAX,

/**
* The parent tag name. Used to organize tags into hierarchical groups
* via the native OpenAPI 3.2.0 Tag Object `parent` field.
*/
public readonly ?string $parent = null,

/**
* A short summary of the tag.
*/
public readonly ?string $summary = null,

/**
* The kind of tag: "navigation" (default) or "api".
*/
public readonly ?string $kind = null,

/**
* URL for additional external documentation for this tag.
*/
public readonly ?string $externalDocsUrl = null,

/**
* Description of the external documentation for this tag.
*/
public readonly ?string $externalDocsDescription = null,
) {}
}
47 changes: 45 additions & 2 deletions src/DocumentTransformers/AddDocumentTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\ExternalDocumentation;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Tag;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -35,9 +36,18 @@ private function makeTagsFromGroupAttributes(Collection $groupsAttributes)

$description = $arguments['description'] ?? $arguments[1] ?? null;
$weight = $arguments['weight'] ?? $arguments[2] ?? null;
$parent = $arguments['parent'] ?? $arguments[3] ?? null;
$summary = $arguments['summary'] ?? $arguments[4] ?? null;
$kind = $arguments['kind'] ?? $arguments[5] ?? null;
$externalDocsUrl = $arguments['externalDocsUrl'] ?? $arguments[6] ?? null;
$externalDocsDescription = $arguments['externalDocsDescription'] ?? $arguments[7] ?? null;

// Use combination of name + parent as unique key to allow same tag name
// under different parents (hierarchical groups)
$tagKey = $parent ? "{$parent}/{$name}" : $name;

/** @var Tag $tag */
$tag = $acc->get($name, new Tag($name));
$tag = $acc->get($tagKey, new Tag($name));

if ($description !== null && $tag->description === null) {
$tag->description = $description;
Expand All @@ -47,11 +57,44 @@ private function makeTagsFromGroupAttributes(Collection $groupsAttributes)
$tag->setAttribute('weight', $weight);
}

$acc->offsetSet($name, $tag);
if ($parent !== null && $tag->parent === null) {
$tag->parent = $parent;
}

if ($summary !== null && $tag->summary === null) {
$tag->summary = $summary;
}

if ($kind !== null && $tag->kind === null) {
$tag->kind = $kind;
}

if ($externalDocsUrl !== null && $tag->externalDocs === null) {
$tag->externalDocs = new ExternalDocumentation(
url: $externalDocsUrl,
description: $externalDocsDescription,
);
}

$acc->offsetSet($tagKey, $tag);

return $acc;
}, collect());

// Auto-create any missing parent tags referenced by child tags
$parentNames = $tags->filter(fn (Tag $tag) => $tag->parent !== null)
->map(fn (Tag $tag) => $tag->parent)
->unique()
->values();

$existingTagNames = $tags->map(fn (Tag $tag) => $tag->name)->values();

foreach ($parentNames as $parentName) {
if (! $existingTagNames->contains($parentName)) {
$tags->offsetSet($parentName, new Tag($parentName));
}
}

return $tags->sortBy(fn (Tag $t) => $t->getAttribute('weight', INF))->values()->all();
}
}
2 changes: 1 addition & 1 deletion src/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ private function createOperationsSorter(): array

private function makeOpenApi(GeneratorConfig $config)
{
$openApi = OpenApi::make('3.1.0')
$openApi = OpenApi::make('3.2.0')
->setComponents(new Components)
->setInfo(
InfoObject::make($config->get('ui.title', $default = config('app.name')) ?: $default)
Expand Down
19 changes: 19 additions & 0 deletions src/Support/Generator/ExternalDocumentation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Dedoc\Scramble\Support\Generator;

class ExternalDocumentation
{
public function __construct(
public string $url,
public ?string $description = null,
) {}

public function toArray(): array
{
return array_filter([
'description' => $this->description,
'url' => $this->url,
]);
}
}
4 changes: 4 additions & 0 deletions src/Support/Generator/OpenApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

class OpenApi
{
use WithExtensions;

public string $version;

public InfoObject $info;
Expand Down Expand Up @@ -126,6 +128,8 @@ public function toArray()
$result['components'] = $serializedComponents;
}

$result = array_merge($result, $this->extensionPropertiesToArray());

return $result;
}
}
12 changes: 12 additions & 0 deletions src/Support/Generator/Tag.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ class Tag
use WithAttributes;
use WithExtensions;

public ?string $summary = null;

public ?string $parent = null;

public ?string $kind = null;

public ?ExternalDocumentation $externalDocs = null;

public function __construct(
public string $name,
public ?string $description = null,
Expand All @@ -17,6 +25,10 @@ public function toArray(): mixed
$result = array_filter([
'name' => $this->name,
'description' => $this->description,
'summary' => $this->summary,
'parent' => $this->parent,
'kind' => $this->kind,
'externalDocs' => $this->externalDocs?->toArray(),
]);

return array_merge(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"openapi": "3.1.0",
"openapi": "3.2.0",
"info": {
"title": "Laravel",
"version": "0.0.1"
Expand Down
190 changes: 190 additions & 0 deletions tests/Attributes/GroupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,193 @@ class GroupTest_C4_Controller
{
public function __invoke() {}
}

it('sets native parent field on tags with parent parameter', function () {
RouteFacade::get('api/users', GroupTest_Users_Controller::class);
RouteFacade::get('api/profiles', GroupTest_Profiles_Controller::class);

Scramble::routes(fn (Route $r) => in_array($r->uri, ['api/users', 'api/profiles']));

$openApiDoc = app()->make(Generator::class)();

expect($openApiDoc)->not->toHaveKey('x-tagGroups')
->and(collect($openApiDoc['tags'])->firstWhere('name', 'users'))->toMatchArray([
'name' => 'users',
'parent' => 'User Management',
])
->and(collect($openApiDoc['tags'])->firstWhere('name', 'profiles'))->toMatchArray([
'name' => 'profiles',
'parent' => 'User Management',
]);
});
#[Group(name: 'users', parent: 'User Management', weight: 1)]
class GroupTest_Users_Controller
{
public function __invoke() {}
}
#[Group(name: 'profiles', parent: 'User Management', weight: 2)]
class GroupTest_Profiles_Controller
{
public function __invoke() {}
}

it('auto-creates missing parent tags', function () {
RouteFacade::get('api/users', GroupTest_Users_Controller::class);
RouteFacade::get('api/profiles', GroupTest_Profiles_Controller::class);

Scramble::routes(fn (Route $r) => in_array($r->uri, ['api/users', 'api/profiles']));

$openApiDoc = app()->make(Generator::class)();

$tagNames = array_column($openApiDoc['tags'], 'name');

expect($tagNames)->toContain('User Management');
});

it('sets native parent field with multiple groups and correct ordering', function () {
RouteFacade::get('api/products', GroupTest_Products_Controller::class);
RouteFacade::get('api/users2', GroupTest_Users2_Controller::class);
RouteFacade::get('api/profiles2', GroupTest_Profiles2_Controller::class);

Scramble::routes(fn (Route $r) => in_array($r->uri, ['api/products', 'api/users2', 'api/profiles2']));

$openApiDoc = app()->make(Generator::class)();

expect($openApiDoc)->not->toHaveKey('x-tagGroups')
->and(collect($openApiDoc['tags'])->firstWhere('name', 'products'))->toMatchArray([
'name' => 'products',
'parent' => 'Products',
])
->and(collect($openApiDoc['tags'])->firstWhere('name', 'users2'))->toMatchArray([
'name' => 'users2',
'parent' => 'User Management',
])
->and(collect($openApiDoc['tags'])->firstWhere('name', 'profiles2'))->toMatchArray([
'name' => 'profiles2',
'parent' => 'User Management',
]);
});
#[Group(name: 'products', parent: 'Products', weight: 0)]
class GroupTest_Products_Controller
{
public function __invoke() {}
}
#[Group(name: 'users2', parent: 'User Management', weight: 1)]
class GroupTest_Users2_Controller
{
public function __invoke() {}
}
#[Group(name: 'profiles2', parent: 'User Management', weight: 2)]
class GroupTest_Profiles2_Controller
{
public function __invoke() {}
}

it('does not set parent field when no tags have parent', function () {
RouteFacade::get('api/simple', GroupTest_Simple_Controller::class);

Scramble::routes(fn (Route $r) => $r->uri === 'api/simple');

$openApiDoc = app()->make(Generator::class)();

$tag = collect($openApiDoc['tags'])->firstWhere('name', 'simple');

expect($tag)->not->toHaveKey('parent')
->and($openApiDoc)->not->toHaveKey('x-tagGroups');
});
#[Group(name: 'simple')]
class GroupTest_Simple_Controller
{
public function __invoke() {}
}

it('includes ungrouped tags without parent field alongside grouped tags', function () {
RouteFacade::get('api/grouped', GroupTest_Grouped_Controller::class);
RouteFacade::get('api/ungrouped', GroupTest_Ungrouped_Controller::class);

Scramble::routes(fn (Route $r) => in_array($r->uri, ['api/grouped', 'api/ungrouped']));

$openApiDoc = app()->make(Generator::class)();

$groupedTag = collect($openApiDoc['tags'])->firstWhere('name', 'grouped');
$ungroupedTag = collect($openApiDoc['tags'])->firstWhere('name', 'ungrouped');

expect($groupedTag)->toMatchArray([
'name' => 'grouped',
'parent' => 'My Group',
])
->and($ungroupedTag)->not->toHaveKey('parent')
->and($openApiDoc)->not->toHaveKey('x-tagGroups');
});
#[Group(name: 'grouped', parent: 'My Group')]
class GroupTest_Grouped_Controller
{
public function __invoke() {}
}
#[Group(name: 'ungrouped')]
class GroupTest_Ungrouped_Controller
{
public function __invoke() {}
}

it('sets summary field on tag', function () {
$openApiDoc = generateForRoute(fn () => RouteFacade::get('api/summary-test', GroupTest_Summary_Controller::class));

expect($openApiDoc['tags'][0])->toMatchArray([
'name' => 'Summarized',
'description' => 'Full description',
'summary' => 'Short summary',
]);
});
#[Group(name: 'Summarized', description: 'Full description', summary: 'Short summary')]
class GroupTest_Summary_Controller
{
public function __invoke() {}
}

it('sets kind field on tag', function () {
$openApiDoc = generateForRoute(fn () => RouteFacade::get('api/kind-test', GroupTest_Kind_Controller::class));

expect($openApiDoc['tags'][0])->toMatchArray([
'name' => 'ApiTag',
'kind' => 'api',
]);
});
#[Group(name: 'ApiTag', kind: 'api')]
class GroupTest_Kind_Controller
{
public function __invoke() {}
}

it('sets externalDocs on tag', function () {
$openApiDoc = generateForRoute(fn () => RouteFacade::get('api/extdocs-test', GroupTest_ExternalDocs_Controller::class));

expect($openApiDoc['tags'][0])->toMatchArray([
'name' => 'Documented',
'externalDocs' => [
'description' => 'More info',
'url' => 'https://example.com/docs',
],
]);
});
#[Group(name: 'Documented', externalDocsUrl: 'https://example.com/docs', externalDocsDescription: 'More info')]
class GroupTest_ExternalDocs_Controller
{
public function __invoke() {}
}

it('sets externalDocs on tag with url only', function () {
$openApiDoc = generateForRoute(fn () => RouteFacade::get('api/extdocs-url-test', GroupTest_ExternalDocsUrl_Controller::class));

expect($openApiDoc['tags'][0])->toMatchArray([
'name' => 'UrlOnly',
'externalDocs' => [
'url' => 'https://example.com/docs',
],
]);
});
#[Group(name: 'UrlOnly', externalDocsUrl: 'https://example.com/docs')]
class GroupTest_ExternalDocsUrl_Controller
{
public function __invoke() {}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.2.0
info:
title: Laravel
version: 0.0.1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.2.0
info:
title: Laravel
version: 0.0.1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.2.0
info:
title: Laravel
version: 0.0.1
Expand Down
Loading
Loading