Skip to content

Issue with DTOs on Laravel #8187

@Dasc3er

Description

@Dasc3er

API Platform version(s) affected: 4.2.2

Description
Using the Laravel variant (not tested with Symfony) and nested DTOs for API definition, the documentation is incomplete and defining the output explicitly makes the endpoint fail.

How to reproduce

Create the following files on a new Laravel installation from https://api-platform.com/docs/laravel/
No changes to the default configuration.

// app\State\Nested.php
namespace App\State;

class Nested
{
    public function __construct(
        public int $id,
    ) {
    }
}
// app\State\TestResponse.php
namespace App\State;

class TestResponse
{
    /** @var Nested[] */
    public array $data = [];
}
// app\State\TestProvider.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;

final class TestProvider implements ProviderInterface
{
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TestResponse
    {
        $ref = $uriVariables['ref'];
        // Other logic

        $response = new TestResponse();

        $test = new Nested(1);
        $response->data[] = $test;

        return $response;
    }
}
// app\Models\TestResource.php
namespace App\Models;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\TestProvider;
use App\State\TestResponse;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/test/{ref}',
            provider: TestProvider::class,
            //output: TestResponse::class,
        ),
    ],
)]
class TestResource
{
}

Register the provider on app\Providers\AppServiceProvider.php

public function boot(): void
    {
...
		$this->app->tag(TestProvider::class, ProviderInterface::class);
    }

If the output section on TestResource is commented out, the following output can be correctly obtained but the documentation is incomplete:

{
  "@context": {
    "@vocab": "http://localhost/my-api-platform-laravel-app/public/api/docs.jsonld#",
    "hydra": "http://www.w3.org/ns/hydra/core#",
    "data": "TestResponse/data"
  },
  "@type": "TestResponse",
  "@id": "/my-api-platform-laravel-app/public/api/.well-known/genid/bac7098647960bfe2bc6",
  "data": {
    "@context": "/my-api-platform-laravel-app/public/api/contexts/TestResource",
    "@id": "/my-api-platform-laravel-app/public/api/test/2",
    "@type": "Collection",
    "totalItems": 1,
    "member": [
      {
        "@type": "Nested",
        "@id": "/my-api-platform-laravel-app/public/api/.well-known/genid/5e27cd4ab65753f0b781",
        "id": 1
      }
    ]
  }
Image

If the output section on TestResource is enabled, the documentation is partially correct (the nested data is not detected) and the endpoint fails with error:

Image
A class metadata factory must be provided in the constructor when setting \"allow_extra_attributes\" to false.

from

{
  "@context": "/my-api-platform-laravel-app/public/api/contexts/Error",
  "@id": "/my-api-platform-laravel-app/public/api/errors/500",
  "@type": "Error",
  "trace": [
    {
      "file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
      "line": 1099,
      "function": "getAllowedAttributes",
      "class": "Symfony/Component/Serializer/Normalizer/AbstractNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
      "line": 261,
      "function": "getAllowedAttributes",
      "class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
      "line": 174,
      "function": "getAttributes",
      "class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/jsonld/Serializer/ObjectNormalizer.php",
      "line": 73,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Serializer.php",
      "line": 152,
      "function": "normalize",
      "class": "ApiPlatform/JsonLd/Serializer/ObjectNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/serializer/AbstractCollectionNormalizer.php",
      "line": 110,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Serializer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/serializer/AbstractCollectionNormalizer.php",
      "line": 81,
      "function": "normalizeRawCollection",
      "class": "ApiPlatform/Serializer/AbstractCollectionNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/hydra/Serializer/CollectionFiltersNormalizer.php",
      "line": 80,
      "function": "normalize",
      "class": "ApiPlatform/Serializer/AbstractCollectionNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/hydra/Serializer/PartialCollectionViewNormalizer.php",
      "line": 55,
      "function": "normalize",
      "class": "ApiPlatform/Hydra/Serializer/CollectionFiltersNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Serializer.php",
      "line": 152,
      "function": "normalize",
      "class": "ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
      "line": 988,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Serializer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
      "line": 198,
      "function": "getAttributeValue",
      "class": "ApiPlatform/Serializer/AbstractItemNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
      "line": 169,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/jsonld/Serializer/ItemNormalizer.php",
      "line": 141,
      "function": "normalize",
      "class": "ApiPlatform/Serializer/AbstractItemNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Serializer.php",
      "line": 152,
      "function": "normalize",
      "class": "ApiPlatform/JsonLd/Serializer/ItemNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
      "line": 133,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Serializer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/jsonld/Serializer/ItemNormalizer.php",
      "line": 104,
      "function": "normalize",
      "class": "ApiPlatform/Serializer/AbstractItemNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Serializer.php",
      "line": 152,
      "function": "normalize",
      "class": "ApiPlatform/JsonLd/Serializer/ItemNormalizer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/symfony/serializer/Serializer.php",
      "line": 131,
      "function": "normalize",
      "class": "Symfony/Component/Serializer/Serializer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/state/Processor/SerializeProcessor.php",
      "line": 85,
      "function": "serialize",
      "class": "Symfony/Component/Serializer/Serializer",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/state/Processor/WriteProcessor.php",
      "line": 54,
      "function": "process",
      "class": "ApiPlatform/State/Processor/SerializeProcessor",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/hydra/State/HydraLinkProcessor.php",
      "line": 58,
      "function": "process",
      "class": "ApiPlatform/State/Processor/WriteProcessor",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/laravel/Controller/ApiPlatformController.php",
      "line": 104,
      "function": "process",
      "class": "ApiPlatform/Hydra/State/HydraLinkProcessor",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Controller.php",
      "line": 54,
      "function": "__invoke",
      "class": "ApiPlatform/Laravel/Controller/ApiPlatformController",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php",
      "line": 43,
      "function": "callAction",
      "class": "Illuminate/Routing/Controller",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Route.php",
      "line": 265,
      "function": "dispatch",
      "class": "Illuminate/Routing/ControllerDispatcher",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Route.php",
      "line": 211,
      "function": "runController",
      "class": "Illuminate/Routing/Route",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
      "line": 822,
      "function": "run",
      "class": "Illuminate/Routing/Route",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 180,
      "function": "Illuminate/Routing/{closure}",
      "class": "Illuminate/Routing/Router",
      "type": "->"
    },
    {
      "file": "<path>/vendor/api-platform/laravel/ApiPlatformMiddleware.php",
      "line": 47,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "ApiPlatform/Laravel/ApiPlatformMiddleware",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 137,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
      "line": 821,
      "function": "then",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
      "line": 800,
      "function": "runRouteWithinStack",
      "class": "Illuminate/Routing/Router",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
      "line": 764,
      "function": "runRoute",
      "class": "Illuminate/Routing/Router",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
      "line": 753,
      "function": "dispatchToRoute",
      "class": "Illuminate/Routing/Router",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
      "line": 200,
      "function": "dispatch",
      "class": "Illuminate/Routing/Router",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 180,
      "function": "Illuminate/Foundation/Http/{closure}",
      "class": "Illuminate/Foundation/Http/Kernel",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php",
      "line": 21,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php",
      "line": 31,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/TransformsRequest",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php",
      "line": 21,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php",
      "line": 51,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/TransformsRequest",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/TrimStrings",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePostSize.php",
      "line": 27,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Http/Middleware/ValidatePostSize",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php",
      "line": 109,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/HandleCors.php",
      "line": 61,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Http/Middleware/HandleCors",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php",
      "line": 58,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Http/Middleware/TrustProxies",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php",
      "line": 22,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePathEncoding.php",
      "line": 26,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 219,
      "function": "handle",
      "class": "Illuminate/Http/Middleware/ValidatePathEncoding",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
      "line": 137,
      "function": "Illuminate/Pipeline/{closure}",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
      "line": 175,
      "function": "then",
      "class": "Illuminate/Pipeline/Pipeline",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
      "line": 144,
      "function": "sendRequestThroughRouter",
      "class": "Illuminate/Foundation/Http/Kernel",
      "type": "->"
    },
    {
      "file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
      "line": 1220,
      "function": "handle",
      "class": "Illuminate/Foundation/Http/Kernel",
      "type": "->"
    },
    {
      "file": "<path>/public/index.php",
      "line": 20,
      "function": "handleRequest",
      "class": "Illuminate/Foundation/Application",
      "type": "->"
    }
  ],
  "type": "/errors/500",
  "title": "An error occurred",
  "status": 500,
  "detail": "A class metadata factory must be provided in the constructor when setting \"allow_extra_attributes\" to false."
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions