API Client

Dave Redfern

Published: 20 Feb 23:43 in Standalone

Introduction

Somnambulist API Client Library

GitHub Actions Build Status

The ApiClient library is intended to help build client libraries for consuming JSON APIs. The library provides abstract models for primary resource objects and related value objects. Persistence requests are handled by ApiActions that encapsulate a change request.

Models and ValueObjects make use of somnambulist/attribute-model type casting system.

The library uses Symfony HTTP Client under the hood.

Requirements

  • PHP 8.0+
  • cURL
  • symfony/event-dispatcher
  • symfony/http-client
  • symfony/routing

Installation

Install using composer, or checkout / pull the files from github.com.

  • composer require somnambulist/api-client

Usage

This library provides some building blocks to help you get started with consuming RESTful APIs. Typically this is for use with a micro-services project where you need to write clients that will be shared amongst other projects.

Please note: this project does not make assumptions about the type of service being used. The included libraries provide suitable defaults, but can be completely replaced by your own implementations.

Tests

PHPUnit 9+ is used for testing. Run tests via vendor/bin/phpunit.

Test data was generated using faker and was randomly generated.

Links

Routing

Routing / Defining API Resources

The client utilises the Symfony router under-the-hood to use named routes and parameter rules to make it easier to manage building requests to an API. This means that routes can be defined in config files if need be.

The ApiRouter encapsulates a set of routes to a service end point. A service is the URL to contact the end point. For example:

The URL can contain paths, ports, http/https. It will be processed by parse_url and the pieces set in a RequestContext that is then passed to the UrlGenerator when generating routes.

<?php

use Somnambulist\Components\ApiClient\Client\ApiRouter;
use Symfony\Component\Routing\RouteCollection;

$router = new ApiRouter('http://api.somedomain.dev/users', new RouteCollection());

Routes can be added to the route collection (or pre-built before creating the router):

<?php

use Somnambulist\Components\ApiClient\Client\ApiRoute;
use Somnambulist\Components\ApiClient\Client\ApiRouter;
use Symfony\Component\Routing\RouteCollection;

$router = new ApiRouter('http://api.somedomain.dev/users', new RouteCollection());
$router->routes()->add('users.list', new ApiRoute('/users'));
$router->routes()->add('users.view', new ApiRoute('/users/{id}', ['id' => '[0-9a-f\-]{36}']));

The ApiRoute class extends the Symfony Route object to simplify the constructor, otherwise the Symfony Route object can be used directly.

To make it easier to create these the ApiRouter can be extended to pre-build / define routes:

<?php

use Somnambulist\Components\ApiClient;
use Symfony\Component\Routing\RouteCollection;

class UserApiRouter extends ApiClient\Client\ApiRouter
{

    public function __construct(string $service)
    {
        $routes = new RouteCollection();
        $routes->add(/* add route definitions */);
    
        parent::__construct($service, $routes);
    }
}

Or a Bundle / ServiceProvider could build and inject the appropriate objects for the container.

Note: when using the standard ApiRouter in a service container, it must be aliased with a custom name, otherwise you can only use a single instance in that container.

Connections

API Connections

The Connection is a very simple wrapper around the HttpClientInterface. It links the client to the ApiRouter and provides default implementations for all the main HTTP verbs. This can be overridden to use other HTTP clients (e.g. Guzzle) or mocked out entirely (see tests for an example).

Note: if implementing another HTTP client, the responses are expected to be Symfony client responses. You would need to translate e.g. a Guzzle response to the symfony response to keep the interface valid.

The exposed methods allow for a named route and route parameters and/or a body payload. The optional parameters offered by the Symfony client are deliberately not exposed. If you require an auth-bearer or token authorisation, then implement a custom client that will handle these requirements.

Alternatively: attach an event listener to the PreRequestEvent and modify the headers or body payload as needed before the request is dispatched.

