Almost all the projects I work on are done by creating a backend API using Symfony and API Platform. The frontend is then built by another developer using Angular, React or another frontend framework.

To help us work together, I make heavy use of the OpenAPI (previously Swagger) API documentation that API Platform automatically generates from my code and configuration. This autogenerated API documentation is very complete and works really great out-of-the-box, but sometimes, I need to add, remove or alter parts of it to better reflect my work. Most of these changes can be done within the boundries of the API Platform resource configuration, but some larger changes need to be done differently. In this blog post, I want to explore some examples of changed API documentation and explain how I made these changes.

Using the API Platform resource configuration

I write the configuration for API Platform resources in YAML. This can also be done using annotations in your Symfony Entities or DTO’s. Either way works fine, this is just a matter of preference. In these examples, I use my YAML configuration. These examples are made for the OpenAPI variation of the documentation. If you’re using Swagger, there are some differences to be aware of.

Disabling all item or collection operations

Some (especially DTO) resources I use only have a POST collectionOperation endpoint. To disable all itemOperations, make sure to pass an empty array into the resource configuration. Omitting the itemOperations key or using ~ as value will enable the default operations. The same is the case for collectionOperations.

resources:
    App\Users\Registration\Registration:
        itemOperations: []

Adding custom responses

Sometimes an endpoint returns a different response than the actual resource. I usually do this for user registration, where the new user object isn’t returned. A JWT token is generated and returned instead. This way, the front-end can directly log in using the new user. I use a custom controller to handle the Registration DTO and turn it into a valid User entity.

The responses are defined in the resource configuration using the resources: [FQCN]: collectionOperations: [operationName]: openapi_context: responses array.

resources:
    App\User\Registration\Registration:
        collectionOperations:
            post:
                method: 'POST'
                path: '/users/register'
                controller: 'App\User\Registration\Register'
                openapi_context:
                    tags: ['User']
                    summary: 'Register a new user'
                    description: 'Registers a new user'
                    responses:
                        201:
                            description: 'Created'
                            content:
                                application/json:
                                    schema:
                                        type: object
                                        properties:
                                            status:
                                                type: string
                                                example: 'ok'
                                            token:
                                                type: string
                                            refresh_token:
                                                type: string
                        400:
                            description: 'Invalid input'
                        422:
                            description: 'Email already in use'

Adding extra parameters

I have a few custom endpoints that don’t use filters, but they do have some query parameters to be used directy in the custom controller. In this example, I have an endpoint for counting the amount of tasks (open, overdue, due today, due this week, etc). The controller returns an array of integers (the result of a bunch of count queries) and allows optional finetuning through query parameters.

These extra parameters are defined in the resources: [FQCN]: collectionOperations: [operationName]: openapi_context: parameters array.

The in parameter can be one of:

  • 'query': As query parameter
  • 'path': As path, make sure there is a replacement (if you define id as path parameter, use {id} in the path)
  • 'body': Within the JSON body
  • 'formData': As field of a (multipart) form body
  • 'cookie': As expected cookie
  • 'header': As a header
resources:
    App\Task\Task:
        collectionOperations:
            summary:
                method: 'GET'
                path: '/tasks/summary'
                controller: 'App\Task\TasksSummary'
                openapi_context:
                    summary: 'Get a summary of task counts per timespan'
                    description: 'Get a summary of task counts per timespan'
                    parameters:
                        - name: 'for'
                          type: 'string'
                          in: 'query'
                          required: false
                        - name: 'forEmpty'
                          type: 'boolean'
                          in: 'query'
                          required: false
                    responses:
                        200:
                            description: 'Task summary response'
                            schema:
                                type: 'object'
                                properties:
                                    all: {type: 'integer'}
                                    today: {type: 'integer'}
                                    nextSevenDays: {type: 'integer'}
                                    overdue: {type: 'integer'}
                                    done: {type: 'integer'}

Decorating the API Platform Documentation Normalizer

