Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Customizing the OpenAPI Docs

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

To use API tokens in Swagger, we need to type the word "Bearer" and then the token. Lame! Especially if we intend for this to be used by real users. So how can we fix that?

The OpenAPI Spec is the Key

Remember that Swagger is entirely generated from the OpenAPI spec document that API Platform builds. You can see this document either by viewing the page source - you can see it all right there - or by going to /api/docs.json. A few minutes ago, we added some config to API Platform called Authorization:

api_platform:
... lines 2 - 7
swagger:
api_keys:
access_token:
name: Authorization
type: header
... lines 13 - 18

The end result is that it added these security sections down here. Yup, it's that simple: this config triggered these new sections in this JSON document: nothing else. Swagger then reads that and knows to make this "Authorization" available.

So I did some digging directly on the OpenAPI site and I found out that it does have a way to define an authentication scheme where you do not need to pass the "Bearer" part manually. Unfortunately, unless I'm missing it, API Platform's config does not support adding that. So are we done for? No way! And for an awesome reason.

Creating our OpenApiFactory

To create this JSON document, internally, API Platform creates an OpenApi object, populates all this data onto it and then sends it through Symfony's serializer. This is important because we can tweak the OpenApi object before it goes through the serializer. How? The OpenApi object is created via a core OpenApiFactory... and we can decorate that.

Check it out: over in the src/ directory, create a new directory called ApiPlatform/... and inside, a new PHP class called OpenApiFactoryDecorator. Make this implement OpenApiFactoryInterface. Then go to "Code"->"Generate" or Command+N on a Mac to implement the one method we need: __invoke():

... lines 1 - 2
namespace App\ApiPlatform;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\OpenApi;
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
public function __invoke(array $context = []): OpenApi
{
// TODO: Implement __invoke() method.
}
}

Hello Service Decoration!

Right now, a core OpenApiFactory service exists in API Platform that creates the OpenApi object with all this data on it. Here's our sneaky plan: we're going to tell Symfony to use our new class as the OpenApiFactory instead of the core one. But... we definitely do not want to re-implement all of the core logic. To avoid that, we'll also tell Symfony to pass us the original, core OpenApiFactory.

You might be familiar with what we're doing. It's class decoration: an object-oriented strategy for extending classes. It's really easy to do in Symfony and API Platform leverages it a lot.

Whenever you do decoration, you will always create a constructor that accepts the interface that you're decorating. So OpenApiFactoryInterface. I'll call this $decorated. Oh, and let me put private in front of that:

... lines 1 - 4
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
... lines 6 - 9
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
public function __construct(private OpenApiFactoryInterface $decorated)
{
}
... lines 15 - 23
}

Perfect.

Down here, to start, say $openApi = $this->decorated and then call the __invoke() method passing the same argument: $context:

... lines 1 - 9
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 12 - 15
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
... lines 19 - 22
}
}

That will call the core factory which will do all the hard work of creating the full OpenApi object. Down here, return that:

... lines 1 - 9
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 12 - 15
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
... lines 19 - 21
return $openApi;
}
}

And in between? Yup, that's where we can mess with things! To make sure this is working, for now, just dump the $openApi object:

... lines 1 - 9
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 12 - 15
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
dump($openApi);
return $openApi;
}
}

The #[AsDecorator] Attribute

At this moment, from an object-oriented point of view, this class is set up correctly for decoration. But Symfony's container is still set up to use the normal OpenApiFactory: it's not going to use our new service at all. We somehow need to tell the container that, first, the core OpenApiFactory service should be replaced by our service, and second, that the original core service should be passed to us.

How can we do that? Above the class, add an attribute called #[AsDecorator] and hit tab to add that use statement. Pass this the service id of the original, core OpenApiFactory. You can do some digging to find this or usually the documentation will tell you. API platform actually documents decorating this service, so right in their docs, you'll find that the service id is api_platform.openapi.factory:

... lines 1 - 6
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('api_platform.openapi.factory')]
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 12 - 23
}