<?php
use Somnambulist\Components\ApiClient\Client\Connection;
use Somnambulist\Components\ApiClient\Client\ApiRouter;
use Symfony\Component\HttpClient\HttpClient;

$conn = new Connection(HttpClient::create(), new ApiRouter());

In the same way that the ApiRouter can be extended, the ApiClient can be extended to provide additional functionality. The base of this library is more focused on offering read defaults for APIs than complex push requests.

The preferred method of extending is by decorating the connection object. Several decorators are included along with a AbstractDecorator base class.

Connection Events

From 2.0.0 the Connection object makes use of the Symfony EventDispatcher and will fire:

  • PreRequestEvent
  • PostRequestEvent

The PreRequestEvent receives:

  • the named route of the request
  • route parameters
  • body parameters

Headers may be added via the PreRequestEvent (e.g.: Request-ID), or the body / route modified as needed (e.g.: to handle BC API calls transparently or tag the current user etc).

The PostRequestEvent receives:

  • HttpClient Response object
  • the original route, parameters, body and headers

Custom Header Injection

There is an included header injector event subscriber / listener that can be added to the standard Symfony services.yaml or the event dispatcher. This can inject from the main RequestStack, a request header (configurable) for tracking requests across service calls.

For example: in a micro services setup, you may use the X-Request-Id header to track a single user journey through the stack. An injector can be configured to pull the header from the apps request object and apply it to all ApiClient calls.

Additional listeners may be created and added to the event to trigger other logic or append additional information such as the current user or additional contextual data.

JSON Structure

Default JSON Response Structure

The JSON response from an API is expected to be a JSON document containing either a single object, or a JSON document that contains a data element that is an array of objects.

For example a single object could be:

{
    "id": "52530121-cc5c-4f56-9c39-734db94c0607",
    "name": "bob"
}

Or a collection or objects:

{
    "data": [
        {
            "id": "52530121-cc5c-4f56-9c39-734db94c0607",
            "name": "bob"
        },
        {
            "id": "05dbf6d3-2042-4363-bfb8-00153417e812",
            "name": "foo"
        }
    ]
}

For nested related data; it is expected to be keyed on a property name without any other element names:

{
    "id": "52530121-cc5c-4f56-9c39-734db94c0607",
    "name": "bob",
    "groups": [
        {
            "id": "31ea5893-809a-4512-b44d-43cad1da35cf",
            "name": "user"
        }
    ]
}

Note: collections must be arrays of objects. Collections defined as objects will not be correctly decoded by default. A custom response decoder will be required in those instances.

Paginated Response Format

A paginated result set is expected to have the following structure:

{
    "data": [
        {
            "id": "52530121-cc5c-4f56-9c39-734db94c0607",
            "name": "bob"
        },
        {
            "id": "05dbf6d3-2042-4363-bfb8-00153417e812",
            "name": "foo"
        }
    ],
    "meta": {
        "pagination": {
            "total": 200,
            "count": 30,
            "per_page": 30,
            "current_page": 1,
            "total_pages": 7,
            "links": {
                "next": "http:\\/\\/api.example.dev\\/v1\\/users?page=2"
            }
        }
    }
}

This behaviour can be changed by overriding the ModelBuilder and re-implementing the logic in fetch().

Models

API Models and ValueObjects

Replacing the previous 1.X EntityLocator is a new Model based approach that follows the active record pattern and functions in a similar manner to somnambulist/read-models.

Setting up the Manager

To make use of the model infrastructure, a Manager instance must first be configured. This maps connections to Models or a default connection that can be used for any Model. As there is a connection per model, the APIs can be completely different or have differing authentication requirements.

The Manager requires at least one connection and a set of casters for casting attributes to PHP objects / other types. The setup is the same as with read-models, and once created the Manager becomes available statically in addition to being a service in a container.

A basic implementation:

<?php
use Somnambulist\Components\ApiClient\Manager;
use Somnambulist\Components\ApiClient\Client\Connection;
use Somnambulist\Components\ApiClient\Client\ApiRouter;
use Somnambulist\Components\AttributeModel\TypeCasters\DateTimeCaster;
use Somnambulist\Components\AttributeModel\TypeCasters\SimpleValueObjectCaster;
use Somnambulist\Domain\Entities\Types\Identity\Uuid;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpClient\HttpClient;

new Manager(
    [
        'default' => new Connection(HttpClient::create(), new ApiRouter(), new EventDispatcher()),
    ],
    [
        new DateTimeCaster(),
        new SimpleValueObjectCaster(Uuid::class, ['uuid'])
    ]
);

To prevent issues with overwriting an existing instance, there is a factory method that can be used. This will return the current instance, or make a new instance with the provided connections and casters.

Note: factory requires connections and casters. If you require only the instance, use the instance method.

Note: the Manager must be instantiated during boot so that the static instance is available. In a Symfony project this means ensuring that the Manager service is accessed at least once in a boot method.

Manager in Symfony Bundles

If creating a client bundle that may be used with other api-client bundles, then some additional setup steps are required to ensure that the Manager will function correctly.

First: be sure to create the bundle HTTP client instance, connection and any specific type casters. Be sure to tag the type casters with a tag for that bundle.

Finally: instead of adding a Manager service directly, you must implement a CompilerPass that runs beforeOptimization. In this pass, check for the Manager and then configure it appropriately. This can be added in the Bundle build method and an anonymous class can be used. For example:

<?php
use Somnambulist\Components\ApiClient\Manager;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

class MyBundle extends Bundle
{
    public function boot()
    {
        $this->container->get(Manager::class);
    }

    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new class implements CompilerPassInterface {
            public function process(ContainerBuilder $container)
            {
                if (!$container->hasDefinition(Manager::class)) {
                    $container->register(Manager::class, Manager::class)->setPublic(true);
                }

                $def = $container->getDefinition(Manager::class);
                $def->addMethodCall('factory', [
                    '$connections' => [
                        MyModel::class => $container->getDefinition('my_bundle.my_model.connection'),
                    ],
                    '$casters'     => tagged_iterator('my_bundle.my_model.type_caster'),
                ]);
            }

        }, PassConfig::TYPE_BEFORE_OPTIMIZATION);
    }
}

The reason for this, is that when the bundle is configured, the whole container is not available so each bundle will replace the Manager rather than adding a new factory call.

Types of Model

ApiClient has two types of model that extend from a common AbstractModel base class.

  • Model
  • ValueObject

Both types can define relationships.

Models

A Model maps to a primary, discrete API end point i.e. it can be "active" and fetch data. Typically the Model will be the primary node or aggregate root of an entity. Models only support a single primary key field that should be the same as the route parameter name.

Value Objects

A ValueObject is a sub-object of a Model that cannot be loaded independently of the Model. i.e.: there is no endpoint to access the data directly or it does not make sense if the model is not loaded. ValueObjects are not "active" and cannot load any data. When fetching data it is pulled from the parent Model instead.

Typically value objects are used when the API does not return independent identities for the object e.g.: a User has a single Address. Another example is when there is a "pivot" table linking two root entities with meta-data. This intermediary object has identities to both sides of the relationship and is not a "valid" independent record.

Note: this ValueObject is not the same as the somnambulist/domain "value object". A domain value object is an immutable; tightly defined domain entity with explicit properties and data.

Making a Model

Once the Manager has been created, model instances can be loaded. A model has the following requirements:

  • must define at least search and view routes in the routes array
  • must define the primary id if not id

All other properties are optional and defaults are provided. To cast attributes to other objects, add entries to casts as attribute -> type key/value pairs.

For example:

<?php
use Somnambulist\Components\ApiClient\Model;

class User extends Model
{

    protected array $routes = [
        'search' => 'users.list',
        'view' => 'users.view',
    ];
    
    protected array $casts = [
        'id' => 'uuid',
        'email' => 'email_address',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];
}