The examples below can not be done by using the resource configuration. Instead, we’ll have to decorate the OpenAPI documentation Normalizer. This can be done by creating a class that implements Symfony\Component\Serializer\Normalizer\NormalizerInterface and decorates the api_platform.openapi.normalizer.api_gateway service.

The decoration can be defined within your services.yaml:

App\Api\Documentation\MyAlteration:
    decorates: 'api_platform.openapi.normalizer.api_gateway'

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 boilerplate class I use for these kinds of alterations:

<?php
declare(strict_types=1);

namespace App\Api\Documentation;

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class MyAlteration implements NormalizerInterface
{
    private NormalizerInterface $decorated;

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

    public function supportsNormalization($data, string $format = null): bool
    {
        return $this->decorated->supportsNormalization($data, $format);
    }

    public function normalize($object, string $format = null, array $context = []): array
    {
        /** @var array $docs */
        $docs = $this->decorated->normalize($object, $format, $context);

        // Make some changes to $docs here.

        return $docs;
    }
}

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

Removing an entire resource

This is a question that came around on the Symfony Slack a couple of times. In order to remove an entire resource (entity or DTO) from the documentation, I had to figure out all the paths that are used for that entity. These are usually the collection operation path (by default, snake cased plural name of your class), and the item operation path (the collection operation path appended with a slash and identifiers, usually /{id}).

If you’re unsure what the paths are, you can use the php bin/console debug:router command to search for them.

Removing those paths from the ['paths'] subarray of the documentation array will omit the entire resource from your documentation.

This example can be used to remove the documentation of MySecretEntity:

/** @var array $docs */
$docs = $this->decorated->normalize($object, $format, $context);

$paths = [
    '/my_secret_entities',
    '/my_secret_entities/{id}'
];

foreach ($paths as $path) {
    unset($docs['paths'][$path]);
}

return $docs;

Removing unused response codes

By default, API Platform generates 201 responses for all POST operations. However, for changing user data I don’t use the default PUT item operation, but I defined two DTO’s to alter either credentials (email and/or password) or profile data (first name, last name, etc). I do this because the current password is required to change either the email or password, but not for the other data.

The “update credentials” POST collection operation responds with a 200 instead of a 201, since no entity is created on the server. The response also contains custom data: a status, a new JWT token and a new refresh token. These are returned because the payload of the original JWT token might not be valid anymore if the email was changed. The frontend can then use these tokens to continue the session without the user having to log in again.

I added the 200 status through the resource configuration method, but the original 201 status is not removed. I had to do that manually in a normalizer decorator. Because I have multiple endpoints and multiple status codes that had to be removed, I made a list of them to loop through:

/** @var array $docs */
$docs = $this->decorated->normalize($object, $format, $context);

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

foreach ($paths as $path => $statusCodes) {
    foreach ($statusCodes as $statusCode) {
        unset($docs['paths'][$path]['post']['responses'][$statusCode]);
    }
}

return $docs;

Removing the (POST or PUT) request body

I have had some cases where I had a POST or PUT operation without any request body. A use case for this is when using the Symfony Workflow component and creating custom endpoints to let an entity enter a certain state. An example for tasks is the PUT /tasks/{id}/done endpoint that only tries to apply the "done" state to a given task, but doesn’t accept any other changes to the entity.

/** @var array $docs */
$docs = $this->decorated->normalize($object, $format, $context);

$path = '/tasks/{id}/done';
$method = 'put'; // You can also use 'post'

unset($docs['paths'][$path][$method]['requestBody']);

return $docs;

Creating completely new endpoint documentation

Some endpoints of your API are defined externally or through the Symfony Routing only (bypassing API-Platform). This results in undocumented endpoints. In the following example, I add documentation for requesting and refreshing a JWT token. I use lexik/jwt-authentication-bundle and gesdinet/jwt-refresh-token-bundle for the functionality itself.

