When using Nelmio with serialization groups, there’s a “small” caveat. As mentioned in the documentation:
If you are using serialization contexts (e.g. Groups) each permutation will be treated as a separate Path. – source
This means that for each serialization group, the number of generated paths will grow really fast, since it provides each permutation with its own path. For example, if you have list and detail groups, you’ll have (2 custom + 1 default)! = 6 possible paths.
While it seems to be a non-issue at first, imagine when you’re building a structure to consume the API and can’t decide whether User1 and User23 can be simplified into a union type before actually inspecting the actual response. Worse still, any changes in backend code that affect either serialization group or parsing logic would remove any guarantee that the variant number still refer to the same structure. Madness!
The developers’ provided workaround, defining alternative names, works up to a point. Once the number of objects and serialization groups grow considerably, the risk from misconfiguration and simple negligence grows. One missed definition and we’re back to square one.
The Fix
Fortunately, there are multiple ways to work around the issue. One that I prefer is to leverage a combination of custom attributes and Symfony commands. The main idea is to specify and generate the alias definition using the same process as the spec generation itself, using attribute.
For the following example, we’ll use Symfony version 7.2. This example also assumes that you have the annotated classes under a common namespace and directory. We’ll use App\Dto.
Preparation
Let’s start by splitting up the Nelmio API Doc config.
imports:
- {resource: ../openapi/}
when@dev:
nelmio_api_doc:
use_validation_groups: true
documentation:
info:
title: Your API
description: some stuff
version: 1.0.0
security:
- Bearer: [ ]
models: # left this key empty
# the rest of the config goes below
We will extract model definition into a new file. This will allow us to generate them without touching the main config, which will benefit us from both traceability and testability. The new file (located inside the openapi subdirectory) should contain:
when@dev:
nelmio_api_doc:
models:
use_jms: false
names:
# list of aliases, e.g.
- { alias: GenericError, type: App\Dto\Error\Base }
Note the use_jms: false. Due to how YAML import and merge works in Symfony, we need to define any config under models here. Otherwise it gets lost during the merge process.
The Attribute Class
Now, let’s move on to our custom attribute, as shown in the following code.
<?php
declare(strict_types=1);
namespace App\OpenApi;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class AlternativeName
{
public string $name;
/**
* @var string[]
*/
public array $groups = [];
/**
* @param string $name the name of the OpenAPI schema
* @param string[] $groups the serialization groups to be included
*/
public function __construct(
string $name = '',
array $groups = []
) {
$this->name = $name;
$this->groups = $groups;
}
}
Pay attention to the highlighted part; the Attribute::TARGET_CLASS will enforce its usage to only be defined as a class-level attribute. While Attribute::IS_REPEATABLE ensures we can define multiple AlternativeName attributes for each class.
As for the attribute properties, nothing really stands out. Alternative names require at least two properties (name and type), one of which will be auto-generated from the class path. The groups property is optional; leaving it blank would set the alias as the default.
The Command
Now comes the fun (and important) part. We’ll use Symfony Command as our interaction method of choice. Having it defined as a command means we can either trigger it manually, or add it to some kind of trigger (e.g., git pre-commit hook) to automate the process.
<?php
namespace App\OpenApi;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Yaml\Yaml;
#[AsCommand(
name: 'app:oa:parse',
description: 'Parse OpenAPI Attributes and update nelmio schema',
)]
class ParseAttributeCommand extends Command
{
/**
* Additional config values to be added under 'models' in the generated YAML output.
*
* @var array<string, mixed>
*/
private array $additionalConfig = [
'use_jms' => false,
];
protected function configure(): void
{
$this
->setHelp('This command allows you to parse OpenAPI attributes from classes and generate nelmio aliases.')
->addArgument('namespace', InputArgument::REQUIRED, 'The namespace of the classes to parse (e.g., App\Dto)')
->addArgument('basepath', InputArgument::REQUIRED, 'The base path for the namespace (e.g., src\Dto)')
->addArgument('output', InputArgument::OPTIONAL, 'The output file to write the parsed attributes to', 'php://stdout');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
[$namespace, $basepath, $outputFile] = $this->parseInputArguments($input, $output);
$classes = $this->findAndReportClasses($namespace, $basepath, $output);
$data = $this->parseAttributesForClasses($classes, $output);
return $this->handleOutput($data, $outputFile, $output);
}
We define an app:oa:parse command that will take three inputs, two of which are required. We define the output parameter as optional to make sure we can test the command by simply executing it.
As seen above, main body of execute is rather simple and self-documenting. Here’s what each method does:
Input Processing
private function parseInputArguments(InputInterface $input, OutputInterface $output): array
{
$namespace = $input->getArgument('namespace');
$basepath = $input->getArgument('basepath');
$outputFile = $input->getArgument('output');
$output->writeln(sprintf(
'<info>Parsing attributes for classes under %s in %s</info>',
$namespace,
$basepath
));
return [$namespace, $basepath, $outputFile];
}
Parsing of input arguments is handled by a dedicated method. We can do extra work inside parseInputArguments, e.g., validating the supplied params and returning an empty array which can be interpreted as Command::INVALID on execute. Note: for the sake of brevity, we don’t implement it here.
private function findAndReportClasses(string $namespace, string $basepath, OutputInterface $output): array
{
$classes = $this->getClassesInNamespace($namespace, $basepath);
$output->writeln(sprintf('<info>Found %d classes</info>', count($classes)));
return $classes;
}
private function getClassesInNamespace(string $namespace, string $basepath): array
{
$classes = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($basepath, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && 'php' === $file->getExtension()) {
$relativePath = str_replace([$basepath.'/', '.php'], '', $file->getPathname());
$className = $namespace.'\\'.str_replace('/', '\\', $relativePath);
$classes[] = $className;
}
}
return $classes;
}
We define two separate methods for handling class lookup with the same ideas in mind: future extensibility. If the classes are spread out across different namespaces, we can iterate on each inside findAndReportClasses. The actual filesystem traversal is handled by getClassesInNamespace, which takes both namespace and physical location, then iterates down the path recursively.
As a fun side note, RecursiveIteratorIterator sounds a bit mouthful, until you realize that it is an iterator for recursive iterators. While the name still personally feels “wrong,” from a communication standpoint it communicates the purpose quite clearly without being overly obtuse.
The Core Logic
The core logic itself is rather simple once you get the gist of it. We use reflection to construct the class and extract the attributes. Since our DTOs will be littered with a lot of other irrelevant attributes, make sure to only fetch AlternativeName. Once retrieved, we store it inside a simple array, to be collected by the caller.
private function parseAttribute(string $class): array
{
$reflectionClass = new \ReflectionClass($class);
$attributes = $reflectionClass->getAttributes(AlternativeName::class);
$results = [];
foreach ($attributes as $attribute) {
$args = $attribute->getArguments();
$results[] = [
'alias' => $args['name'],
'groups' => $args['groups'] ?? [],
'type' => $class,
];
}
return $results;
}
The caller, parseAttributesForClasses, is the one who actually gets called by execute. The bulk of the code is mostly overhead from logging. At its core, the method only iterates on each class and merges the results.
private function parseAttributesForClasses(array $classes, OutputInterface $output): array
{
$data = [];
foreach ($classes as $class) {
try {
if ($output->isVerbose()) {
$output->writeln(sprintf('<info>Parsing attributes for %s</info>', $class));
}
$results = $this->parseAttribute($class);
if (empty($results)) {
continue;
}
foreach ($results as $result) {
if ($output->isVeryVerbose()) {
$output->writeln(sprintf(
'Alias: <comment>%s</comment>, Type: <comment>%s</comment>, Group: <comment>%s</comment>',
$result['alias'],
$result['type'],
join(', ', $result['groups'])
));
}
}
$data = array_merge($data, $results);
} catch (\ReflectionException $e) {
$output->writeln(sprintf('<error>Error parsing class %s: %s</error>', $class, $e->getMessage()));
}
}
return $data;
}
Output Handling
Last, we need to handle the output. Again, the code is a bit bulky; partly because we need to handle filesystem errors and communicate them, partly due to how we define the target structure inside it.
private function handleOutput(array $data, string $outputFile, OutputInterface $output): int
{
if (empty($data)) {
$output->writeln('<info>No aliases found</info>');
return Command::SUCCESS;
}
$output->writeln(sprintf('<info>Found %d aliases</info>', count($data)));
$struct = [
'when@dev' => [
'nelmio_api_doc' => [
'models' => [
...$this->additionalConfig,
'names' => $data
]
]
]
];
$out = Yaml::dump($struct, 5, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
if ('php://stdout' === $outputFile) {
$output->writeln('<info>Generated alias map:</info>');
$output->writeln($out);
} elseif (false === file_put_contents($outputFile, $out)) {
$output->writeln(sprintf('<error>Failed to write to output file %s</error>', $outputFile));
return Command::FAILURE;
} else {
$output->writeln(sprintf('<info>Output written to %s</info>', $outputFile));
}
return Command::SUCCESS;
}
Trying It Out
To make use of our new attribute, we just need to use it in our class. Let’s take a look at a simple example of a User DTO. This DTO has two groups: list which will only show id and fullName, and detail which will add extra data like birthday.
<?php
namespace App\Dto\User;
// other imports
use Symfony\Component\Serializer\Attribute\Groups;
use OpenApi\Attributes as OA;
use App\OpenApi\AlternativeName;
// other imports
#[AlternativeName(name: 'UserDetail', groups: ['detail'])]
#[AlternativeName(name: 'UserList', groups: ['list'])]
class User
{
#[Groups(['list', 'detail'])]
#[OA\Property(example: '1CEpNn5sbMBmXWywGzdf6Z')]
private ?Ulid $id = null;
#[Groups(['list', 'detail'])]
#[OA\Property(example: 'Jane Doe')]
private ?string $fullName = null;
#[Groups(['detail'])]
#[OA\Property(pattern: '/\d{2}-\d{2}-\d{4}/', example: '31-12-1990')]
private ?\DateTimeImmutable $birthday = null;
// the rest of the DTO definition
Running our command without specifying any output file will show us the generated YAML:
bambang@mbp3 sample i› bin/console app:oa:parse 'App\Dto' ./src/Dto
Parsing attributes for classes under App\Dto in ./src/Dto
Found 92 classes
Found 2 aliases
Generated alias map:
when@dev:
nelmio_api_doc:
models:
use_jms: false
names:
- { alias: UserDetail, groups: [detail], type: App\Dto\User\User }
- { alias: UserList, groups: [list], type: App\Dto\User\User }
Nice! To save the config, we just need to pass a 3rd parameter, which is the location of the output file.
What’s Next
Since executing commands every time is tedious, we should automate the process. The details would depend on what kind of automation and version control you have in place, but generally you should always have properly configured documentation inside the revision tree. For example, here’s how I do it using lefthook and pre-commit hooks. stage_fixed makes sure when the command alters the state of schemas.yaml, it gets added to the staged files list.
pre-commit:
commands:
# other commands
# regenerate openapi spec
openapi_gen:
run: "php bin/console app:oa:parse App\\Dto src/Dto src/config/openapi/schemas.yaml"
skip:
- rebase
stage_fixed: true
# check for yaml validity
lint_config:
run: "php bin/console lint:yaml config"
# check for generated doc
openapi_check:
run: "php bin/console nelmio:apidoc:dump > /dev/null"
# other commands
Finally, there are some footnotes regarding this approach, and this post specifically:
- At the time of writing, Symfony 7.3 has already been released with improved DX for creating commands. Although the current command structure is not yet deprecated, I would recommend writing the command the 7.3 way since it has a cleaner structure.
- By parsing the YAML config and injecting our generated config at the right place in the tree, we can skip the config-splitting part. I didn’t cover it here because it would bloat the code with extra complexity.
- Lefthook let us define jobs. We can improve the hook by running generation process in separate job before parallel execution of YAML lint and output check.
The refactoring towards cleaner and more future-proof code is left as an exercise for the readers.
