Whether you’ve built a private or public-facing API, at some point you or your users are going to want to communicate with it. To make things easier you might want to open-source an SDK that other developers can install. In this article we’ll take a look at how you can approach building such an SDK.

Read Part 2: Making it configurable

Read Part 3: Making it testable

A history of HTTP clients

If you’re familiar with the PHP ecosystem then you have most likely used Guzzle at some point to make an API call. Laravel recently introduced their own HTTP client which wraps around Guzzle.

Don’t get me wrong though. It is a great package but it does add a very concrete dependency that has often resulted in conflicting dependencies due to separate packages requiring different versions of the same library.

To solve this problem a proposal was made in the form of PSR-18 with the goal of creating libraries where the HTTP client implementation is decoupled and can thus be easily swapped out. This PSR builds upon the previous PSR-7 and PSR-17 which standardized HTTP messages and got accepted in October 2018. Since then a lot of HTTP clients such as Guzzle and Symfony have adapted to PSR-18.

Building a PSR-18 compliant package

On a particular project, we were building an entirely new API that would be consumed by multiple services. Since we didn’t want each service to implement their own logic for interacting with the API, we decided to build a package to facilitate this.

We took a look at how popular SDKs such as GitHub and Sentry handled this and quickly noticed the same name coming back: PHP-HTTP‘s HTTPlug. It was essentially the predecessor to PSR-18 and it came with an implementation-independent plugin system so you could build the perfect HTTP client for your use case. Let’s take a look at how we approached building this client step-by-step.

To start building the client, we first need to require some dependencies. HTTPlug has a tutorial you can follow when you’re building a library. Note that this tutorial advises you to use the HTTPlug dependencies instead of the PSR ones.

Setting up our dependencies

The first step is to require the psr/http-client-implementation package. This is a virtual package which means that installing our package will fail if you do not require one of its implementations.

Our SDK will also require a PSR-7 and PSR-18 implementation to build the request and response objects. Within our SDK code we will have to rely on the interfaces made available through the PSR/http-factory and PSR/http-client packages so we will also add those to our list of dependencies:

composer require psr/http-factory psr/http-client

Now, when running composer update you will likely see the following error and composer providing a list of packages that implement this virtual package.

Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Root composer.json requires psr/http-client-implementation ^1.0, it could not be found in any version, but the following packages provide it:
      - guzzlehttp/guzzle Guzzle is a PHP HTTP client library
      - symfony/http-client Symfony HttpClient component
      - php-http/guzzle6-adapter Guzzle 6 HTTP Adapter
      - php-http/curl-client PSR-18 and HTTPlug Async client with cURL
      - kriswallsmith/buzz Lightweight HTTP client
      - typo3/cms-core The core library of TYPO3.
      - php-http/mock-client Mock HTTP client
      - php-http/socket-client Socket client for PHP-HTTP
      - php-http/guzzle7-adapter Guzzle 7 HTTP Adapter
      - php-http/guzzle5-adapter Guzzle 5 HTTP Adapter
      - typo3/cms TYPO3 CMS is a free open source Content Management Framework initially created by Kasper Skaarhoj an
      - ricardofiorani/guzzle-psr18-adapter A Guzzle PSR-18 adapter
      - mjelamanov/psr18-guzzle A PSR-18 adapter for guzzle 6 client
      - voku/httpful A Readable, Chainable, REST friendly, PHP HTTP Client
      - windwalker/http Windwalker Http package
      - webclient/fake-http-client Mock PSR-18 HTTP client
      - swisnl/php-http-fixture-client Fixture client for PHP-HTTP
      - dockflow/guzzle Guzzle is a PHP HTTP client library
      - webclient/ext-redirect Redirect extension for PSR-18 HTTP client
      - juststeveking/http-slim A slim psr compliant http client to provide better interoperability
      ... and 32 more.
      Consider requiring one of these to satisfy the psr/http-client-implementation requirement.

To continue we will need to install one of these implementations to satisfy the requirements. For development purposes we will require these as dev dependencies which will give application developers that are using our SDK the opportunity to provide their own implementations of these interfaces. In this case, I will be using HTTPlug’s cURL client and Laminas Diactoros, but you are free to bring your own.

composer require --dev php-http/curl-client laminas/laminas-diactoros

Finally, we’ll be adding the php-http/client-common dependency which adds some useful wrappers around our HTTP client to easily use specific HTTP methods and the ability to include plugins.

composer require php-http/client-common

Starting out

The core part will be the ClientBuilder. This class will be responsible for creating the actual HTTP Client that will be used by the library.

 // ClientBuilder.php
 use PsrHttpClientClientInterface;
 use PsrHttpMessageRequestFactoryInterface;
 use PsrHttpMessageStreamFactoryInterface;

 final class ClientBuilder
 {
     private ClientInterface $httpClient;
     private RequestFactoryInterface $requestFactoryInterface;
     private StreamFactoryInterface $streamFactoryInterface;

     public function __construct(
         ClientInterface $httpClient,
         RequestFactoryInterface $requestFactoryInterface,
         StreamFactoryInterface $streamFactoryInterface
     ) {
         $this->httpClient = $httpClient;
         $this->requestFactoryInterface = $requestFactoryInterface;
         $this->streamFactoryInterface = $streamFactoryInterface;
     }
 } 

One of the cool things about HTTPlug is that it has a Discovery component. The purpose of this component is to find the concrete implementations of an interface, based on the dependencies that are already installed. This allows us to simplify the constructor of our ClientBuilder to use the discovery approach. We’ll still allow package users to provide their own concrete implementations.

 // ClientBuilder.php
 use HttpDiscoveryHttpClientDiscovery;
 use HttpDiscoveryPsr17FactoryDiscovery;
 use PsrHttp\ClientClientInterface;
 use PsrHttp\MessageRequestFactoryInterface;
 use PsrHttp\MessageStreamFactoryInterface;

 final class ClientBuilder
 {
     private ClientInterface $httpClient;
     private RequestFactoryInterface $requestFactoryInterface;
     private StreamFactoryInterface $streamFactoryInterface;
     public function __construct(
         ClientInterface $httpClient = null,
         RequestFactoryInterface $requestFactoryInterface = null,
         StreamFactoryInterface $streamFactoryInterface = null
    ) {
         $this->httpClient = $httpClient ?: HttpClientDiscovery::find();
         $this->requestFactoryInterface = $requestFactoryInterface ?: Psr17FactoryDiscovery::findRequestFactory();
         $this->streamFactoryInterface = $streamFactoryInterface ?: Psr17FactoryDiscovery::findStreamFactory();
    }
 }

If we were to dump(new ClientBuilder) we’d see the following result:

 ^ ApiClientClientBuilder^ {#5
 -httpClient: HttpClientCurlClient^ {#6
 -curlOptions: array:3 [
     52 => false
     42 => false
     19913 => false
 ]
 -responseFactory: LaminasDiactorosResponseFactory^ {#7}
 -streamFactory: LaminasDiactorosStreamFactory^ {#8}
 -handle: null
 -multiRunner: null
 }
 -requestFactoryInterface: LaminasDiactorosRequestFactory^ {#9}
 -streamFactoryInterface: LaminasDiactorosStreamFactory^ {#10}
 } 

Now that we have our dependencies sorted out, it’s time to start adding some behavior to our ClientBuilder. We mainly want it to provide us with an HTTP client. To make it easier for ourselves later on, we’ll make this an HttpMethodsClientInterface. This is essentially a decorator around the HttpClientInterface implementation that extends it with methods such as get, post, and delete.

// ClientBuilder.php
use HttpClientCommonHttpMethodsClient;
use HttpClientCommonHttpMethodsClientInterface;

public function getHttpClient(): HttpMethodsClientInterface
{
    return new HttpMethodsClient(
        $this->httpClient,
        $this->requestFactoryInterface,
        $this->streamFactoryInterface
    );
} 

Adding plugins

As mentioned earlier, one of HTTPlug’s strengths is that it comes with an extensive plugin system. We’ll extend our ClientBuilder with this functionality by adding an addPlugin method and decorate our injected/discovered HttpClient with a PluginClient using the included PluginClientFactory.

use HttpClientCommonPlugin;
use HttpClientCommonPluginClientFactory;

private array $plugins = [];

public function addPlugin(Plugin $plugin): void
{
    $this->plugins[] = $plugin;
}

public function getHttpClient(): HttpMethodsClientInterface
{
    $pluginClient = (new PluginClientFactory())->createClient($this->httpClient, $this->plugins);

    return new HttpMethodsClient(
        $pluginClient,
        $this->requestFactoryInterface,
        $this->streamFactoryInterface
    );
} 

With this added method we can now easily add certain plugins to extend our HTTP client. For example, we can set a default header to indicate we want a JSON response.

 $clientBuilder = new ClientBuilder();
 $clientBuilder->addPlugin(new HeaderDefaultsPlugin([
     'Accept' => 'application/json',
 ])); 

Building our SDK

So far all we’ve done is wired up the dependencies that we need. It’s time to start adding specific functionalities for our own API. We’ll start by adding an Sdk class that will house all of our API logic.

The class takes the ClientBuilder as an optional argument so that it can be tweaked in userland if needed. We’re also adding a proxy method to retrieve the actual HTTP client we can use to make requests.

 // Sdk.php
final class Sdk
{
    private ClientBuilder $clientBuilder;

    public function __construct(ClientBuilder $clientBuilder = null)
    {
        $this->clientBuilder = $clientBuilder ?: new ClientBuilder();
    }

    public function getHttpClient(): HttpMethodsClientInterface
    {
        return $this->clientBuilder->getHttpClient();
    }
}

$sdk = new Sdk($clientBuilder);
$sdk->getHttpClient()->get('<https://jsonplaceholder.typicode.com/todos/>');

To make the developer experience a bit nicer we can ship our SDK with several sensible defaults.

  • Configure the base URI
  • Add default Accept and User-Agent headers.
 // Sdk.php
public function __construct(ClientBuilder $clientBuilder = null, UriFactory $uriFactory = null)
{
    $this->clientBuilder = $clientBuilder ?: new ClientBuilder();
    $uriFactory = $uriFactory ?: Psr17FactoryDiscovery::findUriFactory();
    $this->clientBuilder->addPlugin(
        new BaseUriPlugin($uriFactory->createUri('https://jsonplaceholder.typicode.com'))
    );
    $this->clientBuilder->addPlugin(
        new HeaderDefaultsPlugin(
            [
                'User-Agent' => 'My Custom SDK',
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ]
        )
    );
}

// Adding these defaults simplifies our usage.
$response = $sdk->getHttpClient()->get('/todos');

Adding our endpoints

We already have a working SDK that a developer can integrate into their application. There are however several more improvements that can be made. One of those is adding syntactic sugar for our endpoints.

Since the example API we’re using is RESTful, we can create one class per resource.

 // Endpoint/Todos.php
final class Todos
{
    private Sdk $sdk;

    public function __construct(Sdk $sdk)
    {
        $this->sdk = $sdk;
    }

    public function all(): ResponseInterface
    {
        return $this->sdk->getHttpClient()->get('/todos');
    }
} 

We can then add a method to our SDK to easily get access to this class.

 // Sdk.php
public function todos(): Todos
{
    return new EndpointTodos($this);
}

// Now a developer can retrieve all the todo resources by simply doing:
$response = $client->todos()->all(); 

The php-github-api package makes extensive use of this pattern, even adding nested endpoints. I definitely suggest taking a stroll through the codebase!

Making our output usable

Up to now our SDK has been returning PSR-7 responses. In practice, a developer using our SDK probably does not want to deal with the hassles of decoding this response object to a format that works for them. So let’s optimize for the common case and handle this within our SDK by adding a small utility class to transform a response to an array.

 // HttpClient/Message/ResponseMediator.php
namespace ApiClientHttpClientMessage;

use PsrHttpMessageResponseInterface;

final class ResponseMediator
{
    public static function getContent(ResponseInterface $response): array
    {
        return json_decode($response->getBody()->getContents(), true);
    }
}

This class simply takes a PSR-7 response and returns the contents as an array. We can use this within our endpoint class:

// Endpoint/Todos.php
public function all(): array
{
    return ResponseMediator::getContent($this->sdk->getHttpClient()->get('/todos'));
    // [{userId: 1,id: 1,title: "delectus aut autem",completed: false},{userId: 1,id: 2,title: "quis ut nam facilis et officia qui",completed: false}]
} 

Conclusion

In this blog post we’ve covered the bare minimum to get started with building your own SDK, making use of several PSRs such as PSR-7, PSR-17, and PSR-18 .

If you’d like to explore the code we covered in this post you can head over to GitHub.

The world of SDK development is very interesting and there’s a lot of topics to cover such as how to make your SDK configurable, adding authentication into the mix and writing tests. Stay tuned!