Loïc Faugeron Technical Blog

Symfony / Web Services - part 3.3: Consuming, remote calls 25/03/2015

Deprecated: This series has been re-written - see The Ultimate Developer Guide to Symfony

This is the seventh article of the series on managing Web Services in a Symfony environment. Have a look at the six first ones:

You can check the code in the following repository.

In the previous article, we've created a Guzzle RequestHandler: we are now able to make remote calls using a third party library, but without the cost of coupling ourselves to it. If Guzzle 6 is released we'll have to change only one class, instead of everywhere in our application.

In this article, we'll create the actual remote calls.

Credential configuration

The web service we want to call requires us to authenticate. Those credentials shouldn't be hardcoded, we'll create new parameters for them (same goes for the URL):

# File: app/config/parameters.yml.dist
    ws_url: http://example.com
    ws_username: username
    ws_password: ~

We can then set those values in the actual parameter file:

# File: app/config/parameters.yml
    ws_url: "http://ws.local/app_dev.php"
    ws_username: spanish_inquisition
    ws_password: "NobodyExpectsIt!"

Note that because our password contains a character which is reserved in YAML (!), we need to put the value between double quotes (same goes for % and @).

Let's commit this:

git add -A
git commit -m 'Added credentials configuration'

Profile Gateway

We can create a Gateway specialized in calling the profile web service:

./bin/phpspec describe 'AppBundle\Profile\ProfileGateway'

Usually we categorize our Symfony applications by Pattern: we'd create a Gateway directory with all the Gateway service. However this can become quite cubersome when the application grows, services are usually linked to a model meaning that we'd have to jump from the Model (or Entity) directory to the Gateway one, then go to the Factory directory, etc...

Here we've chosen an alternative: group services by model. All Profile services can be found in the same directory.

Let's write the Gateway's specification:

<?php
// File: spec/AppBundle/Profile/ProfileGatewaySpec.php

namespace spec\AppBundle\Profile;

use AppBundle\RequestHandler\RequestHandler;
use AppBundle\RequestHandler\Response;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class ProfileGatewaySpec extends ObjectBehavior
{
    const URL = 'http://example.com';
    const USERNAME = 'spanish inquisition';
    const PASSWORD = 'nobody expects it';

    const ID = 42;
    const NAME = 'Arthur';

    function let(RequestHandler $requestHandler)
    {
        $this->beConstructedWith($requestHandler, self::URL, self::USERNAME, self::PASSWORD);
    }

    function it_creates_profiles(RequestHandler $requestHandler, Response $response)
    {
        $profile = array(
            'id' => self::ID,
            'name' => self::NAME,
        );

        $request = Argument::type('AppBundle\RequestHandler\Request');
        $requestHandler->handle($request)->willReturn($response);
        $response->getBody()->willReturn($profile);

        $this->create(self::NAME)->shouldBe($profile);
    }
}

We can now generate the code's skeleton:

./bin/phpspec run

It constructs a Request object, gives it to RequestHandler and then returns the Response's body:

<?php
// File: src/AppBundle/Profile/ProfileGateway.php

namespace AppBundle\Profile;

use AppBundle\RequestHandler\Request;
use AppBundle\RequestHandler\RequestHandler;

class ProfileGateway
{
    private $requestHandler;
    private $url;
    private $username;
    private $password;

    public function __construct(RequestHandler $requestHandler, $url, $username, $password)
    {
        $this->requestHandler = $requestHandler;
        $this->username = $username;
        $this->password = $password;
    }

    public function create($name)
    {
        $request = new Request('POST', $this->url.'/api/v1/profiles');
        $request->setHeader('Authorization', 'Basic '.base64_encode($this->username.':'.$this->password));
        $request->setHeader('Content-Type', 'application/json');
        $request->setBody(json_encode(array('name' => $name)));

        $response = $this->requestHandler->handle($request);

        return $response->getBody();
    }
}

Note: Managing URLs can become quite tricky when the number of routes grows. Sometimes we'll want HTTPS, sometimes HTTP. Sometimes we'll want the first version of the API, sometimes the pre production one. And what should we do when we'll need query parameters?

