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
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
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
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
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
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
);
}