This post is part of the “Building an SDK with PHP” series. Read Part 1: building an SDK.
If you’ve followed along with the last post you have created a SDK in PHP while leveraging various PSRs such as PSR-17 and PSR-18. Today we’ll take this a step further and allow developers using our SDK to configure it how they want. This can be useful in case you want to expose certain options specific to your SDK; some common examples are using a custom URL or configuring the credentials for an oAuth2 client. Let’s dive in!
Making our base URL configurable.
In our current iteration of the SDK we have the URL hard-coded via the BaseUriPlugin
in the constructor.
// ClientBuilder.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',
]
)
);
}
This limits us in case you are building an SDK for your own API and want to have your staging environment connect to your staging API. Let’s try and get this fixed!
First of all, let’s decide on what we do NOT want to do. There are some SDKs out there that will look for environment variables through either the $_ENV
or $_SERVER
variables. I personally do not like this approach as it makes too many assumptions about how someone manages their environment. For our SDK we want to make it as flexible as possible so that a developer can provide the options in whichever way they want.
Let’s start by making some tweaks to the constructor of our Sdk class. Instead of passing the ClientBuilder
and UriFactoryInterface
, let’s pass in an Options
object.
We’ll allow passing several options through this object such as the ClientBuilder
and UriFactoryInterface
instances. If they are not provided, we will fallback to a default.
// Options.php
final class Options
{
private array $options;
public function __construct(array $options = [])
{
$this->options = $options;
}
public function getClientBuilder(): ClientBuilder
{
return $this->options['client_builder'] ?? new ClientBuilder();
}
public function getUriFactory(): UriFactoryInterface
{
return $this->options['uri_factory'] ?? Psr17FactoryDiscovery::findUriFactory();
}
public function getUri(): UriInterface
{
return $this->getUriFactory()->createUri($this->options['uri'] ?? 'https://jsonplaceholder.typicode.com');
}
}
// usage
$options = new Options([
'client_builder' => $clientBuilder,
'uri' => 'https://jsonplaceholder.madewithlove.com',
]);
$sdk = new Sdk($options);
Mission accomplished! We are now able to provide our own URI to the SDK.
Taking it a step further
One concern with the previous approach is that we have sacrificed type safety over flexibility. A developer could now provide an invalid value for uri
or client_builder
that would cause the application to break. Luckily there are several packages to help out with this. For our SDK, we will be using Symfony’s OptionsResolver component.
One of the ways this component helps us is by making it easy to provide default values. Using this component internally in our Options
class allows us to refactor it to the following.
final class Options
{
private array $options;
public function __construct(array $options = [])
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$this->options = $resolver->resolve($options);
}
private function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'client_builder' => new ClientBuilder(),
'uri_factory' => Psr17FactoryDiscovery::findUriFactory(),
'uri' => 'https://jsonplaceholder.typicode.com',
]);
}
public function getClientBuilder(): ClientBuilder
{
return $this->options['client_builder'];
}
public function getUriFactory(): UriFactoryInterface
{
return $this->options['uri_factory'];
}
public function getUri(): UriInterface
{
return $this->getUriFactory()->createUri($this->options['uri']);
}
}
Now, we can manage our default options in a single method instead of having it spread out across each getter. An additional benefit is that when a developer tries to use an option that is not supported, they will see a runtime exception:
PHP Fatal error: Uncaught SymfonyComponentOptionsResolverExceptionUndefinedOptionsException: The option "unsupported" does not exist. Defined options are: "client_builder", "uri", "uri_factory".
The second thing we can do is assure type safety for our options, which will result in another runtime exception when a developer passes in a value that does not match the type.
$resolver->setAllowedTypes('uri', 'string');
resolver->setAllowedTypes('client_builder', ClientBuilder::class);
$resolver->setAllowedTypes('uri_factory', UriFactoryInterface::class);
Finally, a neat thing this component allows us to do is to deprecate a specific option.
Conclusion
Thanks for following along with part 2 of this series. We’ve gotten another step closer in building a usable SDK. You can check the repository over at https://github.com/bramdevries/example-php-sdk.
In the next part, we’ll be taking a closer look at how we can test our SDK from within our application.
Member discussion