Tester à travers OpenAPI
ou comment valider votre documentation !

Stéphane Hulard

🤓 Directeur Technique ( CH Studio)

🎓 Formateur

🤝 Contributeur

OpenAPI ?

Qu'est-ce que c'est ?

Objectif: Normaliser et standardiser la description d'APIs

2009: Swagger specification

2015: Initative OpenAPI

2017: v3.0.0 2021: v3.1.0

Pourquoi ?

Interopérabilité, Automatisation, Fiabilité.

Bonnes pratiques

Design-First,Single Source of Truth,Source Control.

Un petit exemple

                            // openapi.yaml
openapi: 3.1.0
info:
    version: "1.0.0"
    title: My Awesome API
servers:
    - url: 'http://localhost:8000'
      description: Local development server
paths:
    /api/users/me:
        $ref: users/me.yaml
components:
    securitySchemes:
        GESSO:
            type: apiKey
            in: header
            name: sso_uid
                            
                        
                            // openapi.yaml
openapi: 3.1.0
info:
    version: "1.0.0"
    title: My Awesome API
servers:
    - url: 'http://localhost:8000'
      description: Local development server
paths:
    /api/users/me:
        $ref: users/me.yaml
components:
    securitySchemes:
        GESSO:
            type: apiKey
            in: header
            name: sso_uid
                            
                        
                            // users/me.yaml
get:
    operationId: getCurrentUser
    summary: |
        Retrieve the logged user details.
    security:
        - GESSO: [ ]
    responses:
        '200':
            description: |
                Successfully retrieved the details of the logged in user
            content:
                'application/json':
                    schema:
                        $ref: 'schema/user.yaml#/User'


                            
                        
                            // users/me.yaml
get:
    operationId: getUserMe
    summary: |
        Retrieve the logged user details.
    security:
        - GESSO: [ ]
    responses:
        '200':
            description: |
                Successfully retrieved the details of the logged in user
            content:
                'application/json':
                    schema:
                        $ref: 'schema/user.yaml#/User'


                            
                        
                            // users/me.yaml
get:
    operationId: getUserMe
    summary: |
        Retrieve the logged user details.
    security:
        - GESSO: [ ]
    responses:
        '200':
            description: |
                Successfully retrieved the details of the logged in user
            content:
                'application/json':
                    schema:
                        $ref: 'schema/user.yaml#/User'


                            
                        
                            // users/schema/user.yaml
User:
    type: object
    required:
        - name
        - email
    properties:
        name:
            type: string
            example: Jane Doe
        updatedAt:
            type: string
            format: date
            example: 2017-07-21
        email:
            type: string
            example: example@ge.com
                            
                        
                            // users/schema/user.yaml
User:
    type: object
    required:
        - name
        - email
    properties:
        name:
            type: string
            example: Jane Doe
        updatedAt:
            type: string
            format: date
            example: 2017-07-21
        email:
            type: string
            example: example@ge.com
                            
                        

Il était un projet d'API…

… qui est un projet client aussi !

🔧 OpenAPI à la rescousse !

Un langage descriptif……une suite d'outils !

⚠️ Divergence

Une documentation n'a de sens que si elle reflète l'état actuel de l'application !
— Maître Shifu, KungFu Panda 2008

🤖 Mock, générateur etc.

Créer un faux serveur, des requêtes, des réponses…

Créer un client pour intéragir avec l'API…

Générer de fausses données en utilisant le schéma…

✅ Validation

league/openapi-psr7-validator

✅ Validation

❤️ PSRs HTTP: Message, Client, Factories…

… conçu comme un middleware …

… framework agnostic …

… basé sur des outils fiables et maintenus.

🔌 Implémentation

💡 Idée générale

HTTP PHP Standard Recommandation

 7. Message: {Request,Response,ServerRequest}Interface

17. Factories: {Request,Uri,Stream}FactoryInterface

18. Client: ClientInterface

HttpFoundation ❤️ PSR

symfony/psr-http-message-bridge

                            use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

$symfonyRequest = $httpFoundationFactory->createRequest($psrServerRequest);

$psrResponse = $psrHttpFactory->createResponse($symfonyResponse);

                        

HttpKernel ❤️ PSR

                            use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\{RequestInterface,ResponseInterface};

class HttpKernelClient implements ClientInterface
{
    /* constructor */

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        /* transform RequestInterface to ServerRequestInterface */

        $response = $this->kernel->handle(
            $this->httpFoundationFactory->createRequest($serverRequest)
        );
        return $this->psrHttpFactory->createResponse($response);
    }
}

                        
                            use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\{RequestInterface,ResponseInterface};

class HttpKernelClient implements ClientInterface
{
    /* constructor */

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        /* transform RequestInterface to ServerRequestInterface */

        $response = $this->kernel->handle(
            $this->httpFoundationFactory->createRequest($serverRequest)
        );
        return $this->psrHttpFactory->createResponse($response);
    }
}

                        
                            use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\{RequestInterface,ResponseInterface};

class HttpKernelClient implements ClientInterface
{
    /* constructor */

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        /* transform RequestInterface to ServerRequestInterface */

        $response = $this->kernel->handle(
            $this->httpFoundationFactory->createRequest($serverRequest)
        );
        return $this->psrHttpFactory->createResponse($response);
    }
}

                        

🔧 Intégration validation

                            use League\OpenAPIValidation\PSR7;

/* Load OpenAPI doc */
$builder = (new PSR7\ValidatorBuilder)->fromYamlFile($yamlFile);

$builder->getRequestValidator()->validate($request);

/* Trigger request and get response */

$operation = new PSR7\OperationAddress('/api/users/me', 'get') ;
$builder->getResponseValidator()->validate($operation, $response);

                        
                            use League\OpenAPIValidation\PSR7;

/* Load OpenAPI doc */
$builder = (new PSR7\ValidatorBuilder)->fromYamlFile($yamlFile);

$builder->getRequestValidator()->validate($request);

/* Trigger request and get response */

$operation = new PSR7\OperationAddress('/api/users/me', 'get') ;
$builder->getResponseValidator()->validate($operation, $response);

                        
                            use League\OpenAPIValidation\PSR7;

/* Load OpenAPI doc */
$builder = (new PSR7\ValidatorBuilder)->fromYamlFile($yamlFile);

$builder->getRequestValidator()->validate($request);

/* Trigger request and get response */

$operation = new PSR7\OperationAddress('/api/users/me', 'get') ;
$builder->getResponseValidator()->validate($operation, $response);

                        
                            use League\OpenAPIValidation\PSR7;

/* Load OpenAPI doc */
$builder = (new PSR7\ValidatorBuilder)->fromYamlFile($yamlFile);

$builder->getRequestValidator()->validate($request);

/* Trigger request and get response */

$operation = new PSR7\OperationAddress('/api/users/me', 'get') ;
$builder->getResponseValidator()->validate($operation, $response);

                        

💡 Idée générale

On veut des tests métiers !

chstudio/raven

1. Validation par rapport à la documentation

2. Utilisation de vraies données pour tester

3. Vérification du comportement de l'API

Construire les requêtes ?

S'appuie sur fakerphp/faker

… résolution de données dynamique …

… ajout des contraintes de validation.

RequestFactory ❤️ PSR

                            $request = $requestFactory->fromArray([
    'uri' => [
        'base' => '/api/users/{id}',
        'parameters' => [
            '{id}' => '<userId()>'
        ]
    ],
    'method' => 'POST',
    'body' => [
        'name' => '<name()>',
        'creationDate' => '<date("Y-m-d")>',
        'office' => '<officeId("LYON")>'
    ]
]);

                        
                            $request = $requestFactory->fromArray([
    'uri' => [
        'base' => '/api/users/{id}',
        'parameters' => [
            '{id}' => '<userId()>'
        ]
    ],
    'method' => 'POST',
    'body' => [
        'name' => '<name()>',
        'creationDate' => '<date("Y-m-d")>',
        'office' => '<officeId("LYON")>'
    ]
]);

                        

Expectations

                            use CHStudio\Raven\Validator\Expectation\ExpectationFactory;

$requestData = [
    'uri' => 'http://myhost.com/api/users/me',
    'method' => 'GET',
    'statusCode' => 403
];

$expectations = (new ExpectationFactory())->fromArray($requestData);
$request = $requestFactory->fromArray($requestData);

$executor->execute($request, $expectations);

                        
                            use CHStudio\Raven\Validator\Expectation\ExpectationFactory;

$requestData = [
    'uri' => 'http://myhost.com/api/users/me',
    'method' => 'GET',
    'statusCode' => 403
];

$expectations = (new ExpectationFactory())->fromArray($requestData);
$request = $requestFactory->fromArray($requestData);

$executor->execute($request, $expectations);

                        

Raven ❤️ PHPUnit

                            use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class OpenApiTest extends WebTestCase
{
    /** @dataProvider giveRequests */
    public function testThroughOpenApi(array $requestData)
    {
        $httpClient = new HttpKernelClient(self::createKernel());

        /** [...] */
    }

    public function giveRequests(): Generator
    {
        yield /** [...] */
    }
}

                        
                            $factory = Factory::fromYamlFile('doc/api/openapi.yaml');
$executor = new Executor(
    $httpClient,
    $factory->getRequestValidator(),
    $factory->getResponseValidator()
);

$request = $requestFactory->fromArray($requestData);
$expectations = $expectationFactory->fromArray($requestData);

