A few weeks ago, I wrote about how I alter the API documentation generated by API Platform. As things usually go when I write blog posts or create packages, a new (usually better) built-in method to do the same thing is released either shortly before or after I write about the old method. Thankfully, Alan Poulain pointed this out on the Symfony Slack after I published the previous blog post.

Instead of decorating api_platform.openapi.normalizer.api_gateway service with a NormalizerInterface, the newer method allows decorating the api_platform.openapi.factory service with an OpenApiFactoryInterface. I’m going to rewrite the examples in the second part of my previous blog post to this new method.

Changes to the configuration & boilerplate class

View the original code

The decoration can be defined within your services.yaml:

App\Api\Documentation\MyAlteration:
    decorates: 'api_platform.openapi.factory'

As before, you can define multiple classes and have them all decorate the same service. This way, you can separate different alterations to your documentation in different classes and there is no need to put all changes into one big class.

This is the new boilerplate class I use:

<?php
declare(strict_types=1);

namespace App\Api\Documentation;

use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\Core\OpenApi\OpenApi;

final class MyAlteration implements OpenApiFactoryInterface
{
    private OpenApiFactoryInterface $decorated;

    public function __construct(OpenApiFactoryInterface $decorated)
    {
        $this->decorated = $decorated;
    }

    public function __invoke(array $context = []): OpenApi
    {
        $openApi = $this->decorated->__invoke($context);

        // Make some changes to $openApi here.

        return $openApi;
    }
}

In the examples below, I will only show the contents of the __invoke method.

Removing an entire resource

View the original code