That's it! Thanks to this, anyone that was previously using the core api_platform.openapi.factory service will receive our service instead. But the original one will be passed to us.

So... it should be working! To test it, head to the API homepage and refresh. Yes! When this page loads, it renders the OpenAPI JSON document in the background. The dump in the web debug toolbar proves that it hit our code! And check out that beautiful OpenApi object: it has everything including security, which matches what we saw in the JSON. So now, we can tweak that!

Customizing the OpenAPI Config

The code I'll put here is a bit specific to the OpenApi object and the exact config that I know we need in the final Open API JSON:

... lines 1 - 9
#[AsDecorator('api_platform.openapi.factory')]
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 13 - 16
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
... lines 22 - 26
return $openApi;
}
}

We fetch the $securitySchemes, and then override access_token. This matches the name we used in the config. Set that to a new SecurityScheme() object with two named arguments: type: 'http' and scheme: 'bearer':

... lines 1 - 5
use ApiPlatform\OpenApi\Model\SecurityScheme;
... lines 7 - 9
#[AsDecorator('api_platform.openapi.factory')]
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
... lines 13 - 16
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
$securitySchemes['access_token'] = new SecurityScheme(
type: 'http',
scheme: 'bearer',
);
return $openApi;
}
}

That's it! First refresh the raw JSON document so we can see what this looks like. Let me search for "Bearer". There we go! We modified what the JSON looks like!

What does Swagger think about this new config? Refresh and hit "Authorize". Ok cool: access_token, http, Bearer. Go steal an API token... paste without saying Bearer first and hit "Authorize". Let's test the same endpoint. Whoops, I need to hit "Try it out". And... gorgeous! Look at that Authorization header! It passed Bearer for us. Mission accomplished.

By the way, you might think, because we're completely overriding the access_token config, that we could just delete it from api_platform.yaml. Unfortunately, for subtle reasons that have to do with how the security documentation is generated, we do still need this. But I'll say # overridden in OpenApiFactoryDecorator:

api_platform:
... lines 2 - 7
swagger:
api_keys:
# overridden in OpenApiFactoryDecorator
access_token:
... lines 12 - 19

This was just one example of how you could extend your Open API spec doc. But if you ever need to tweak something else, now you know how.

Next, let's talk about scopes.

Leave a comment!

2
Login or Register to join the conversation
Oleh-K Avatar

Maybe something has been changed in API Platform with updates, since this video was recorded. I've tried, and securitySchemes hasn't been overridden.

        $securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
        $securitySchemes['access_token'] = new SecurityScheme(
            type: 'http',
            scheme: 'bearer',
        );

After some workaround I've stopped on this solution:

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

        $components = $decorated->getComponents()->withSecuritySchemes(
            new ArrayObject(
                [
                    'JWT' => new SecurityScheme(
                        type:         'http',
                        description:  'Value for the JWT Authorization header parameter.',
                        scheme:       'bearer',
                        bearerFormat: 'JWT'
                    )
                ]
            )
        );

        return $decorated->withComponents($components);
    }
Reply

Hey @Oleh-K!

Sorry for the slow reply! Hmm. I wonder: did you add the access_token config to api_platform.yaml? I actually think your solution is superior. My guess (I could be wrong) is that you don't have this config. And so, this line:

$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();

is using the new \ArrayObject part. But in my code, because I have the config, the $openApi->getComponents()->getSecuritySchemes() is returning an ArrayObject. The difference is that, in my case, I'm them "mutating" this existing object. But in your case, your "mutation" the new \ArrayObject(), which is then never actually set into the OpenApi object. Basically, my code was short-sighted and would ONLY work in the case where $openApi->getComponents()->getSecuritySchemes() DOES return an ArrayObject.

Am I correct? Does $openApi->getComponents()->getSecuritySchemes() return an ArrayObject or is it null? Anyway, your solution is superior, I believe, as it will work in call cases.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice