Consuming External APIs: basic tips & tricks

By Leo Hajder -

How often do you have to consume an external API and integrate it into a Symfony application? Let’s see how to do it fast and/or well, and hopefully learn something along the way. For this example, we’re going to take a look at how to get a response from an API endpoint and render the data in a twig template.

Using cURL in the controller

You can just use cURL and get the response directly in your controller. It’s all in the same place. It’s readable. It’s simple. Maybe it’s even generated by some library. It works. It’s fine.

// src/Controller/ChuckNorrisJokeController.php
<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class ChuckNorrisJokeController extends AbstractController
{
    /**
     * @Route("/jokes", name="app_jokes")
     */
    public function jokes(): Response
    {
        $url = 'http://api.icndb.com/jokes/random/3?limitTo=[nerdy]&escape=javascript';

        $curl = curl_init();
        curl_setopt_array($curl, [
            CURLOPT_URL => $url,
            CURLOPT_CUSTOMREQUEST => 'GET',
            CURLOPT_RETURNTRANSFER => true,
        ]);

        $rawResponse = curl_exec($curl);
        $info = curl_getinfo($curl);
        curl_close($curl);

        if ($info['http_code'] === 200) {
             $response = json_decode($rawResponse, true);
             $jokes = $response['value'];
        }

        return $this->render('jokes.html.twig', [
            'jokes' => $jokes ?? [],
        ]);
    }
}

Hiding the logic

Well, maybe it can be better. What if you need to reuse the logic or something? Ok, let’s move the code responsible for getting the response data in a helper service. This way the controller stays as thin as possible and the implementation is obscured. You don’t see it. You don’t really know how it works at first glance. Honestly, you might not even care, as long as it does what it’s expected.

// src/Helper/ChuckNorrisJokeHelper.php
<?php

declare(strict_types=1);

namespace App\Helper;

class ChuckNorrisJokeHelper
{
      public function getRandomJokes(): array
      {
           $url = 'http://api.icndb.com/jokes/random/3?limitTo=[nerdy]&escape=javascript';

           $curl = curl_init();
           curl_setopt_array($curl, [
               CURLOPT_URL => $url,
               CURLOPT_CUSTOMREQUEST => 'GET',
               CURLOPT_RETURNTRANSFER => true,
           ]);

          $rawResponse = curl_exec($curl);
          $info = curl_getinfo($curl);
          curl_close($curl);

          if ($info['http_code'] !== 200) {
              return [];
          }

          $response = json_decode($rawResponse, true);

          return $response['value'];
     }
}

// src/Controller/ChuckNorrisJokeController.php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Helper\ChuckNorrisJokeHelper;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class ChuckNorrisJokeController extends AbstractController
{
     private ChuckNorrisJokeHelper $jokeHelper;

     public function __construct(ChuckNorrisJokeHelper $jokeHelper)
     {
          $this->jokeHelper = $jokeHelper;
     }

     /**
      * @Route("/jokes", name="app_jokes")
      */
     public function jokes(): Response
     {
          $jokes = $this->jokeHelper->getRandomJokes();

          return $this->render('jokes.html.twig', [
              'jokes' => $jokes,
          ]);
     }
}

Using the Symfony HTTP Client

Instead of writing raw PHP cURL requests, you can explore a few PHP HTTP client libraries, such as Guzzle, Buzz, HTTPlug, etc. There are many options to choose from, depending on what you like, what’s already inside your project, which version of PHP you’re running, etc. I’ll be using the Symfony HTTP Client.

Basic Use

This creates and sends the request, decodes the JSON response, and lots of other cool and useful things for us, so there’s less stuff for us to worry about. You can make synchronous and asynchronous requests with or without data, authentication, caching, etc. Please note that this showcases mainly the basic use and there is a lot more to explore. This is definitely not an in-depth guide.

// src/Helper/ChuckNorrisJokeHelper.php
<?php

declare(strict_types=1);

namespace App\Helper;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class ChuckNorrisJokeHelper
{
    private HttpClientInterface $client;

    public function __construct(HttpClientInterface $client)
    {
        $this->client = $client;
    }

    /**
     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
     */
     public function getRandomJokes(): array
     {
          $url = 'http://api.icndb.com/jokes/random/3?limitTo=[nerdy]&escape=javascript';

          $response = $this->client->request('GET', $url);
          $parsedResponse = $response->toArray();

          return $parsedResponse['value'];
     }
}

Using scoped clients

What if your application consumes more than one external API? You can set up multiple HTTP clients. This allows you to configure them differently, set base URIs, headers, timeout values and other parameters.

# config/packages/framework.yaml
framework:
    # ...
    http_client:
        scoped_clients:
            # ...
            chuck_norris_joke.client:
                base_uri: 'http://api.icndb.com'
            bored_api.client:
                base_uri: 'https://www.boredapi.com/api'
            # ...

 Specific scoped clients can be injected into specific services by passing a variable matching the client’s name.

// src/Helper/ChuckNorrisJokeHelper.php
// ..
public function __construct(HttpClientInterface $chuckNorrisJokeClient)
{
    $this->client = $chuckNorrisJokeClient;
}
// ... 

If you don’t believe in the magic of autowiring, you can register the helper as a service and inject the scoped client as an argument, without depending on the variable name. Make sure to use the service notation (add an @ in front of the client name).

// config/services.yaml
services:
# ...
    App\Helper\ChuckNorrisJokeHelper:
        arguments:
            - '@chuck_norris_joke.client'
// src/Helper/ChuckNorrisJokeHelper.php
// ...
public function __construct(HttpClientInterface $client)
{
    $this->client = $client;
}
// ...

Parameterizing stuff

It’s a good idea to use parameters, especially if you need to set different values for each environment (prod, staging, dev, test, local, etc.). Put anything that might change depending on the application environment in .env files as key-value pairs, and Inject the keys as service arguments.

# .env
# .env.dev
# .env.local
# ...
CHUCK_NORRIS_JOKES_API_BASE_URL="http://api.icndb.com"
CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES=3
# config/packages/framework.yaml
framework:
# ...
    http_client:
        scoped_clients:
            chuck_norris_joke.client:
                base_uri: '%env(CHUCK_NORRIS_JOKES_API_BASE_URL)%'
# config/services.yaml
services:
# ...
    App\Helper\ChuckNorrisJokeHelper:
        arguments:
            - '@chuck_norris_joke.client'
            - '%env(CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES)%'

You can even get the parameter values from the controllers (i.e. from the dependency injection container) if you need to.

# config/services.yaml
parameters:
    number_of_jokes: '%env(CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES)%'
// src/Helper/ChuckNorrisJokeHelper.php
// ...
public function getRandomJokes(int $numberOfJokes): array
{
    $path = '/jokes/random/' . $numberOfJokes;
    $response = $this->client->request('GET', $path, [
        'query' => [
            'limitTo' => '[nerdy]',
            'escape' => 'javascript',
        ],
    ]);
// ...
}
// src/Controller/ChuckNorrisJokeController.php
// ...
$numberOfJokes = (int) $this->getParameter('number_of_jokes');
$jokes = $this->jokeHelper->getRandomJokes($numberOfJokes);
// ...

Generating routes programmatically

Sometimes the API routes are complex, have a lot of query parameters, or the API is just big and you use many routes. What if I told you there are better ways of organizing them so that they are self-documenting and easy to maintain, change and generate programmatically?

Using the Symfony router

You can do that using the Symfony Router. Note that we switched to yaml routing from now on because it allows routes with no attached controllers. Also, note that the variables required by the route and the query string are now generated by the Symfony Router. You can set prefixes to entire routing resources, or not, it’s up to you.

# config/routes.yaml
# ...
app_jokes:
    path: /jokes
    methods: [GET]
    defaults:
        _controller: App\Controller\ChuckNorrisJokeController::jokes

# routes used to consume the Chuck Norris jokes API
chuck_norris_jokes_api:
    resource: 'routes/chuck_norris_jokes.yaml'
    name_prefix: 'chuck_norris_'
# ...
# config/routes/chuck_norris_jokes.yaml
# routes used internally, no controllers attached
random_jokes:
    path: /jokes/random/{numberOfJokes}
    methods: [GET]
    requirements:
        numberOfJokes: \d+
# ...
Using a scoped client

Everything you’ve read in this post so far leads to this scenario. You generate relative paths and the scoped client has the base URI set.

# config/services.yaml
services:
# ...
    App\Helper\ChuckNorrisJokeHelper:
    arguments:
        - '@chuck_norris_joke.client'
        - '@router'
        - '%env(CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES)%'
// src/Helper/ChuckNorrisJokeHelper.php
<?php

declare(strict_types=1);

namespace App\Helper;

use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ChuckNorrisJokeHelper
{
    private HttpClientInterface $client;
    private RouterInterface $router;
    private int $numberOfJokes;

    public function __construct(
        HttpClientInterface $client,
        RouterInterface $router,
        int $numberOfJokes
    ) {
        $this->client = $client;
        $this->router = $router;
        $this->numberOfJokes = $numberOfJokes;
    }

    /**
     * @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface   
     */
    public function getRandomJokes(): array
    {
        $path = $this->router->generate('chuck_norris_random_jokes', [
            'numberOfJokes' => $this->numberOfJokes,
            'limitTo' => '[nerdy]',
            'escape' => 'javascript',
        ]);

        $response = $this->client->request('GET', $path);
        $parsedResponse = $response->toArray();

        return $parsedResponse['value'];
    }
}
Not using a scoped client

You can further configure Symfony routes to generate with a specific host, scheme, etc. Note that now you have to generate absolute URLs because the client is not scoped.

# config/routes/chuck_norris_jokes.yaml
# routes used internally, no controllers attached
# ...
random_jokes:
    path: /jokes/random/{numberOfJokes}
    methods: [GET]
    host: '%chuck_norris_api_host%'
    schemes: [http]
    requirements:
        numberOfJokes: \d+
    defaults:
        port: 80
# ...
# .env
# .env.dev
# .env.local
# ...
CHUCK_NORRIS_JOKES_API_HOST="api.icndb.com"
// src/Helper/ChuckNorrisJokeHelper.php
// ...
$url = $this->generateUrl('chuck_norris_random_jokes', [
    'numberOfJokes' => $this->numberOfJokes,
    'limitTo' => '[nerdy]',
    'escape' => 'javascript',
], UrlGeneratorInterface::ABSOLUTE_URL);

$response = $this->client->request('GET', $url);
$parsedResponse = $response->toArray();
// ...

Using http_build_query

If you’re not using either the Symfony Router or a scoped client, at least make the routes easier to generate by concatenating the API route and the query string generated by http_build_query

# .env
# .env.dev
# .env.local
# ...
CHUCK_NORRIS_JOKES_API_BASE_URL="http://api.icndb.com"
CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES=3
# config/services.yaml
services:
# ...
    App\Helper\ChuckNorrisJokeHelper:
        arguments:
            - '%env(CHUCK_NORRIS_JOKES_API_BASE_URL)%'
            - '%env(CHUCK_NORRIS_JOKES_API_NUMBER_OF_RANDOM_JOKES)%'
// src/Helper/ChuckNorrisJokeHelper.php
<?php

declare(strict_types=1);

namespace App\Helper;

class ChuckNorrisJokeHelper
{
    private string $apiBaseUrl;
    private int $numberOfJokes;

    public function __construct(
        string $apiBaseUrl,
        int $numberOfJokes
    ) {
        $this->apiBaseUrl = $apiBaseUrl;
        $this->numberOfJokes = $numberOfJokes;
    }

    public function getRandomJokes(): array
    {
        $route = $this->apiBaseUrl . '/jokes/random/' . $this->numberOfJokes;
        $queryString = http_build_query([
            'limitTo' => '[nerdy]',
            'escape' => 'javascript',
        ]);
        $url = implode('?', [
            $route,
            $queryString
        ]);

        // use cURL to make the request
        // ...
    }
}

Conclusion

I hope you get something from this post and that it makes things easier for yourself and others, whichever method works best for you. More maintainable and readable code is always something to strive for. This example describes consuming a single API route, but my thoughts are that most of these concepts could be helpful in preserving clarity and separation when consuming several bigger APIs.

Comments

This site uses cookies. Some of these cookies are essential, while others help us improve your experience by providing insights into how the site is being used. For more detailed information on the cookies we use, please check our Privacy Policy.