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,
) {}
}
53 changes: 53 additions & 0 deletions src/Attributes/Tag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Dedoc\Scramble\Attributes;

use Attribute;

/**
* Declares a tag without putting the annotated endpoints in it.
*
* `Group` states where an endpoint belongs; this states what a tag is. It exists
* for the tag nothing else declares: a parent named by `Group::$parent` holds no
* endpoints of its own, so without this it reaches the document as a bare name.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Tag
{
public function __construct(
public readonly string $name,
public readonly ?string $description = null,

/**
* Determines the ordering of the tags. Tags with the same weight, are sorted
* by the name (with `SORT_LOCALE_STRING` sorting flag).
*/
public readonly int $weight = PHP_INT_MAX,

/**
* The name of a tag this one is nested under, so a parent may itself have a parent.
*/
public readonly ?string $parent = null,

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

/**
* A machine-readable category for the tag. Any string is allowed;
* the spec names `nav`, `badge` and `audience` as common values.
*/
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,
) {}
}
82 changes: 80 additions & 2 deletions src/DocumentTransformers/AddDocumentTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace Dedoc\Scramble\DocumentTransformers;

use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\Tag as TagAttribute;
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 All @@ -14,14 +16,15 @@ class AddDocumentTags implements DocumentTransformer
{
public function handle(OpenApi $document, OpenApiContext $context): void
{
$document->tags = $this->makeTagsFromGroupAttributes($context->groups);
$document->tags = $this->makeTagsFromGroupAttributes($context->groups, $context->tags);
}

/**
* @param Collection<int, ReflectionAttribute<Group>> $groupsAttributes
* @param Collection<int, ReflectionAttribute<TagAttribute>> $tagAttributes
* @return Tag[]
*/
private function makeTagsFromGroupAttributes(Collection $groupsAttributes)
private function makeTagsFromGroupAttributes(Collection $groupsAttributes, Collection $tagAttributes = new Collection)
{
/** @var Collection<string, Tag> $tags */
$tags = $groupsAttributes->reduce(function (Collection $acc, ReflectionAttribute $attribute) {
Expand All @@ -47,11 +50,86 @@ private function makeTagsFromGroupAttributes(Collection $groupsAttributes)
$tag->setAttribute('weight', $weight);
}

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

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

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

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

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

return $acc;
}, collect());

$this->applyDeclaredTags($tags, $tagAttributes);

$this->addMissingParentTags($tags);

return $tags->sortBy(fn (Tag $t) => $t->getAttribute('weight', INF))->values()->all();
}

/**
* Applies `Tag` attributes, which describe a tag without claiming any endpoint.
*
* A group of the same name wins on every field it already states, so declaring a
* tag never overrides where an endpoint says it belongs.
*
* @param Collection<string, Tag> $tags
* @param Collection<int, ReflectionAttribute<TagAttribute>> $tagAttributes
*/
private function applyDeclaredTags(Collection $tags, Collection $tagAttributes): void
{
foreach ($tagAttributes as $attribute) {
$declaration = $attribute->newInstance();

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

$tag->description ??= $declaration->description;
$tag->parent ??= $declaration->parent;
$tag->summary ??= $declaration->summary;
$tag->kind ??= $declaration->kind;

if ($declaration->weight !== PHP_INT_MAX && $tag->getAttribute('weight') === null) {
$tag->setAttribute('weight', $declaration->weight);
}

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

$tags->offsetSet($declaration->name, $tag);
}
}

/**
* A tag naming a parent nothing declares would leave the document invalid: the
* spec requires the named parent to exist in the API description.
*
* @param Collection<string, Tag> $tags
*/
private function addMissingParentTags(Collection $tags): void
{
$tags->pluck('parent')
->filter()
->unique()
->reject(fn (string $parent): bool => $tags->has($parent))
->each(fn (string $parent) => $tags->offsetSet($parent, new Tag($parent)));
}
}
2 changes: 1 addition & 1 deletion src/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,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
5 changes: 5 additions & 0 deletions src/OpenApiContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Dedoc\Scramble;

use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\Tag;
use Dedoc\Scramble\Diagnostics\DiagnosticsCollector;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\ProNudge\ProNudgeCollector;
Expand All @@ -19,6 +20,10 @@ public function __construct(
* @var Collection<int, ReflectionAttribute<Group>>
*/
public Collection $groups = new Collection,
/**
* @var Collection<int, ReflectionAttribute<Tag>>
*/
public Collection $tags = new Collection,
public DiagnosticsCollector $diagnostics = new DiagnosticsCollector,
public ProNudgeCollector $proNudge = new ProNudgeCollector,
) {}
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
25 changes: 25 additions & 0 deletions src/Support/OperationExtensions/RequestEssentialsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Dedoc\Scramble\Attributes\Endpoint;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\Tag as TagAttribute;
use Dedoc\Scramble\Extensions\OperationExtension;
use Dedoc\Scramble\GeneratorConfig;
use Dedoc\Scramble\Infer;
Expand Down Expand Up @@ -218,6 +219,28 @@ private function getOperationId(RouteInfo $routeInfo)
/**
* @return ReflectionAttribute<Group>[]
*/
/**
* Collects `Tag` attributes, which declare a tag without claiming the endpoint.
*
* A parent tag holds no endpoints of its own, so nothing would otherwise carry
* its description into the document.
*/
private function collectDeclaredTags(RouteInfo $routeInfo): void
{
$reflection = $routeInfo->reflectionAction();

$attributes = [
...($reflection?->getAttributes(TagAttribute::class, ReflectionAttribute::IS_INSTANCEOF) ?? []),
...($reflection instanceof ReflectionMethod
? $reflection->getDeclaringClass()->getAttributes(TagAttribute::class, ReflectionAttribute::IS_INSTANCEOF)
: []),
];

foreach ($attributes as $attribute) {
$this->openApiContext->tags->push($attribute);
}
}

private function getTagsAnnotatedByGroups(RouteInfo $routeInfo): array
{
$reflection = $routeInfo->reflectionAction();
Expand All @@ -234,6 +257,8 @@ private function getTagsAnnotatedByGroups(RouteInfo $routeInfo): array

private function attachTagsToOpenApi(RouteInfo $routeInfo): void
{
$this->collectDeclaredTags($routeInfo);

if (! $groups = $this->getTagsAnnotatedByGroups($routeInfo)) {
return;
}
Expand Down
Loading