Alessandro Lai / @AlessandroLai
sfday @ 127.0.0.1 - May 13th 2021, Online
APIs are the "common language" of web development
If you don't have a monorepo, you'll need a way to describe
what your APIs does and how it does it
Design by contract is an approach for designing software.From Wikipedia
[...]
software designers should define formal, precise and verifiable
interface specifications for software components,
[...]
all client components that invoke
an operation on a server component will meet the preconditions
[...]
[Otherwise] the inverse approach is taken, meaning that the server component
tests that all relevant preconditions hold true.
Which language to use? Which format?
openapi: 3.0.3
info:
title: Test API for Facile.it
version: '1.0'
description: Description of the APIs
contact:
name: Alessandro Lai
servers:
- url: 'https://openapi.facile.it'
description: Prod
paths:
/api/test:
get:
summary: Get the test information
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
# ...
/**
* List the rewards of the specified user.
*
* This call takes into account all confirmed awards, but not pending or refused awards.
*
* @Route("/api/{user}/rewards", methods={"GET"})
* @SWG\Response(
* response=200,
* description="Returns the rewards of an user",
* @SWG\Schema(
* type="array",
* @SWG\Items(ref=@Model(type=Reward::class, groups={"full"}))
* )
* )
* @SWG\Parameter(name="order", in="query", type="string", description="The field used to order rewards")
* @SWG\Tag(name="rewards")
* @Security(name="Bearer")
*/
public function fetchUserRewardsAction(User $user)
{
OA Spec Code
DANGEROUS:
code changes can accidentally alter the API contract!
OA Spec Code
All code can be seen in action at https://github.com/Jean85/symfony-openapi-examplecomposer require league/openapi-psr7-validator
psr7-pack
(see
symfony/recipes#911)
composer require psr7-pack
# same as
composer require symfony/psr-http-message-bridge nyholm/psr7
services:
Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory:
autowire: true
autoconfigure: true
sensio_framework_extra:
psr_message:
enabled: true
$validatorBuilder = (new \League\OpenAPIValidation\PSR7\ValidatorBuilder)
->setCache(new Psr16(...))
->fromYamlFile($yamlFile);
$validatorBuilder->getServerRequestValidator();
$validatorBuilder->getResponseValidator();
services:
_defaults:
autowire: true
autoconfigure: true
League\OpenAPIValidation\PSR7\ValidatorBuilder:
calls:
- ['fromYamlFile', ['%kernel.project_dir%/openapi.yaml']]
# cache pool from framework.cache.pools
- ['setCache', ['@cache.openapivalidator']]
League\OpenAPIValidation\PSR7\RequestValidator:
factory:
- '@League\OpenAPIValidation\PSR7\ValidatorBuilder'
- 'getRequestValidator'
class OpenApiClient extends KernelBrowser
{
protected function doRequest($request): Response
{
if ($this->validateRequest) {
$psr7request = $this->psrHttpFactory->createRequest($request);
$this->requestValidator->validate($psr7request);
}
$pathFinder = new PathFinder(
$this->responseValidator->getSchema(),
$request->getUri(),
$request->getMethod()
);
$matchingOperations = $pathFinder->search();
if (count($matchingOperations) !== 1) {
throw new \RuntimeException(
"Unexpected number of matches for {$request->getUri()}: " .
count($matchingOperations)
);
} // ...
// (continue)
try {
$this->responseValidator->validate(
$matchingOperations[0],
$this->psrHttpFactory->createResponse($response)
);
} catch (\... $exception) {
// You should catch and decorate the exceptions
// to make the failures "prettier"
}
return $response;
}
<?php
declare(strict_types=1);
use League\OpenAPIValidation\PSR7\RequestValidator as Psr7RequestValidator;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
class RequestValidatorEventListener
{
public function __construct(
private PsrHttpFactory $psrHttpFactory,
private RequestValidator $requestValidator,
) {}
public function onKernelController(ControllerEvent $event): void
{
$psr7request = $this->psrHttpFactory->createRequest($event->getRequest());
$this->requestValidator->validate($psr7request);
}
}
<?php
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use Symfony\Component\HttpFoundation\JsonResponse;
class ErrorEventListener
{
public function onKernelException(ExceptionEvent $event): void
{
// ...
$event->setResponse(
$this->createFromThrowable($event->getThrowable())
);
}
private function createFromThrowable(\Throwable $error): JsonResponse
{
if ($error instanceof ValidationFailed) {
return new JsonResponse([/* ... */], Response::HTTP_BAD_REQUEST);
}
// ...
}
}
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
// arbitrary extended properties:
"balance": 30,
"accounts": ["/account/12345", "/account/67890"]
}
composer require crell/api-problem
use Crell\ApiProblem\ApiProblem;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
$problem = new ApiProblem("You do not have enough credit.", "http://example.com/probs/out-of-credit");
// Defined properties in the API have their own setter methods.
$problem
->setDetail("Your current balance is 30, but that costs 50.")
->setInstance("http://example.net/account/12345/msgs/abc");
$problem['balance'] = 30;
$problem['accounts'] = [
"http://example.net/account/12345",
"http://example.net/account/67890"
];
return new JsonResponse($problem->asArray(), Response::HTTP_BAD_REQUEST);
Please rate my talk on Joind.in: https://joind.in/talk/39cea