try {
    $executor->execute($request, $expectations);
} catch (Throwable $e) {
    $message = $e->getMessage();
}

static::assertNull($message, $message ?? '');

                        
                            $factory = Factory::fromYamlFile('doc/api/openapi.yaml');
$executor = new Executor(
    $httpClient,
    $factory->getRequestValidator(),
    $factory->getResponseValidator()
);

$request = $requestFactory->fromArray($requestData);
$expectations = $expectationFactory->fromArray($requestData);

try {
    $executor->execute($request, $expectations);
} catch (Throwable $e) {
    $message = $e->getMessage();
}

static::assertNull($message, $message ?? '');

                        
                            $factory = Factory::fromYamlFile('doc/api/openapi.yaml');
$executor = new Executor(
    $httpClient,
    $factory->getRequestValidator(),
    $factory->getResponseValidator()
);

$request = $requestFactory->fromArray($requestData);
$expectations = $expectationFactory->fromArray($requestData);

try {
    $executor->execute($request, $expectations);
} catch (Throwable $e) {
    $message = $e->getMessage();
}

static::assertNull($message, $message ?? '');

                        
                            $factory = Factory::fromYamlFile('doc/api/openapi.yaml');
$executor = new Executor(
    $httpClient,
    $factory->getRequestValidator(),
    $factory->getResponseValidator()
);

$request = $requestFactory->fromArray($requestData);
$expectations = $expectationFactory->fromArray($requestData);

try {
    $executor->execute($request, $expectations);
} catch (Throwable $e) {
    $message = $e->getMessage();
}

static::assertNull($message, $message ?? '');

                        
                            $factory = Factory::fromYamlFile('doc/api/openapi.yaml');
$executor = new Executor(
    $httpClient,
    $factory->getRequestValidator(),
    $factory->getResponseValidator()
);

$request = $requestFactory->fromArray($requestData);
$expectations = $expectationFactory->fromArray($requestData);

try {
    $executor->execute($request, $expectations);
} catch (Throwable $e) {
    $message = $e->getMessage();
}

static::assertNull($message, $message ?? '');

                        

Raven ❤️ PHPUnit

                            /** Exemple d'erreur de validation dans PHPUnit */

✘ Through open api with users.yaml::/api/users/me
┐
├ Unexpected status code 500, expected 200
├ Failed asserting that false is true.
│
╵ /app/tests/Api/OpenApiTest.php:94
┴
                             
                        
                            /** Exemple d'erreur de validation dans PHPUnit */

✘ Through open api with users.yaml::/api/users/me
┐
├ Unexpected status code 500, expected 200
├ Failed asserting that false is true.
│
╵ /app/tests/Api/OpenApiTest.php:94
┴
                             
                        
                            /** Exemple d'erreur de validation dans PHPUnit */

✘ Through open api with users.yaml::/api/users/me
┐
├ Data validation failed for property data.attributes.email,
├ invalid value: array (
├   'name' => 'Jane Doe',
├   'updatedAt' => '2022-10-14'
├ )
├ Keyword validation failed: Required property 'email' must
├ be present in the object.
├
├ Failed asserting that false is true.
│
╵ /app/tests/Api/OpenApiTest.php:94
┴

                        
                            /** Exemple d'erreur de validation dans PHPUnit */

✘ Through open api with users.yaml::/api/users/me
┐
├ Data validation failed for property data.attributes.email,
├ invalid value: array (
├   'name' => 'Jane Doe',
├   'updatedAt' => '2022-10-14'
├ )
├ Keyword validation failed: Required property 'email' must
├ be present in the object.
├
├ Failed asserting that false is true.
│
╵ /app/tests/Api/OpenApiTest.php:94
┴

                        
                            /** Exemple d'erreur de validation dans PHPUnit */

✘ Through open api with users.yaml::/api/users/me
┐
├ Data validation failed for property data.attributes.email,
├ invalid value: array (
├   'name' => 'Jane Doe',
├   'updatedAt' => '2022-10-14'
├ )
├ Keyword validation failed: Required property 'email' must
├ be present in the object.
├
├ Failed asserting that false is true.
│
╵ /app/tests/Api/OpenApiTest.php:94
┴

                        

🤩 Doc testée et validée !

Respect du schéma …

… requêtes basé sur des vraies données …

… vérifications spécifiques au projet.

Et maintenant ?

Pourquoi ne pas essayer ?

Un projet GitHub avec un README,

Une utilisation des standards,

Contributions bienvenues 😜.

Comment démarrer ?

Tout ne doit pas être documenté …

… un point d'API à la fois …

… enrichir les tests avec les cas particuliers.

🧌 Préparez vous au DDD

Documentation Driven Design

Documenter l'API, écrire les requêtes …… puis coder !

@s_hulard

https://github.com/chstudio/raven