Model Options

The following properties may be customised per model:

  • routes - the routes to use for search and view
  • casts - any attributes that should be converted to other types
  • with - any relationships to always eager load when fetching data
  • primaryKey - the name of the primary key; both attribute and root option
  • collectionClass - the type of collection to return when fetching many results
  • queryEncoder - the class to use to encode search requests to the API
  • responseDecoder - the class to use to decode API responses to PHP arrays

API Searches

To load a user: User::find(id) or User::query()->whereField('name', 'like', 'foo%')->fetch(). Searching will depend on the API being called. The query builder allows for nested and/or queries as well as multiple values for in type statements and approximations of null/not null. Most APIs will not support nested conditionals and to help, several query string encoders are provided:

  • JsonApi - encodes to standard / suggested JSON API query args
  • OpenStackApi - encodes to the OpenStack standard
  • Simple - default, encodes 1.X type query strings
  • NestedArray - converts nested conditionals to an array structure maintaining all keys
  • CompoundNestedArray - use a compound operator:value instead of separate array keys

The query encoder class can be set on a per model basis and any QueryEncoderInterface may be used, so completely custom serialization is possible. Encoders that do not support nested or OR conditionals, will raise an error when encountered during the query encoding process.

In keeping with read-models / active record, linked records can be loaded using ->with(), though this is dependent on the API. It is preferable to always eager load the data you need at the point of request to avoid unnecessary API calls or worse, cascading API calls as they will be much slower than the equivalent database operations.

Note: when data is eager loaded it will first be in the main attributes unless relationships are defined. See model relationships for more details about relationships.

The ModelBuilder has some additional helper methods:

  • find()
  • findBy()
  • findOneBy()
  • findOrFail()
  • fetchFirstOrFail()
  • fetchFirstOrNull()

By default calling ->fetch() will return a Collection class. This can be overridden to a custom collection by setting the class for collections to use on the Model. Note that this must be a somnambulist/collection interface type collection.

Most querying will use the search route defined in the routes array; however the primary key method or find() will trigger the use of the view route instead of a search. This should be a named route that is available in the connections ApiRouter instance. Named routes are always used in api-client.

There are many examples in the tests of using the model find methods.

Making a ValueObject

A ValueObject is essentially the same as a Model except it lacks any find methods:

For example:

<?php
use Somnambulist\Components\ApiClient\ValueObject;

class Address extends ValueObject
{

    protected array $casts = [

    ];
}

ValueObjects can define the collection class to use with multiple objects in the same way as Model.

Adding Behaviour

Both Model and ValueObject are attribute models, so the same get mutators and attribute accessor work on both. The mutators allow the creation of virtual properties or modify the output, or adding custom output derived from the attributes.

For example a User model has both a first and last name field, a mutator can be added to concat both together:

<?php

class User extends Model
{

    protected function getFullNameAttribute(): string
    {
        return sprintf('%s %s', $this->first_name, $this->last_name);
    }
}

// User has: first as foo, and last as bar -> "foo bar"
User::find()->fullName();

Note: due to the use of relationships and magic getters / call, you should always use the attribute mutators for methods to avoid any potential issues e.g.: trying to call a method but it is interpreted as a relationship access.

Type Casting

Type Casting

In 1.X models were hydrated using an ObjectMapper; however 2.0 now uses type casting and the same type caster system that read models uses. This type casting is defined in somnambulist/attribute-model package.

This allows type casters to be shared between read-models and api-client allowing for much better re-use, especially in e.g. Symfony were they can be loaded as services.

A type caster is a class that can convert attribute(s) to a PHP object or PHP type. They can work on a single or many attributes allowing complex value objects to be created; further the used attributes can be removed in place of the main attribute. See the attribute model docs for more details.

Type casters are added to the main Manager instance and can be extended at runtime if needed.

For example to convert an email string to an EmailAddress object:

<?php
use Somnambulist\Components\ApiClient\Manager;
use Somnambulist\Components\ApiClient\Client\Connection;
use Somnambulist\Components\ApiClient\Client\ApiRouter;
use Somnambulist\Components\AttributeModel\TypeCasters\SimpleValueObjectCaster;
use Somnambulist\Domain\Entities\Types\Identity\EmailAddress;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpClient\HttpClient;

new Manager(
    [
        'default' => new Connection(HttpClient::create(), new ApiRouter(), new EventDispatcher()),
    ],
    [
        new SimpleValueObjectCaster(EmailAddress::class, ['email'])
    ]   
);

Casting Complex Objects

For more complex needs, define your own type caster:

<?php
use Somnambulist\Components\ApiClient\Tests\Support\Stubs\Entities\Address;
use Somnambulist\Components\AttributeModel\Contracts\AttributeCasterInterface;

class AddressCaster implements AttributeCasterInterface
{
    public function types() : array
    {
        return ['address'];
    }

    public function supports(string $type) : bool
    {
        return in_array($type, $this->types());
    }

    public function cast(array &$attributes, $attribute, string $type) : void{
        $attributes[$attribute] = new Address(
            $attributes['address_line_1'],
            $attributes['address_line_2'],
            $attributes['address_city'],
            $attributes['address_state'],
            $attributes['address_postcode'],
            $attributes['address_country'],
        );
        
        // remove the attributes from the main array
        unset($attributes['address_line_1'], $attributes['address_line_2']...);
    }
}

If this is then used, an Address value object (as-in an actual defined value object) will be created at the given attribute and the attributes used to make it will be removed.

Technically related objects defined in the attributes could be hydrated into collections of objects without needing the relationships; however they would not be loaded after the fact.

Model Relationships

Model Relationships

Like active-record models, the ApiClient Model and ValueObject can define relationships to other models. This allows certain data to be lazy loaded when the relationship is accessed if it does not already exist.

Note: lazy loading API responses may severely impact on your applications performance! Be sure to profile thoroughly and check the number of API calls being made. In a Symfony app, be sure that all HttpClients are tagged:

Note: lazy loading may be disabled to prevent run-away API calls on each relationship. This can be changed at runtime by accessing the relationship (if allowed) and using enable/disable lazy loading methods.

services:
    app.clients.service_api_client:
        factory: Symfony\Component\HttpClient\HttpClient::create
        class: Symfony\Component\HttpClient\CurlHttpClient
        tags:
            - { name: 'monolog.logger', channel: 'http_client' }
            - { name: 'http_client.client' }

This will ensure that the profiler will collect the requests / responses and ensure they are logged appropriately.

Relationship Types

The following relationships can be created:

  • HasOne
  • HasMany
  • BelongsTo

This more or less correspond to the usual types of relationship i.e. a HasOne means there is a 1:1 relationship between the parent and related objects, where-as HasMany means there is a collection of results.

BelongsTo is a special case used to link external API endpoints to the model i.e. the primary key is defined in the parent Model or ValueObject vs. the data being fetched on the parent. This is explained further in the Limitations section.

Helper methods are defined on the base AbstractModel class to define these relationships. It is recommended that they are kept as protected methods as searching on relationships is not a supported function and setting limits may cause unpredictable behaviour.

Implementation Under-the-Hood

Under the hood, when relationships are eager loaded, the data is loaded into the main parents attribute array in the parent model. The relationships specific attributes are then extracted from the specified key and this is passed down into each subsequent relationship. Once populated the relationship is removed from the attributes, keeping the original models data cleaner.

When lazy loading and if the attributes do not exist they will be loaded via the most appropriate means. For HasOne / Many this is by the parent model; essentially reloading the data with the related data as an include request, and processing it via the relationship. For BelongsTo, the related object is loaded from the specified API resource and again the data processed by the relationship.

It is therefore possible to load data without triggering any API calls by injecting an array of attributes that matches the cascading structure of the relationships - basically a JSON decoded API response of all the relationship data. This makes testing very easy.

Limitations of Relationships

There are a number of limitations deliberately imposed on the relationship model. This is partly to ease the implementation, but also to encourage eager loading whenever possible.

HasOne and HasMany

HasOne and HasMany only load data from the parent defining the relationship. This means that they cannot be defined on a ValueObject as the parent must be "active". This is because both of these load the relationship data by issuing a ->with() on the parent Model and processing the result.

The reason for this behaviour is that for these relationships, it is expected that the related object is not directly accessible without the parent i.e. there is no route to fetch just that record individually and there is no primary key in the related data.

Note that both HasOne and HasMany can load ValueObject models as the result of loading the relationship, they just cannot be used as the source.

BelongsTo

Opposite to HasOne and HasMany, the BelongsTo relationships can only be used with Model relations. The parent can be a ValueObject, but only Models can be used as the related type. This is because the data is loaded from the related side and the related must be "active", in the same way on a HasOne, the parent must be active.

For BelongsTo, the related is usually on a separate API end point e.g. a User belongs to an Account and is linked by an account_id. Therefore to load this data, the endpoint must be accessed and ValueObjects do not support fetches.

In all cases: again, you must thoroughly analyse in development the number of API calls before deploying any solution to ensure you do not accidentally cause a large number of API calls. Remember that API calls will typically incur far higher overhead over Redis, or Database calls due to the higher costs of the HTTP overhead and JSON serialization/deserialization.

Note: there is no BelongsToMany as this would be referenced as a 1:M API relationship as the link table is not exposed via an API. If it is, then it is linked via a ValueObject that has a single BelongsTo to. Remember: API client is not exactly the same as active record.

Example Usage

The following is an example of using the various relationship types. Here a User has one address, multiple contact types and belongs to a single account.

<?php
use Somnambulist\Components\ApiClient\Model;
use Somnambulist\Components\ApiClient\Relationships\BelongsTo;
use Somnambulist\Components\ApiClient\Relationships\HasMany;
use Somnambulist\Components\ApiClient\Relationships\HasOne;

class User extends Model
{

    protected function address(): HasOne
    {
        return $this->hasOne(Address::class, 'address', false);
    }

    protected function contacts(): HasMany
    {
        return $this->hasMany(Contact::class, 'contacts', 'type');
    }

    protected function account(): BelongsTo
    {
        return $this->belongsTo(Account::class, 'account', 'account_id');
    }
}

Depending on the API implementation it should be possible to eager load all this data directly from the User itself: User::with('address', 'contacts', 'account')->find(). The relationships will then be populated from the pre-fetched data.

Persistence

Persisting "Objects"

For simple use cases an ActionPersister class is available. This allows for storing, updating or deleting records via API calls: POST, PUT and DELETE. The basic implementation makes use of form-data and sends a standard request. The implementation can be customised or swapped out entirely.

The persister works with ApiActionInterface objects that should provide:

  • the hydrating class
  • the route and parameters (must be valid in the Connection passed to the persister)
  • the properties to change / send to the API

Unlike the Model, the ActionPersister is not keyed to a particular class. This is defined on the action. Custom actions can be passed, provided they implement the interface. For updates and deletes, the route parameter values are hashed together to act as an id value for logging / exception purposes.

Errors and exceptions from all methods are converted to ActionPersisterException instances. For errors derived from a JSON decoded response, the errors are parsed out and made available via the ->getErrors() method. The original response is kept in the exception.

store and update will attempt to return a hydrated object - provided that the API returns the representation after the action is performed.

For complex persistence requirements, implement your own solution.

It is recommended to either extend the AbstractAction or implement your own typed actions for your specific use cases. Strongly typing constructors / arguments will ensure that any API requests will be verified before they are dispatched, reducing the number of round trips to persist changes.

Persisting "null" values

Sometimes it is advantageous to be able to send "null" as the value for a field. Unfortunately the Symfony HttpClient uses http_build_query under the hood to normalise the body data. This function will strip all keys with null values, however it will leave false, 0 and empty string as-is.

Your options in this case are:

  • substitute empty string or another value to stand in for null
  • send a JSON payload through a custom request call (use ['json' => [..array of data..]])

API Recording

Recording API Responses

The ApiClient instance can be wrapped in decorators to modify the behaviour / add functionality. Decorators can be stacked over an underlying instance.

One of the built-in decorators allows API responses to be recorded and then played back during testing or to verify the data from the API.

There are 3 modes of operation:

  • passthru - the normal, it does nothing except return the response as-is
  • record - record the response to a JSON file
  • playback - load the cached response instead of making the request

passthru is the default mode if nothing is configured. The mode is changed by calling: ->record(), ->playback() or ->passthru() on the instance.

A store must be configured before any recording or playback can be done.

For example to set up recording:

<?php
use Somnambulist\Components\ApiClient\Client\Decorators\RecordResponseDecorator;
use Somnambulist\Components\ApiClient\Client\RequestTracker;
use Somnambulist\Components\ApiClient\Client\ResponseStore;

ResponseStore::instance()->setStore($store);
RequestTracker::instance();

$apiClient = new RecordResponseDecorator($connection);
$apiClient->record();

Any calls to the API using this client instance will be recorded to the folder specified. All responses are recorded as SHA1 hashes of the request data:

  • url + parameters
  • headers
  • body

To avoid many files in one folder, the first 4 characters are used as sub-folders:

  • path/to/file/store/ae/bc/aebc....._(n+1).json

All data in the hash is sorted by key in ascending order so that the request will hash to the same value.

To avoid issues where the same request may produce different output, each call to the same endpoint is tracked during that request cycle and the call number appended to the hash. For example: if you make 3 requests to https//api.url/v1/user/, there will be 3 cache files generated for each response during that request cycle.

Because data could change between request cycles, it is recommended to use separate stores. For example in a test suite you would want to store the responses per test suite, otherwise responses may be overwritten, invalidating your tests.

For example; the following trait can be used to ensure that the recording folder is set for each test method:

<?php
use Somnambulist\Components\ApiClient\Client\ResponseStore;
use Somnambulist\Components\ApiClient\Client\RequestTracker;

trait CanRecordApiResponses
{
    /* be sure to call setRecordingStore() in the test class setUp() method
    protected function setUp(): void
    {
        $this->setRecordingStore();
    }
    */

    protected function setRecordingStore(): void
    {
        $test  = str_replace(['App\\Tests\\', 'Test', '\\'], ['', '', '/'], __CLASS__);
        $store = sprintf('%s/tests/recordings/%s/%s', dirname(__DIR__, 3), $test, $this->getName());

        ResponseStore::instance()->setStore($store);
        RequestTracker::instance()->reset();
    }
}

In your tests, add the trait and make sure that the store is setup. Then when your tests run, first run with "record" set on the decorator, and then in your CI you would run in "playback" mode. The JSON files generated would need to be committed to your VCS.

Using with Symfony WebTestCase

When using recording with Symfony WebTestCases, you need to set the recording store for each test method, otherwise you may overwrite previous requests. Then before each test method you should additionally call: RequestTracker::instance()->reset() to ensure that the request counters are reset between tests.

The reset could be placed in the tearDown() or setUp() method, along with the setStore():

<?php
use Somnambulist\Components\ApiClient\Client\RequestTracker;
use Somnambulist\Components\ApiClient\Client\ResponseStore;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class LoginTest extends WebTestCase
{
    protected function setUp(): void 
    {
        ResponseStore::instance()->setStore('/path/to/store');
        RequestTracker::instance()->reset();
    }
}

The request tracker/store are used in the unit tests for the library.

Note: the recording is set PER connection instance. If you have 4 separate connections you will need to wrap ALL the connections to record all responses.

Return to article