Repository: dedoc/scramble (php)

Issue:
Additional collection’s data: Not documenting
According to the docs, Additional collection data will automatically be documented when adding additional data to a collection.

```php
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use App\Models\TodoItem;
use Illuminate\Pagination\LengthAwarePaginator;

class TodoItemsController
{
    public function index(Request $request)
    {
        return TodoItemResource::collection(TodoItem::all())
            ->additional(['permissions' => true]);
    }
}
```

For my setup, (Laravel 10 with PHP 8.1) this is not working. 

I'm using pagination, but I also tried without.

This is my method:
```php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\CompanyStoreRequest;
use App\Http\Requests\CompanyUpdateRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Validation\ValidationException;

class CompanyController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @param Request $request \
     * @return AnonymousResourceCollection<LengthAwarePaginator<CompanyResource>>
     * @throws AuthorizationException
     * @throws ValidationException
     */
    public function index(Request $request): AnonymousResourceCollection
    {
        $this->authorize('viewAny', Company::class);

        $this->validate($request, [
            'search' => 'nullable|string'
        ]);

        return CompanyResource::collection(
            Company::search($request->input('search'))
                ->paginate(50))
            ->additional(['search' => $request->input('search')]);
    }
}
```

Pull request description:
Support for `additional` on anonymous collections
closes #311 

Patch:
diff --git a/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php b/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php
index e939c88..d4a23d8 100644
--- a/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php
+++ b/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php
@@ -7,7 +7,9 @@ use Dedoc\Scramble\Support\Generator\Response;
 use Dedoc\Scramble\Support\Generator\Schema;
 use Dedoc\Scramble\Support\Generator\Types\ArrayType as OpenApiArrayType;
 use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType;
+use Dedoc\Scramble\Support\Generator\Types\UnknownType;
 use Dedoc\Scramble\Support\Type\Generic;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
 use Dedoc\Scramble\Support\Type\ObjectType;
 use Dedoc\Scramble\Support\Type\Type;
 use Dedoc\Scramble\Support\Type\TypeWalker;
@@ -16,6 +18,9 @@ use Illuminate\Http\Resources\Json\JsonResource;
 
 class AnonymousResourceCollectionTypeToSchema extends TypeToSchemaExtension
 {
+    use FlattensMergeValues;
+    use MergesOpenApiObjects;
+
     public function shouldHandle(Type $type)
     {
         return $type instanceof Generic
@@ -41,6 +46,11 @@ class AnonymousResourceCollectionTypeToSchema extends TypeToSchemaExtension
      */
     public function toResponse(Type $type)
     {
+        $additional = $type->templateTypes[1 /* TAdditional */] ?? new UnknownType();
+        if ($additional instanceof KeyedArrayType) {
+            $additional->items = $this->flattenMergeValues($additional->items);
+        }
+
         // In case of paginated resource, we want to get pagination response.
         if ($type->templateTypes[0] instanceof Generic && ! $type->templateTypes[0]->isInstanceOf(JsonResource::class)) {
             return $this->openApiTransformer->toResponse($type->templateTypes[0]);
@@ -51,14 +61,23 @@ class AnonymousResourceCollectionTypeToSchema extends TypeToSchemaExtension
         }
 
         $jsonResourceOpenApiType = $this->openApiTransformer->transform($collectingResourceType);
-        $responseWrapKey = AnonymousResourceCollection::$wrap;
 
-        $openApiType = $responseWrapKey
+        $shouldWrap = ($wrapKey = AnonymousResourceCollection::$wrap ?? null) !== null
+            || $additional instanceof KeyedArrayType;
+        $wrapKey = $wrapKey ?: 'data';
+
+        $openApiType = $shouldWrap
             ? (new OpenApiObjectType)
-                ->addProperty($responseWrapKey, (new OpenApiArrayType)->setItems($jsonResourceOpenApiType))
-                ->setRequired([$responseWrapKey])
+                ->addProperty($wrapKey, (new OpenApiArrayType)->setItems($jsonResourceOpenApiType))
+                ->setRequired([$wrapKey])
             : (new OpenApiArrayType)->setItems($jsonResourceOpenApiType);
 
+        if ($shouldWrap) {
+            if ($additional instanceof KeyedArrayType) {
+                $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($additional));
+            }
+        }
+
         return Response::make(200)
             ->description('Array of `'.$this->components->uniqueSchemaName($collectingResourceType->name).'`')
             ->setContent('application/json', Schema::fromType($openApiType));
diff --git a/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php b/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php
new file mode 100644
index 0000000..745d9df
--- /dev/null
+++ b/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Dedoc\Scramble\Support\TypeToSchemaExtensions;
+
+use Carbon\Carbon;
+use Dedoc\Scramble\Support\Type\ArrayItemType_;
+use Dedoc\Scramble\Support\Type\Generic;
+use Dedoc\Scramble\Support\Type\KeyedArrayType;
+use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType;
+use Dedoc\Scramble\Support\Type\StringType;
+use Dedoc\Scramble\Support\Type\Type;
+use Dedoc\Scramble\Support\Type\TypeWalker;
+use Dedoc\Scramble\Support\Type\Union;
+use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Http\Resources\MergeValue;
+use Illuminate\Http\Resources\MissingValue;
+
+trait FlattensMergeValues
+{
+    protected function flattenMergeValues(array $items)
+    {
+        return collect($items)
+            ->flatMap(function (ArrayItemType_ $item) {
+                if ($item->value instanceof KeyedArrayType) {
+                    $item->value->items = $this->flattenMergeValues($item->value->items);
+                    $item->value->isList = KeyedArrayType::checkIsList($item->value->items);
+
+                    return [$item];
+                }
+
+                if (
+                    $item->value instanceof Union
+                    && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(Carbon::class))
+                ) {
+                    (new TypeWalker)->replace($item->value, function (Type $t) {
+                        return $t->isInstanceOf(Carbon::class)
+                            ? tap(new StringType, fn ($t) => $t->setAttribute('format', 'date-time'))
+                            : null;
+                    });
+
+                    return [$item];
+                }
+
+                if ($item->value->isInstanceOf(JsonResource::class)) {
+                    $resource = $this->getResourceType($item->value);
+
+                    if ($resource->isInstanceOf(MissingValue::class)) {
+                        return [];
+                    }
+
+                    if (
+                        $resource instanceof Union
+                        && (new TypeWalker)->first($resource, fn (Type $t) => $t->isInstanceOf(MissingValue::class))
+                    ) {
+                        $item->isOptional = true;
+
+                        return [$item];
+                    }
+                }
+
+                if (
+                    $item->value instanceof Union
+                    && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(MissingValue::class))
+                ) {
+                    $newType = array_filter($item->value->types, fn (Type $t) => ! $t->isInstanceOf(MissingValue::class));
+
+                    if (! count($newType)) {
+                        return [];
+                    }
+
+                    $item->isOptional = true;
+
+                    if (count($newType) === 1) {
+                        $item->value = $newType[0];
+
+                        return $this->flattenMergeValues([$item]);
+                    }
+
+                    $item->value = new Union($newType);
+
+                    return $this->flattenMergeValues([$item]);
+                }
+
+                if (
+                    $item->value instanceof Generic
+                    && $item->value->isInstanceOf(MergeValue::class)
+                ) {
+                    $arrayToMerge = $item->value->templateTypes[1];
+
+                    // Second generic argument of the `MergeValue` class must be a keyed array.
+                    // Otherwise, we ignore it from the resulting array.
+                    if (! $arrayToMerge instanceof KeyedArrayType) {
+                        return [];
+                    }
+
+                    $arrayToMergeItems = $this->flattenMergeValues($arrayToMerge->items);
+
+                    $mergingArrayValuesShouldBeRequired = $item->value->templateTypes[0] instanceof LiteralBooleanType
+                        && $item->value->templateTypes[0]->value === true;
+
+                    if (! $mergingArrayValuesShouldBeRequired || $item->isOptional) {
+                        foreach ($arrayToMergeItems as $mergingItem) {
+                            $mergingItem->isOptional = true;
+                        }
+                    }
+
+                    return $arrayToMergeItems;
+                }
+
+                return [$item];
+            })
+            ->values()
+            ->all();
+    }
+}
diff --git a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
index cf75ae5..f7de50a 100644
--- a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
+++ b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php
@@ -2,32 +2,26 @@
 
 namespace Dedoc\Scramble\Support\TypeToSchemaExtensions;
 
-use Carbon\Carbon;
 use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
 use Dedoc\Scramble\Support\Generator\Reference;
 use Dedoc\Scramble\Support\Generator\Response;
 use Dedoc\Scramble\Support\Generator\Schema;
-use Dedoc\Scramble\Support\Generator\Types as OpenApiTypes;
 use Dedoc\Scramble\Support\Generator\Types\UnknownType;
 use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer;
-use Dedoc\Scramble\Support\Type\ArrayItemType_;
 use Dedoc\Scramble\Support\Type\ArrayType;
 use Dedoc\Scramble\Support\Type\Generic;
 use Dedoc\Scramble\Support\Type\KeyedArrayType;
-use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType;
 use Dedoc\Scramble\Support\Type\ObjectType;
-use Dedoc\Scramble\Support\Type\StringType;
 use Dedoc\Scramble\Support\Type\Type;
-use Dedoc\Scramble\Support\Type\TypeWalker;
-use Dedoc\Scramble\Support\Type\Union;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\ResourceCollection;
-use Illuminate\Http\Resources\MergeValue;
-use Illuminate\Http\Resources\MissingValue;
 
 class JsonResourceTypeToSchema extends TypeToSchemaExtension
 {
+    use FlattensMergeValues;
+    use MergesOpenApiObjects;
+
     public function shouldHandle(Type $type)
     {
         return $type instanceof ObjectType
@@ -69,102 +63,6 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
         return $this->openApiTransformer->transform($array);
     }
 
-    private function flattenMergeValues(array $items)
-    {
-        return collect($items)
-            ->flatMap(function (ArrayItemType_ $item) {
-                if ($item->value instanceof KeyedArrayType) {
-                    $item->value->items = $this->flattenMergeValues($item->value->items);
-                    $item->value->isList = KeyedArrayType::checkIsList($item->value->items);
-
-                    return [$item];
-                }
-
-                if (
-                    $item->value instanceof Union
-                    && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(Carbon::class))
-                ) {
-                    (new TypeWalker)->replace($item->value, function (Type $t) {
-                        return $t->isInstanceOf(Carbon::class)
-                            ? tap(new StringType, fn ($t) => $t->setAttribute('format', 'date-time'))
-                            : null;
-                    });
-
-                    return [$item];
-                }
-
-                if ($item->value->isInstanceOf(JsonResource::class)) {
-                    $resource = $this->getResourceType($item->value);
-
-                    if ($resource->isInstanceOf(MissingValue::class)) {
-                        return [];
-                    }
-
-                    if (
-                        $resource instanceof Union
-                        && (new TypeWalker)->first($resource, fn (Type $t) => $t->isInstanceOf(MissingValue::class))
-                    ) {
-                        $item->isOptional = true;
-
-                        return [$item];
-                    }
-                }
-
-                if (
-                    $item->value instanceof Union
-                    && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(MissingValue::class))
-                ) {
-                    $newType = array_filter($item->value->types, fn (Type $t) => ! $t->isInstanceOf(MissingValue::class));
-
-                    if (! count($newType)) {
-                        return [];
-                    }
-
-                    $item->isOptional = true;
-
-                    if (count($newType) === 1) {
-                        $item->value = $newType[0];
-
-                        return $this->flattenMergeValues([$item]);
-                    }
-
-                    $item->value = new Union($newType);
-
-                    return $this->flattenMergeValues([$item]);
-                }
-
-                if (
-                    $item->value instanceof Generic
-                    && $item->value->isInstanceOf(MergeValue::class)
-                ) {
-                    $arrayToMerge = $item->value->templateTypes[1];
-
-                    // Second generic argument of the `MergeValue` class must be a keyed array.
-                    // Otherwise, we ignore it from the resulting array.
-                    if (! $arrayToMerge instanceof KeyedArrayType) {
-                        return [];
-                    }
-
-                    $arrayToMergeItems = $this->flattenMergeValues($arrayToMerge->items);
-
-                    $mergingArrayValuesShouldBeRequired = $item->value->templateTypes[0] instanceof LiteralBooleanType
-                        && $item->value->templateTypes[0]->value === true;
-
-                    if (! $mergingArrayValuesShouldBeRequired || $item->isOptional) {
-                        foreach ($arrayToMergeItems as $mergingItem) {
-                            $mergingItem->isOptional = true;
-                        }
-                    }
-
-                    return $arrayToMergeItems;
-                }
-
-                return [$item];
-            })
-            ->values()
-            ->all();
-    }
-
     /**
      * @param  Generic  $type
      */
@@ -215,7 +113,7 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
         return new Reference('schemas', $type->name, $this->components);
 
         /*
-         * @todo: Allow (enforce) user to explicitly pass short and unique names for the reference.
+         * @todo: Allow (enforce) user to explicitly pass short and unique names for the reference and avoid passing components.
          * Otherwise, only class names are correctly handled for now.
          */
         return Reference::in('schemas')
@@ -223,19 +121,6 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension
             ->uniqueName($type->name);
     }
 
-    private function mergeOpenApiObjects(OpenApiTypes\ObjectType $into, OpenApiTypes\Type $what)
-    {
-        if (! $what instanceof OpenApiTypes\ObjectType) {
-            return;
-        }
-
-        foreach ($what->properties as $name => $property) {
-            $into->addProperty($name, $property);
-        }
-
-        $into->addRequired(array_keys($what->properties));
-    }
-
     private function getResourceType(Type $type): Type
     {
         if (! $type instanceof Generic) {
diff --git a/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php b/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php
new file mode 100644
index 0000000..184b7f3
--- /dev/null
+++ b/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Dedoc\Scramble\Support\TypeToSchemaExtensions;
+
+use Dedoc\Scramble\Support\Generator\Types as OpenApiTypes;
+
+trait MergesOpenApiObjects
+{
+    protected function mergeOpenApiObjects(OpenApiTypes\ObjectType $into, OpenApiTypes\Type $what)
+    {
+        if (! $what instanceof OpenApiTypes\ObjectType) {
+            return;
+        }
+
+        foreach ($what->properties as $name => $property) {
+            $into->addProperty($name, $property);
+        }
+
+        $into->addRequired(array_keys($what->properties));
+    }
+}


Test patch:
diff --git a/tests/ResourceCollectionResponseTest.php b/tests/ResourceCollectionResponseTest.php
index d260397..082b84a 100644
--- a/tests/ResourceCollectionResponseTest.php
+++ b/tests/ResourceCollectionResponseTest.php
@@ -1,12 +1,10 @@
 <?php
 
 use Dedoc\Scramble\Infer;
-use Dedoc\Scramble\Scramble;
 use Dedoc\Scramble\Support\Generator\Components;
 use Dedoc\Scramble\Support\Generator\TypeTransformer;
 use Dedoc\Scramble\Support\Type\ObjectType;
 use Dedoc\Scramble\Support\TypeToSchemaExtensions\JsonResourceTypeToSchema;
-use Illuminate\Routing\Route;
 use Illuminate\Support\Facades\Route as RouteFacade;
 
 use function Spatie\Snapshots\assertMatchesSnapshot;
@@ -112,14 +110,12 @@ class UserCollection_Four extends \Illuminate\Http\Resources\Json\ResourceCollec
 }
 
 it('attaches additional data to the response documentation', function () {
-    RouteFacade::get('api/test', [ResourceCollectionResponseTest_Controller::class, 'index']);
-
-    Scramble::routes(fn (Route $r) => $r->uri === 'api/test');
-    $openApiDocument = app()->make(\Dedoc\Scramble\Generator::class)();
+    $openApiDocument = generateForRoute(function () {
+        return RouteFacade::get('api/test', [ResourceCollectionResponseTest_Controller::class, 'index']);
+    });
 
     assertMatchesSnapshot($openApiDocument);
 });
-
 class ResourceCollectionResponseTest_Controller
 {
     public function index(Request $request)
@@ -131,6 +127,27 @@ class ResourceCollectionResponseTest_Controller
     }
 }
 
+it('attaches additional data to the response documentation for annotation', function () {
+    $openApiDocument = generateForRoute(function () {
+        return RouteFacade::get('api/test', [AnnotationResourceCollectionResponseTest_Controller::class, 'index']);
+    });
+
+    expect($props = $openApiDocument['paths']['/test']['get']['responses'][200]['content']['application/json']['schema']['properties'])
+        ->toHaveKeys(['data', 'something'])
+        ->and($props['something']['properties'])
+        ->toBe(['foo' => ['type' => 'string', 'example' => 'bar']]);
+});
+class AnnotationResourceCollectionResponseTest_Controller
+{
+    public function index(Request $request)
+    {
+        return UserResource::collection(collect())
+            ->additional([
+                'something' => ['foo' => 'bar'],
+            ]);
+    }
+}
+
 class UserResource extends \Illuminate\Http\Resources\Json\JsonResource
 {
     public function toArray($request)