Usually I don't bother with those until the need is actually there, then I create a UrlGenerator which works a bit like Symfony's one and relies on a configuration array.

Let's check our tests:

./bin/phpspec run

All green!

git add -A
git commit -m 'Created ProfileGateway'

Create Profile Command

Our application happens to be a Command Line Interface (CLI). We want to write a command to create profiles, and as usual we'll begin with a test:

<?php
// File: tests/Command/CreateProfileCommandTest.php

namespace AppBundle\Tests\Command;

use PHPUnit_Framework_TestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;

class CreateProfileCommandTest extends PHPUnit_Framework_TestCase
{
    private $app;
    private $output;

    protected function setUp()
    {
        $kernel = new \AppKernel('test', false);
        $this->app = new Application($kernel);
        $this->app->setAutoExit(false);
        $this->output = new NullOutput();
    }

    public function testItRunsSuccessfully()
    {
        $input = new ArrayInput(array(
            'commandName' => 'app:profile:create',
            'name' => 'Igor',
        ));

        $exitCode = $this->app->run($input, $this->output);

        $this->assertSame(0, $exitCode);
    }
}

Let's make this test pass:

<?php
// File: src/AppBundle/Command/CreateProfileCommand.php

namespace AppBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CreateProfileCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this->setName('app:profile:create');
        $this->setDescription('Create a new profile');

        $this->addArgument('name', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $profileGateway = $this->getContainer()->get('app.profile_gateway');

        $profile = $profileGateway->create($input->getArgument('name'));

        $output->writeln(sprintf('Profile #%s "%s" created', $profile['id'], $profile['name']));
    }
}

We'll need to define ProfileGateway as a service:

# File: app/config/services.yml
imports:
    - { resource: services/request_handler.yml }

services:
    app.profile_gateway:
        class: AppBundle\Profile\ProfileGateway
        arguments:
            - "@app.request_handler"
            - "%ws_url%"
            - "%ws_username%"
            - "%ws_password%"

By having a look ProfileGateway we can spot a mistake, the initialization or URL is missing from the constructor:

<?php
// File: src/AppBundle/Profile/ProfileGateway.php

    public function __construct(RequestHandler $requestHandler, $url, $username, $password)
    {
        $this->requestHandler = $requestHandler;
        $this->username = $username;
        $this->password = $password;
        $this->url = $url;
    }

Another mistake lies in JsonResponseListener, each Guzzle header is an array:

<?php
// File: src/AppBundle/RequestHandler/Listener/JsonResponseListener.php

        $contentType = $response->getHeader('Content-Type');
        if (false === strpos($contentType[0], 'application/json')) {
            return;
        }

With these fixes, the test should pass:

phpunit -c app

Note: if we get a You have requested a non-existent service "app.profile_gateway" error, we might need to clear the cache for test environment: php app/console cache:clear --env=test.

Note: if we get a Guzzle exception, we need to check that the previous application installed ("ws.local"), and that its database is created:

cd ../ws
php app/console doctrine:database:create
php app/console doctrine:schema:create
cd ../cs

We can now save our work:

git add -A
git commit -m 'Created CreateProfileCommand'

Conclusion

We have now an application that consumes a web service. We have decoupled it from third party libraries using RequestHandler and isolated the endpoint logic in a Gateway class.

There's a lot to say about the test we wrote: it makes a network call which is slow, unreliable and it isn't immutable. If we try to run again our test, it will fail! To fix this we have many possibilities:

At this point it depends on how confident we are in the web services and what we want to test.

We should also write more test on edge cases: what happens with the wrong credentials? What happens if the endpoints cannot be reached (request timeout, connection timeout, etc)? What happens when we try to create a profile which already exists?

As it happens, this is also the conclusion of this series on managing Web Services in a Symfony environment. There's a lot more to say for example about caching remote resources in a local database, about self discovering APIs and about micro services, but I feel those should each have their own series of article :) .

Reference: see the phpspec reference article