API Client
Dave RedfernPublished: 20 Feb 23:43 in Standalone
Introduction
Somnambulist API Client Library
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. ValueObject
s 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
andview
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 = [
];
}
ValueObject
s 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 Model
s 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 ValueObject
s 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.