Documentation is important in the field of software development. It is the primary source for figuring out how a certain library, framework or application (programming) interface works. At the same time, you need to keep it up to date. This can sometimes feel like redundant work. In a lot of cases, this redundancy can be avoided.
- Autor
- Arjan Frans
- Datum
- 28. Juni 2023
- Lesedauer
- 3 Minuten
Source code is documentation
Your source code will always be the best documentation. At least for fellow programmers who understand the language. If your software is aimed to be used by other software developers anyway, why should you even bother with writing documentation? Sometimes it can feel like you're doing everything twice: you write your code and then you write your documentation which describes the same thing.
In the PHP/Symfony ecosystem, we developed a few tools that can help with generating automated documentation. Below, you will find a brief description of how it works. To skip right to the full example, check out this repository.
Documenting routes
A PHP implementation of the OpenAPI Standard along with the NelmioApiDocBundle makes it possible to easily generate documentation from your controller endpoints. Here is an example of how it can be used:
#[Route('/{id}', methods: 'POST')]
#[OA\RequestBody(
required: true,
content: new Model(type: UpdateContactRequest::class))]
#[OA\Response(
response: 200,
description: 'ContactResponse',
content: new Model(type: ContactResponse::class),
)]
As you can tell, there's quite a bit of code needed to fully document the controller action. It also seems redundant to use the same models in the actual code when they're already strictly typed.
Typed input and output
Incoming requests are converted into strictly typed objects by using our http-kernel-extensions library. Using the `#[FromRequest]` attribute we can indicate which argument is supposed to be the incoming request object.
public function exampleAction(
#[FromRequest] UpdateContactRequest $request // Automatically mapped input model
): ContactResponse
{
// ..
}
To be able to return an object directly from a controller we can utilize Symfony's kernel.view event. Inside an event subscriber we can then convert the object into an actual response.
final class ViewEventSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly NormalizerInterface $normalizer)
{
}
public function onKernelView(ViewEvent $event): void
{
$view = $event->getControllerResult();
if (null === $view) {
$response = new JsonResponse('', Response::HTTP_NO_CONTENT);
} else {
$response = new JsonResponse($this->normalizer->normalize($view));
}
$event->setResponse($response);
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::VIEW => ['onKernelView'],
];
}
}
The "Documented" Route
To prevent redundant type definitions, we developed a custom route attribute in our api-documentation-bundle. It automatically reads the input and output types defined in the function signature or docblocks in case of arrays or generics. A controller action can now look like the following and will automatically be documented without any extra annotations.
#[DocumentedRoute('/{id}', methods: 'PATCH')]
public function updateContactAction(#[FromRequest] UpdateContactRequest $request): ContactResponse
{
// ...
}
If you now check out the generated documentation it will automatically show all the strictly typed objects for input and output.
Conclusion
By combining the two bundles we developed we have reduced the redundancy of having to define the input and output type multiple times. Input and output are strictly typed and most of the endpoints, which already have their obvious usage described in their name, now require less code. All the OpenApi attributes required for these use cases have been abstracted away. Flexibility is retained by allowing you to still use all the OpenApi attributes in combination with the DocumentedRoute
attribute.
Check out the complete example.