loading...

Use Swagger, Jane and Behat to document and test a Symfony API - part 2

matks profile image Mathieu Ferment ・6 min read

This post is the 2nd part of a previous post available here.

Jane

Now that they can read your swagger-powered documentation, developers start building cool apps using your API: javascript web apps, mobile apps ...

Here comes a developer who asks "Your API and your doc is great but I got to build an HTTP client that builds the right requests for your API, and parse the JSON responses to extract the data I want. Do you know if someone built such an API Client as a SDK so I do not have to rewrite it all ?".

He's right: it is a lot more important for him to focus on his app business logic and UI than to write code that build the expected HTTP requests for your API. He must focus on the "why" he wants to use the API instead of the "how".

What is interesting is that all the details of the "how" are provided in the Swagger file: routes, request body structures, responses formats and http codes, required http headers ...

API Client generation tools

There are a lot of tools that can build an API Client from a Swagger file for multiple programming languages. For PHP, there is Jane OpenAPI. Basically you give it your Swagger file and it generates the needed PHP classes. Pretty neat.

Here is an example of the generated PHP code to perform the GET HTTP call to fetch movies:

    /**
     * Get movies
     *
     * @param array  $parameters {
     *     @var string $order Order criterion
     *     @var string $dir Sort criterion
     *     @var int $page Page number
     * }
     * @param string $fetch      Fetch mode (object or response)
     *
     * @return \Psr\Http\Message\ResponseInterface|\ApiCycle\Generated\ApiMoviesClient\Model\MoviesViewDTO
     */
    public function getMovies($parameters = array(), $fetch = self::FETCH_OBJECT)
    {
        $queryParam = new QueryParam();
        $queryParam->setDefault('order', NULL);
        $queryParam->setDefault('dir', NULL);
        $queryParam->setDefault('page', NULL);
        $url = '/v1/movies';
        $url = $url . ('?' . $queryParam->buildQueryString($parameters));
        $headers = array_merge(array('Host' => 'localhost', 'Accept' => array('application/json')), $queryParam->buildHeaders($parameters));
        $body = $queryParam->buildFormDataString($parameters);
        $request = $this->messageFactory->createRequest('GET', $url, $headers, $body);
        $promise = $this->httpClient->sendAsyncRequest($request);
        if (self::FETCH_PROMISE === $fetch) {
            return $promise;
        }
        $response = $promise->wait();
        if (self::FETCH_OBJECT == $fetch) {
            if ('200' == $response->getStatusCode()) {
                return $this->serializer->deserialize((string) $response->getBody(), 'ApiCycle\\Generated\\ApiMoviesClient\\Model\\MoviesViewDTO', 'json');
            }
        }
        return $response;
    }

So the steps are:
1) generate the API Client classes using Jane
2) autoload them
3) start using them !

I put the generated classes in the generated/ folder of my API Client and I require it in my composer.json to autoload it:

{
  "name": "matks/movies-api-client",
  "license": "MIT",
  "type": "project",
  "autoload": {
    "psr-4": {
      "ApiCycle\\Generated\\ApiMoviesClient\\": "generated/"
    }
  }

Here an example of how I can use the generated php classes:

// in a Silex app
$resource = $app['app.api.api-client'];

$response1 = $resource->getMovies();

$body = new MoviesBody();
$body->setName('A new movie 2');

$response2 = $resource->createMovie($body);

And next time the API changes, for example /v1/movies become /v2/api/get-movies ; or the request body for the POST endpoint needs a new mandatory item, I can re-generate my API client to handle the new API requirements. The generated code is updated, but the code which uses it does not. The "how" is now fully automatic (and more reliable since it is written by a robot instead of a human).

Using behat and the API Client to test the API

Behat is a php framework that allows you to write test scenarios for your app.
Instead of writing tests such as:

public function testGetMoviesWithBadInput()
    {
        $client = static::createClient();

        $crawler = $client->request('GET', '/v1/movies?order=bad');
        $response1 = $client->getResponse();

        $expectedData1 = [
            'message' => 'Only allowed value for order is \'name\'',
            'error' => 'Bad query',
        ];

        $this->assertJsonResponse($response1, Response::HTTP_BAD_REQUEST);
        $this->assertJsonContent($response1, $expectedData1);

        $crawler = $client->request('GET', '/v1/movies?order=name&dir=a');
        $response2 = $client->getResponse();

        $expectedData2 = [
            'message' => 'Dir must be one of those: asc, desc',
            'error' => 'Bad query',
        ];

        $this->assertJsonResponse($response2, Response::HTTP_BAD_REQUEST);
        $this->assertJsonContent($response2, $expectedData2);
    }

You write scenarios that are human-readable such as:

  Scenario: Create a movie
    When I create a movie "Amazing new movie Z"
    Then I should receive a success response
    When I fetch page 1 from movies from the Api using an "asc" sorting on names
    Then I should receive a list of 3 movies with a total of 31
    And the list should contain:
      | movie name          |
      | Amazing new movie Z |

The fact that these scenarios are human-readable makes a big difference: it is so much easier to write, maintain and update. You can even consider it a runnable user-friendly documentation of how your app behaves.

If you want to discover it further, there are multiple tutorials and blog posts available about it such as this one.

Just to sum up how it works:

  • you write human-readable scenarios using this "When X Then Y" syntax called Gherkin in .feature files
  • you write PHP code which is able to translate these statements into PHP calls in FeatureContexts files
  • you run the tests with behat binary

The "translation" between "When I fetch movies from the Api" and an actual PHP call looks like this:

    /**
     * @When I fetch movies from the Api
     */
    public function fetchMoviesBasic()
    {
        try {
            $this->latestResponse = self::getApiClient()->getMovies();
        } catch (\Exception $e) {
            $this->handleExceptionsAfterAPICall($e);
        }
    }

This is where I use the generated API Client.

So I wrote a complete features set about my API:

Feature: Movies API
  In order to manage movies
  As an API Client
  I need to be able to use the Movies API

  Scenario: Get basic movies list
    When I fetch movies from the Api
    Then I should receive a list of 3 movies with a total of 30
    And the list should contain:
      | movie name         |
      | Fast and Furious 8 |
      | Taken 3            |

  Scenario: Get advanced movies list
    When I fetch page 3 from movies from the Api using an "desc" sorting on names
    Then I should receive a list of 3 movies with a total of 30
    And the list should contain:
      | movie name            |
      | Another great movie 6 |

  Scenario: Create a movie
    When I create a movie "Amazing new movie Z"
    Then I should receive a success response
    When I fetch page 1 from movies from the Api using an "asc" sorting on names
    Then I should receive a list of 3 movies with a total of 31
    And the list should contain:
      | movie name          |
      | Amazing new movie Z |

  Scenario: Create a movie twice and receive a bad response
    When I create a movie "Spiderman"
    Then I should receive a success response
    When I create a movie "Spiderman"
    Then I should receive a bad response

  Scenario: Delete a movie
    When I delete the movie 31
    Then I should receive a null response
    When I fetch page 1 from movies from the Api using an "asc" sorting on names
    Then I should receive a list of 3 movies with a total of 31
    And the list should NOT contain:
      | movie name          |
      | Amazing new movie Z |

  Scenario: Delete a movie twice and receive a bad response
    When I delete the movie 32
    Then I should receive a null response
    When I delete the movie 32
    Then I should receive a bad response

Then I wrote a complete FeatureContext to "translate" all the features into PHP functions which make sense, and it is runnable.

State reset for integration tests

An important point here is that these steps perform real HTTP calls to my API. Which means the API is running with a real database behind (either mysql or sqlite). That is why at the beginning of the test suite I need to reset the API state so that previous test runs do not impact this run.

I performed this with a Makefile-controlled process where I drop the database and recreate it then re-fill it with DataFixtures:

load_fixtures: ## load fixtures
    $(APP_CONSOLE) doctrine:schema:create
    $(APP_CONSOLE) doctrine:fixtures:load --no-interaction

reset_fixtures: ## load fixtures after cleaning the database
    $(APP_CONSOLE) doctrine:schema:drop --force --no-interaction
    $(MAKE) load_fixtures

However to be done right, it should be done by my behat test process and not outside of it in a Makefile. Behat allows it with a hooking process where you can perform some steps before the suite or before each scenario or before each step. Guess I'll update the project following this lead soon :) .

Conclusion

I was able to produce a Swagger specification file from my API which can be given to other developers as a documentation or can help them build API clients in whatever language they need. Which is very useful for writing apps that use the API ... or integration tests to check the API behavior is correct.

Tools are awesome :)

Discussion

pic
Editor guide