I first add a few schemas to $docs['components']['schemas']. I use these later in the path defintions with '$ref' => '#/components/schemas/....'.

/** @var array $docs */
$docs = $this->decorated->normalize($object, $format, $context);

// Add the JWT Authentication request object
$docs['components']['schemas']['JWTAuth'] = [
    'type' => 'object',
    'properties' => [
        'email' => ['type' => 'string', 'format' => 'email'],
        'password' => ['type' => 'string'],
    ],
];

// Add the JWT Refresh request object
$docs['components']['schemas']['JWTRefresh'] = [
    'type' => 'object',
    'properties' => [
        'refresh_token' => ['type' => 'string'],
    ],
];

// Add the JWT response object (used in both auth and refresh).
$docs['components']['schemas']['JWTResponse'] = [
    'type' => 'object',
    'properties' => [
        'token' => ['type' => 'string'],
        'refresh_token' => ['type' => 'string'],
    ],
];

// Add the JWT Authentication endpoint
$docs['paths']['/jwt/token']['post'] = [
    'tags' => ['Authentication'],
    'operationId' => 'jwt_token',
    'summary' => 'Log in',
    'parameters' => [
        [
            'name' => 'auth',
            'in' => 'body',
            'schema' => [
                '$ref' => '#/components/schemas/JWTAuth',
            ],
        ],
    ],
    'responses' => [
        200 => [
            'description' => 'Login successful',
            'content' => [
                'application/json' => [
                    'schema' => [
                        '$ref' => '#/components/schemas/JWTResponse',
                    ],
                ],
            ],
        ],
        400 => [
            'description' => 'Bad request: missing or incorrect body',
        ],
        401 => [
            'description' => 'Invalid credentials: user does not exist or password is incorrect',
        ],
    ],
];

// Add the JWT Refresh endpoint
$docs['paths']['/jwt/token/refresh']['post'] = [
    'tags' => ['Authentication'],
    'operationId' => 'jwt_refresh',
    'summary' => 'Refresh JWT Token',
    'parameters' => [
        [
            'name' => 'auth',
            'in' => 'body',
            'schema' => [
                '$ref' => '#/components/schemas/JWTRefresh',
            ],
        ],
    ],
    'responses' => [
        200 => [
            'description' => 'Refresh successful',
            'content' => [
                'application/json' => [
                    'schema' => [
                        '$ref' => '#/components/schemas/JWTResponse',
                    ],
                ],
            ],
        ],
        400 => [
            'description' => 'Bad request: missing or incorrect body',
        ],
        401 => [
            'description' => 'An authentication exception occurred: incorrect or expired refresh token',
        ],
    ],
];

// Sort the schemas and the endpoints, because that's nicer
ksort($docs['components']['schemas']);
ksort($docs['paths']);

return $docs;

Converting these examples to Swagger

Some older projects I made still use the Swagger documentation method. These are the reversed changes I made when converting those configurations to the newer OpenAPI documentation method. With these changes, any of the examples above should work if you’re using Swagger instead of OpenAPI.

Resource configuration

  • Replace openapi_context with swagger_context.
  • If you’re defining custom responses, remove the content: application/json: layers between the status code and schema (schema: should be at the same level as description:).

Documentation Normalizer Decorators

  • Decorate 'api_platform.swagger.normalizer.api_gateway' instead of 'api_platform.openapi.normalizer.api_gateway'.
  • Use ['definitions'] instead of ['components']['schemas'].
  • In ['paths'][$path][$method], add 'produces' => ['application/json'].
  • In ['paths'][$path][$method], remove the ['content']['application/json'] layers ('schema' should be at the same level as 'description').

In my older projects code, the decorated normalizer returned an array of ArrayObject instances instead of a multidimensional array. If that is the case, you can sort these by using $docs['definitions']->ksort(); instead of ksort($docs['components']['schemas']); and $docs['paths']->ksort(); instead of ksort($docs['paths']);.

Thanks for proofreading and improvements: