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
65 changes: 65 additions & 0 deletions docs/guide/extension-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,71 @@ registration order.
The enum is `OpenApi\Augmenter\Group`. The [Augmenters
reference](/reference/augmenters) lists the built-in pipeline and what each phase is for.

## Mergers

Two attributes can claim one key. Two operations on the same path and method, two schemas named
`Pet` — a scan finds one, a `withSpecification()` hook contributes the other, an inheritance
clone makes a third. Something has to decide which of them the document holds, and until it
does the compiler decides by accident: it writes each into a PHP array and keeps whichever it
wrote last.

`Augmenter\Merge` decides instead, through a chain of mergers. A merger says what makes two
attributes the same one and what the survivor is — below, an operation the scan already
described keeps the key against one a hook contributed and marked as its own:

```php
use OpenApi\Contracts\AttributeInterface;
use OpenApi\Contracts\MergerInterface;
use OpenApi\Spec as OA;

final class MyOperationMerger implements MergerInterface
{
public function supports(string $class): bool
{
return is_a($class, OA\Operation::class, true);
}

public function identity(AttributeInterface $attribute): ?string
{
return $attribute->path !== null && $attribute->method !== null
? $attribute->method . ' ' . $attribute->path
: null;
}

public function merge(AttributeInterface $earlier, AttributeInterface $later): AttributeInterface
{
return $later->getMeta(self::class, false) ? $earlier : $later;
}
}
```

`Builder::withMergers()` registers it. They are tried in order and the first to claim a type
handles it, so `Merge\LastWins` — which claims everything, keeps the later entry and warns with
both locations — ships last:

```php
use OpenApi\Merge;
use OpenApi\Utils\TypedList;

$builder->withMergers(fn (TypedList $mergers) => $mergers->insert(
new MyOperationMerger(),
Merge\LastWins::class,
));
```

`identity()` returning `null` means the attribute never merges and passes through: that is how
servers and security requirements stay as they are, being positional rather than keyed.

`$earlier` and `$later` are in producer order, which is the only thing the pipeline guarantees —
`return $later` is last-wins. Precedence beyond that order is a policy the core does not hold. A
package that needs to recognise its own attributes marks them as it creates them —
`$operation->setMeta(MyOperationMerger::class, true)` — and reads that back in `merge()`. `meta`
is keyed by whoever writes to it; nothing in swagger-php writes or reads it.

The pass runs over the `Specification`'s own collections, where the halves come from different
places. A duplicate key *inside* one attribute — two `200` responses in one operation — is two
entries one author wrote in one place, and the compiler is left to keep the last of them.

## Compilers

`Builder::setCompiler()` replaces the compiler that turns the `Specification` into a
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/augmenters.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ Resolves FQCN-based $ref values to JSON Reference paths.
Builds a map of class names to their component paths and rewrites
any $ref that looks like a FQCN into the proper #/components/... path.

### [Merge](https://github.com/zircote/swagger-php/tree/master/src/Augmenter/Merge.php)

Reduces every root collection to one entry per key, through the registered mergers.

Two attributes can claim one key — two operations on the same path and method, two schemas
named `Pet`. The merger that claims the type says what identifies it and what the survivor is,
and one entry per key reaches the compiler.

Only the root collections, because that is where the halves come from different places — a
scan, a `withSpecification()` hook, the resolver, an inheritance clone — and something has to
decide between them. A collision *inside* one attribute is two entries the same author wrote
in one place; the compiler reports it and keeps its own last-write-wins.

Registered twice, and both are this class. The first run is the first pipe of the **reduce**
phase, which is the earliest point every identity exists — `Augmenter\PathItems` resolves an
operation's path and `Augmenter\Names` infers component keys, both in **resolve** — and it is
before `Cleanup` and everything downstream that should see the survivor rather than both
halves. The second is the last pipe of all, so what a late augmenter adds is reduced too;
it is a grouping over lists and costs nothing when there is nothing to do. An augmenter
registered after it runs after it; `insert()` is how to land ahead.

### [PathFilter](https://github.com/zircote/swagger-php/tree/master/src/Augmenter/PathFilter.php)

Filters operations by tag and/or path patterns.
Expand Down
29 changes: 29 additions & 0 deletions docs/reference/builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,35 @@ $builder->withResolver(function (Resolver $resolver) {

Resolvers implement `OpenApi\Contracts\ResolverInterface` and receive the FQCN and the `Assembler` in use. The first one to return `true` claims the FQCN. See the [Resolver section](/reference/architecture#resolver) in the architecture docs for details, including how to reorder or clear the chain.

### Merger configuration (spec/hybrid mode) {#mergers}

Mergers decide what makes two attributes the same one and which of them the document holds.
`Augmenter\Merge` applies them to the `Specification`'s collections, first in the **reduce**
phase and again as the last pipe, so a key claimed twice reaches the compiler once.

`Merge\LastWins` is registered by default: it claims every type, keys each collection the way
the document does — component key, path and method, webhook and method, tag name — and on a
collision keeps the later entry and warns with both locations. Positional lists, `servers` and
`security`, have no key and are left alone.

Use `withMergers()` to add your own. The hook runs when called; repeated calls configure the
same list:

```php
use OpenApi\Merge;
use OpenApi\Utils\TypedList;

$builder->withMergers(fn (TypedList $mergers) => $mergers->insert(
new MyOperationMerger(),
Merge\LastWins::class,
));
```

Mergers implement `OpenApi\Contracts\MergerInterface` and are tried in registration order; the
first to `supports()` a type claims it, so a merger for one type goes ahead of the catch-all.
`insert()` places it there. See the [Mergers section](/guide/extension-points#mergers) in the
extension points guide for what a merger looks like and how it recognises its own attributes.

### Attribute factory configuration (spec mode) {#attribute-factory}

Use `withAttributeFactory()` to add custom attribute translators. The hook runs when called;
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/extension-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,18 @@ is enough as long as everything it references (directly or transitively) carries

A FQCN is considered resolved if the specification knows it once collected. Classes without any
spec attributes are left to the next resolver in the chain.

## Default Mergers

### [LastWins](https://github.com/zircote/swagger-php/tree/master/src/Merge/LastWins.php)

The catch-all merger: one entry per key survives, the later one, and the author is told.

It reduces rather than folds: two entries in, one out, so the compiler never sees a collision
and its own accidental rules stop deciding anything. Combining fields from both halves is a
type-specific merger's job, registered ahead of this one.

Identity by collection: the component key for anything in a `components` bucket, path and
method for an operation, webhook and method for a webhook operation, path for a path-bound
path item, name for a tag. Servers, security requirements and external documentation are
positional — their entries have no identity, duplicates are legal, and they pass through.
126 changes: 126 additions & 0 deletions src/Augmenter/Merge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php declare(strict_types=1);

/**
* @license Apache 2.0
*/

namespace OpenApi\Augmenter;

use OpenApi\Contracts\AttributeInterface;
use OpenApi\Contracts\MergerInterface;
use OpenApi\Specification;
use OpenApi\Utils\PipeInterface;
use OpenApi\Utils\TypedList;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\LoggerInterface;

/**
* Reduces every root collection to one entry per key, through the registered mergers.
*
* Two attributes can claim one key — two operations on the same path and method, two schemas
* named `Pet`. The merger that claims the type says what identifies it and what the survivor is,
* and one entry per key reaches the compiler.
*
* Only the root collections, because that is where the halves come from different places — a
* scan, a `withSpecification()` hook, the resolver, an inheritance clone — and something has to
* decide between them. A collision *inside* one attribute is two entries the same author wrote
* in one place; the compiler reports it and keeps its own last-write-wins.
*
* Registered twice, and both are this class. The first run is the first pipe of the **reduce**
* phase, which is the earliest point every identity exists — `Augmenter\PathItems` resolves an
* operation's path and `Augmenter\Names` infers component keys, both in **resolve** — and it is
* before `Cleanup` and everything downstream that should see the survivor rather than both
* halves. The second is the last pipe of all, so what a late augmenter adds is reduced too;
* it is a grouping over lists and costs nothing when there is nothing to do. An augmenter
* registered after it runs after it; `insert()` is how to land ahead.
*
* @implements PipeInterface<Specification>
*/
class Merge implements PipeInterface, LoggerAwareInterface
{
use LoggerAwareTrait;

/**
* @param TypedList<MergerInterface> $mergers
*/
public function __construct(
protected TypedList $mergers,
protected Group $group = Group::Reduce,
) {
}

public function __invoke(mixed $payload): mixed
{
if ($this->logger instanceof LoggerInterface) {
foreach ($this->mergers as $merger) {
if ($merger instanceof LoggerAwareInterface) {
$merger->setLogger($this->logger);
}
}
}

// every root collection, discovered rather than listed: a new bucket on the
// `Specification` is reduced without this pass being told about it
foreach (get_object_vars($payload) as $property => $value) {
if (is_array($value)) {
$payload->{$property} = $this->reduce($value);
}
}

return null;
}

public function group(): string|\BackedEnum
{
return $this->group;
}

/**
* @param array<mixed> $attributes
* @return list<mixed>
*/
protected function reduce(array $attributes): array
{
if (count($attributes) < 2) {
return array_values($attributes);
}

$reduced = [];
$slots = [];

foreach ($attributes as $attribute) {
$merger = $attribute instanceof AttributeInterface ? $this->mergerFor($attribute) : null;
$identity = $merger?->identity($attribute);

if (!$merger instanceof MergerInterface || $identity === null) {
$reduced[] = $attribute;
continue;
}

// two mergers never fold into each other: whoever claimed the type decides
$key = $merger::class . "\0" . $identity;

if (!array_key_exists($key, $slots)) {
$slots[$key] = count($reduced);
$reduced[] = $attribute;
continue;
}

$reduced[$slots[$key]] = $merger->merge($reduced[$slots[$key]], $attribute);
}

return $reduced;
}

protected function mergerFor(AttributeInterface $attribute): ?MergerInterface
{
foreach ($this->mergers as $merger) {
if ($merger->supports($attribute::class)) {
return $merger;
}
}

return null;
}
}
Loading
Loading