Currently, it is a bit of a hassle to remove paths. You’ll have to create a new, empty ApiPlatform\Core\OpenApi\Model\Paths object and add the paths that you do want to keep back to it. You can then add the new list of paths to a copy of $openApi using the withPaths method. Make sure you assign the result of this method to a variable if you wish to do more with $openApi (like $openApi = $openApi->withPaths($paths).

$openApi = $this->decorated->__invoke($context);

$paths = $openApi->getPaths()->getPaths();

unset(
    $paths['/my_secret_entities'],
    $paths['/my_secret_entities/{id}'],
);

$newPaths = new Paths();

foreach ($paths as $path => $pathItem) {
    $newPaths->addPath($path, $pathItem);
}

return $openApi->withPaths($newPaths);

Removing unused response codes

View the original code

As with the previous example, due to the immutable nature of the objects used, it requires a bit of a workaround to set the updated objects correctly in all the different layers. This means we need to update the post Operation with the new (filtered) list of responses. Then we need to update the PathItem with the new post operation. Finally, we need to override the original path with the new PathItem object using addPath. There are some null-checks on the way, so we also need to be on a lookout for those. Fortunately, since we’re removing stuff, we can safely ignore any path or operation that doesn’t exist in the first place.

$openApi = $this->decorated->__invoke($context);

$paths = $openApi->getPaths();

$resultsToRemove = [
    '/users/update_credentials' => [201, 422],
    '/users/update_profile' => [201, 422],
];

foreach ($resultsToRemove as $path => $statusesToRemove) {
    $pathItem = $paths->getPath($path);
    if (!$pathItem) {
        continue;
    }

    // Use getGet(), getPut() or another getter if you use a different HTTP method.
    $post = $pathItem->getPost();
    if (!$post) {
        continue;
    }

    $responses = $post->getResponses();

    foreach ($statusesToRemove as $statusToRemove) {
        unset($responses[$statusToRemove]);
    }

    // Calling addPath with an existing path will override the original
    $paths->addPath(
        $path,
        $pathItem->withPost(
            $post->withResponses($responses)
        )
    );
}

return $openApi->withPaths($paths);

Removing the (POST or PUT) request body

View the original code

Because there is currently no way to unset the request body of an operation, we’ll have to copy it manually while replacing the request body with null.

$openApi = $this->decorated->__invoke($context);

$path = '/users/register';

$paths = $openApi->getPaths();

$pathItem = $paths->getPath($path);

if (!$pathItem) {
    return $openApi;
}

$operation = $pathItem->getPost();

if (!$operation) {
    return $openApi;
}

$newOperation = new Operation(
    $operation->getOperationId(),
    $operation->getTags(),
    $operation->getResponses(),
    $operation->getSummary(),
    $operation->getDescription(),
    $operation->getExternalDocs(),
    $operation->getParameters(),
    null,
    $operation->getCallbacks(),
    $operation->getDeprecated(),
    $operation->getSecurity(),
    $operation->getServers()
);

$paths->addPath($path, $pathItem->withPost($newOperation));

return $openApi->withPaths($paths);

Creating completely new endpoint documentation

View the original code

In this case, the example was too large to be written out fully. Below the first code example are the helper methods I’ve created to help out. In this case, they were private methods in the same class. If you’re using multiple classes to add new endpoints, I would suggest creating a separate helper class or service and put the methods there.

$openApi = $this->decorated->__invoke($context);

// Add the JWT Authentication request object
$openApi = $this->addSchema(
    $openApi,
    'JWTAuth',
    [
        'type' => 'object',
        'required' => ['email', 'password'],
        'properties' => [
            'email' => new \ArrayObject(['type' => 'string', 'format' => 'email']),
            'password' => new \ArrayObject(['type' => 'string']),
        ]
    ]
);

// Add the JWT Refresh request object
$openApi = $this->addSchema(
    $openApi,
    'JWTRefresh',
    [
        'type' => 'object',
        'required' => ['refresh_token'],
        'properties' => [
            'refresh_token' => new \ArrayObject(['type' => 'string']),
        ],
    ]
);

// Add the JWT response object (used in both auth and refresh).
$openApi = $this->addSchema(
    $openApi,
    'JWTResponse',
    [
        'type' => 'object',
        'properties' => [
            'token' => new \ArrayObject(['type' => 'string']),
            'refresh_token' => new \ArrayObject(['type' => 'string']),
        ],
    ]
);

// Add the JWT Authentication endpoint
$this->addPostOperation(
    $openApi,
    '/jwt/token',
    'jwt_token',
    ['Authentication'],
    [
        200 => $this->createResponse('Login successful', 'JWTResponse'),
        400 => $this->createResponse('Bad request: missing or incorrect body'),
        401 => $this->createResponse('Invalid credentials: user does not exist or password is incorrect'),
    ],
    'Log in',
    $this->createRequestBody('Credentials', 'JWTAuth')
);

// Add the JWT Refresh endpoint
$this->addPostOperation(
    $openApi,
    '/jwt/token/refresh',
    'jwt_refresh',
    ['Authentication'],
    [
        200 => $this->createResponse('Refresh successful', 'JWTResponse'),
        400 => $this->createResponse('Bad request: missing or incorrect body'),
        401 => $this->createResponse('An authentication exception occurred: incorrect or expired refresh token'),
    ],
    'Refresh JWT Token',
    $this->createRequestBody('Refresh token', 'JWTRefresh')
);

return $openApi;

The helper methods for this example:

private function addSchema(OpenApi $openApi, string $schemaName, array $errorSchema): OpenApi
{
    $components = $openApi->getComponents();
    $schemas = $components->getSchemas();

    if ($schemas === null) {
        $schemas = new \ArrayObject();
    }

    $schemas[$schemaName] = new \ArrayObject($errorSchema);

    $schemas->ksort();

    return $openApi->withComponents(
        $components->withSchemas($schemas)
    );
}

private function addPostOperation(OpenApi $openApi, string $path, string $operationId, array $tags, array $responses, string $summary, ?RequestBody $requestBody): void
{
    $operation = new Operation(
        $operationId,
        $tags,
        $responses,
        $summary,
        $summary,
        null,
        [],
        $requestBody
    );

    $openApi->getPaths()->addPath(
        $path,
        (new PathItem())
            ->withPost($operation)
    );
}

private function createResponse(string $description, ?string $reference = null): Response
{
    $schema = null;
    if ($reference !== null) {
        $schema = new \ArrayObject([
            'application/json' => [
                'schema' => new \ArrayObject([
                    '$ref' => '#/components/schemas/'.$reference,
                ]),
            ],
        ]);
    }

    return new Response($description, $schema);
}

private function createRequestBody(string $description, string $reference): RequestBody
{
    return new RequestBody(
        $description,
        new \ArrayObject([
            'application/json' => new MediaType(
                new \ArrayObject([
                    '$ref' => '#/components/schemas/' . $reference,
                ])
            )
        ]),
        true
    );
}
Thanks for proofreading and improvements: