diff --git a/README.md b/README.md
index 422f262..8e4de7a 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,55 @@ $middlewares = [
**Remember**: [There are a shitload of HTTPlug middleware available already.](http://docs.php-http.org/en/latest/plugins/) Try on of them before writing your own one!
+
+#### Enhance your setup by using a Client Builder
+
+Configuring an HTTP client can be cumbersome if you have a lot of plugins.
+In order to make this easier, we've provided a Client Builder that helps you set-up your client step by step:
+
+```php
+use Http\Message\Authentication\BasicAuth;
+use Phpro\HttpTools\Client\ClientBuilder;
+use Phpro\HttpTools\Client\Factory\AutoDiscoveredClientFactory;
+
+$client = ClientBuilder::default(AutoDiscoveredClientFactory::create([]))
+ ->addBaseUri('https://www.google.com')
+ ->addHeaders([
+ 'x-Foo' => 'bar',
+ ])
+ ->addAuthentication(new BasicAuth('user', 'pass'))
+ ->addPlugin(new YourPlugin(), priority: 99)
+ // -> and many more ...
+ ->build();
+```
+
+Our suggested approach is to create a configuration object for all variable client configurations and services.
+This can be used in combination with a PHP factory class that builds your client through this builder:
+
+```php
+final readonly class YourClientConfig {
+ public function __construct(
+ public string $apiUrl,
+ #[\SensitiveParameter] public string $apiKey,
+ public LoggerInterface $logger
+ ) {
+ }
+}
+```
+
+```php
+final readonly class YourClientFactory
+{
+ public static function create(YourClientConfig $config): ClientInterface
+ {
+ return ClientBuilder::default()
+ ->addBaseUri($config->apiUrl)
+ ->addHeaders(['x-Api-Key' => $config->apiKey])
+ ->build();
+ }
+}
+```
+
### Logging
This package contains the `php-http/logger-plugin`.
@@ -104,6 +153,37 @@ $middlewares[] = new Http\Client\Common\Plugin\LoggerPlugin(
);
```
+#### Log formatter builder
+
+Since logging is a common requirement, we've added a formatter builder which can be used directly from the client builder:
+
+```php
+use Http\Message\Formatter;
+use Phpro\HttpTools\Client\ClientBuilder;
+use Phpro\HttpTools\Formatter\FormatterBuilder;
+use Phpro\HttpTools\Formatter\RemoveSensitiveHeadersFormatter;
+use Phpro\HttpTools\Request\Request;
+use Phpro\HttpTools\Transport\Presets\RawPreset;
+use Phpro\HttpTools\Uri\RawUriBuilder;
+use Symfony\Component\Console\Logger\ConsoleLogger;
+use Symfony\Component\Console\Output\ConsoleOutput;
+use Symfony\Component\Console\Output\OutputInterface;
+
+$client = ClientBuilder::default()
+ ->addLogger(
+ new ConsoleLogger(new ConsoleOutput(OutputInterface::VERBOSITY_DEBUG)),
+ FormatterBuilder::default()
+ ->withDebug(true)
+ ->withMaxBodyLength(1000)
+ ->addDecorator(RemoveSensitiveHeadersFormatter::createDecorator([
+ 'X-SENSITIVE-HEADER',
+ ]))
+ ->build()
+ )
+ ->build();
+```
+
+
[More info ...](http://docs.php-http.org/en/latest/plugins/logger.html)
## Using the HTTP-Client
diff --git a/docs/framework/magento2.md b/docs/framework/magento2.md
deleted file mode 100644
index 2596739..0000000
--- a/docs/framework/magento2.md
+++ /dev/null
@@ -1,215 +0,0 @@
-# Using this package inside Magento2
-
-## Configuration via di.xml
-1. Define and configure your custom plugins/middlewares or implement some of [HTTPlug](http://docs.php-http.org/en/latest/plugins/)
-2. Use `Phpro\HttpTools\Client\Factory\LazyClientLoader` to configure your preferred HTTP client (`AutoDiscoveredClientFactory`, `GuzzleClientFactory`, `SymfonyClientFactory`, ...) and define options such as base_uri, default headers, ...
-3. Always create your own _Transport_ class as decorator. Magento don't know the concept of factories or stack in its DI component. Inside your custom _Transport_ you can use the built-in transports like e.g. `JsonPreset`
-4. Last but not least, create and configure your _Request Handler(s)_ to transform _Request Value Object(s)_ to _Response Value Object(s)_
-
-Example etc/di.xml file
-```xml
-
-
-
-
-
-
-
-
-
-
-
- nl-BE
-
-
-
-
-
- \Phpro\HttpTools\Client\Factory\SymfonyClientFactory
-
-
-
- - Phpro\HttpTools\Plugin\AcceptLanguagePlugin
-
-
- - http://api.openweathermap.org
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Phpro\HttpTools\Client\Factory\LazyClientLoader
-
-
-
-
-
-
-
- Mage\OpenWeather\Http\Transport
-
-
-
-```
-
-## Example Transport
-
-```php
-jsonTransport = JsonPreset::create(
- $clientLoader->load(),
- $uriBuilder
- );
- }
-
- public function __invoke(RequestInterface $request): array
- {
- return ($this->jsonTransport)($request);
- }
-}
-```
-
-## Example Request Handler
-```php
-transport = $transport;
- }
-
- public function __invoke(WeatherRequest $weatherRequest): WeatherResponse
- {
- # You could add data conversion, error handling, ... here.
- return WeatherResponse::fromRawArray(
- ($this->transport)($weatherRequest)
- );
- }
-}
-```
-
-## Example Request Value Object
-```php
- 'Antwerp,be',
- 'appid' => '...'
- ];
- }
-
- public function body(): array
- {
- return [];
- }
-}
-```
-
-## Example Response Value Object
-```php
-type = trim($type);
- $this->description = trim($description);
- }
-
- public static function fromRawArray(array $data): self
- {
- if (!isset($data['weather']) || !is_array($data['weather']) || !isset($data['weather'][0]['main'])) {
- throw new \Exception('Invalid data provided');
- }
-
- return new self(
- $data['weather'][0]['main'],
- $data['weather'][0]['description']
- );
- }
-
- public function getType(): string
- {
- return $this->type;
- }
-
- public function getDescription(): string
- {
- return $this->description;
- }
-}
-```
diff --git a/docs/framework/symfony.md b/docs/framework/symfony.md
deleted file mode 100644
index ee31b39..0000000
--- a/docs/framework/symfony.md
+++ /dev/null
@@ -1,129 +0,0 @@
-# Using this package inside Symfony
-
-## Example Configuration
-
-```yaml
-services:
-
- #
- # Configuring an HTTP client:
- #
- App\SomeClient\HttpClient:
- factory: ['Phpro\HttpTools\Client\Factory\SymfonyClientFactory', 'create']
- # factory: ['Phpro\HttpTools\Client\Factory\GuzzleClientFactory', 'create']
- # factory: ['Phpro\HttpTools\Client\Factory\AutoDiscoveredClientFactory', 'create']
- arguments:
- $middlewares: !tagged app.someclient.plugin
- $options:
- base_uri: '%env(SOME_CLIENT_BASE_URI)%'
-
- #
- # Configuring plugins:
- #
- App\SomeClient\Plugin\AcceptLanguagePlugin:
- class: Phpro\HttpTools\Plugin\AcceptLanguagePlugin
- arguments:
- - 'nl-BE'
- tags:
- - { name: 'app.someclient.plugin' }
-
- App\SomeClient\Plugin\Authentication\ServicePrincipal:
- arguments:
- - '%env(API_SECRET)%'
- tags:
- - { name: 'app.someclient.plugin' }
-
- #
- # Logging:
- #
- App\SomeClient\Plugin\Logger:
- class: 'Http\Client\Common\Plugin\LoggerPlugin'
- arguments:
- - '@monolog.logger.someclient'
- - '@app.http.logger_formatter'
- tags:
- - { name: 'app.someclient.plugin', priority: 1000 }
-
- app.http.logger_formatter:
- stack:
- - Phpro\HttpTools\Formatter\RemoveSensitiveHeadersFormatter:
- $formatter: '@.inner'
- $sensitiveHeaders:
- - 'X-Api-Key'
- - 'X-Api-Secret'
- - Phpro\HttpTools\Formatter\RemoveSensitiveJsonKeysFormatter:
- $formatter: '@.inner'
- $sensitiveJsonKeys:
- - password
- - oldPassword
- - refreshToken
- - '@app.http.logger_formatter.base'
-
- app.http.logger_formatter.base:
- factory: ['Phpro\HttpTools\Formatter\Factory\BasicFormatterFactory', 'create']
- class: Http\Message\Formatter
- arguments:
- $debug: '%kernel.debug%'
- $maxBodyLength: 1000
-
- #
- # Setting up the transport
- #
- App\SomeClient\Transport:
- class: Phpro\HttpTools\Transport\TransportInterface
- factory: ['Phpro\HttpTools\Transport\Presets\JsonPreset', 'sync']
- arguments:
- - '@App\SomeClient'
- - '@Phpro\HttpTools\Uri\TemplatedUriBuilder'
-
- Phpro\HttpTools\Uri\TemplatedUriBuilder: ~
-
- #
- # Registering a single Request Handler
- #
- App\SomeClient\RequestHandler\ListSomething:
- arguments:
- - '@App\SomeClient\Transport'
-
- #
- # Or register all Request Handlers at once
- #
- App\SomeClient\RequestHandler\:
- resource: '../../RequestHandler/*'
- bind:
- $transport: '@App\SomeClient\Transport'
-```
-
-If you are using the Symfony container inside functional tests.
-You could make the VCR testing part of your dependency container by setting this up in `config/packages/test/services.yaml`.
-Or any other env where you wish to use recorded responses by using the correct package path.
-This way, you could also use the recordings as mocks for your frontend e.g.
-
-```yaml
-services:
- Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy:
- class: Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy
-
- Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder:
- class: Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder
- arguments: ['Tests/_fixtures']
-
- Http\Client\Plugin\Vcr\RecordPlugin:
- class: Http\Client\Plugin\Vcr\RecordPlugin
- arguments:
- - '@Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy'
- - '@Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder'
- tags:
- - { name: 'app.someclient.plugin', priority: 1000 }
- - { name: 'app.otherclient.plugin', priority: 1000 }
-
- Http\Client\Plugin\Vcr\ReplayPlugin:
- class: Http\Client\Plugin\Vcr\ReplayPlugin
- arguments:
- - '@Http\Client\Plugin\Vcr\NamingStrategy\PathNamingStrategy'
- - '@Http\Client\Plugin\Vcr\Recorder\FilesystemRecorder'
- - false
- tags:
- - { name: 'app.someclient.plugin', priority: 1000 }
- - { name: 'app.otherclient.plugin', priority: 1000 }
-```
diff --git a/src/Client/ClientBuilder.php b/src/Client/ClientBuilder.php
new file mode 100644
index 0000000..c1a1561
--- /dev/null
+++ b/src/Client/ClientBuilder.php
@@ -0,0 +1,171 @@
+
+ */
+ private SplPriorityQueue $plugins;
+ /**
+ * @var list
+ */
+ private array $decorators = [];
+
+ /**
+ * @param iterable $middlewares
+ */
+ public function __construct(
+ ?ClientInterface $client = null,
+ iterable $middlewares = [],
+ ) {
+ $this->client = $client ?? Psr18ClientDiscovery::find();
+ /** @var SplPriorityQueue $plugins */
+ $plugins = new SplPriorityQueue();
+ $this->plugins = $plugins;
+
+ foreach ($middlewares as $middleware) {
+ $plugins->insert($middleware, self::PRIORITY_LEVEL_DEFAULT);
+ }
+ }
+
+ public static function default(
+ ?ClientInterface $client = null,
+ ): self {
+ return new self($client, [
+ new Plugin\ErrorPlugin(),
+ ]);
+ }
+
+ /**
+ * @param Decorator $decorator
+ */
+ public function addDecorator(Closure $decorator): self
+ {
+ $this->decorators[] = $decorator;
+
+ return $this;
+ }
+
+ public function addPlugin(
+ Plugin $plugin,
+ int $priority = self::PRIORITY_LEVEL_DEFAULT,
+ ): self {
+ $this->plugins->insert($plugin, $priority);
+
+ return $this;
+ }
+
+ /**
+ * @param Closure(ClientInterface): Plugin $pluginBuilder
+ */
+ public function addPluginWithCurrentlyConfiguredClient(
+ Closure $pluginBuilder,
+ int $priority = self::PRIORITY_LEVEL_DEFAULT,
+ ): self {
+ return $this->addPlugin(
+ $pluginBuilder($this->build()),
+ $priority,
+ );
+ }
+
+ public function addAuthentication(
+ Authentication $authentication,
+ int $priority = self::PRIORITY_LEVEL_SECURITY,
+ ): self {
+ return $this->addPlugin(new Plugin\AuthenticationPlugin($authentication), $priority);
+ }
+
+ public function addLogger(
+ LoggerInterface $logger,
+ ?Formatter $formatter = null,
+ int $priority = self::PRIORITY_LEVEL_LOGGING,
+ ): self {
+ return $this->addPlugin(new Plugin\LoggerPlugin($logger, $formatter), $priority);
+ }
+
+ /**
+ * @param array $headers
+ *
+ * @return $this
+ */
+ public function addHeaders(
+ array $headers,
+ int $priority = self::PRIORITY_LEVEL_DEFAULT,
+ ): self {
+ return $this->addPlugin(new Plugin\HeaderSetPlugin($headers), $priority);
+ }
+
+ public function addBaseUri(
+ UriInterface|string $baseUri,
+ bool $replaceHost = true,
+ int $priority = self::PRIORITY_LEVEL_DEFAULT,
+ ): self {
+ $baseUri = match (true) {
+ is_string($baseUri) => Psr17FactoryDiscovery::findUriFactory()->createUri($baseUri),
+ default => $baseUri,
+ };
+
+ return $this->addPlugin(new Plugin\BaseUriPlugin($baseUri, ['replace' => $replaceHost]), $priority);
+ }
+
+ public function addRecording(
+ NamingStrategyInterface $namingStrategy,
+ RecorderInterface&PlayerInterface $recorder,
+ int $priority = self::PRIORITY_LEVEL_LOGGING,
+ ): self {
+ return $this
+ ->addPlugin(new RecordPlugin($namingStrategy, $recorder), $priority)
+ ->addPlugin(new ReplayPlugin($namingStrategy, $recorder, false), $priority);
+ }
+
+ /**
+ * @param PluginCallback $callback
+ */
+ public function addCallback(callable $callback, int $priority = self::PRIORITY_LEVEL_DEFAULT): self
+ {
+ return $this->addPlugin(new CallbackPlugin($callback), $priority);
+ }
+
+ public function build(): PluginClient
+ {
+ return PluginsConfigurator::configure(
+ Fun\pipe(...$this->decorators)($this->client),
+ [...clone $this->plugins]
+ );
+ }
+}
diff --git a/src/Formatter/FormatterBuilder.php b/src/Formatter/FormatterBuilder.php
new file mode 100644
index 0000000..173852f
--- /dev/null
+++ b/src/Formatter/FormatterBuilder.php
@@ -0,0 +1,60 @@
+
+ */
+ private array $decorators = [];
+
+ public static function default(
+ ): FormatterBuilder {
+ return new self();
+ }
+
+ public function withDebug(bool $debug = true): self
+ {
+ $this->debug = $debug;
+
+ return $this;
+ }
+
+ public function withMaxBodyLength(int $maxBodyLength): self
+ {
+ $this->maxBodyLength = $maxBodyLength;
+
+ return $this;
+ }
+
+ /**
+ * @param Decorator $decorator
+ */
+ public function addDecorator(Closure $decorator): self
+ {
+ $this->decorators[] = $decorator;
+
+ return $this;
+ }
+
+ public function build(): Formatter
+ {
+ return Fun\pipe(...$this->decorators)(
+ BasicFormatterFactory::create($this->debug, $this->maxBodyLength)
+ );
+ }
+}
diff --git a/src/Formatter/RemoveSensitiveHeadersFormatter.php b/src/Formatter/RemoveSensitiveHeadersFormatter.php
index 31daf5d..548aee2 100644
--- a/src/Formatter/RemoveSensitiveHeadersFormatter.php
+++ b/src/Formatter/RemoveSensitiveHeadersFormatter.php
@@ -4,6 +4,7 @@
namespace Phpro\HttpTools\Formatter;
+use Closure;
use Http\Message\Formatter as HttpFormatter;
use function preg_quote;
@@ -12,6 +13,9 @@
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
+/**
+ * @psalm-import-type Decorator from FormatterBuilder
+ */
final class RemoveSensitiveHeadersFormatter implements HttpFormatter
{
private HttpFormatter $formatter;
@@ -30,6 +34,16 @@ public function __construct(HttpFormatter $formatter, array $sensitiveHeaders)
$this->sensitiveHeaders = $sensitiveHeaders;
}
+ /**
+ * @param non-empty-list $sensitiveHeaders
+ *
+ * @return Decorator
+ */
+ public static function createDecorator(array $sensitiveHeaders): Closure
+ {
+ return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveHeaders);
+ }
+
public function formatRequest(RequestInterface $request): string
{
return $this->removeCredentials(
diff --git a/src/Formatter/RemoveSensitiveJsonKeysFormatter.php b/src/Formatter/RemoveSensitiveJsonKeysFormatter.php
index e19069e..ed8c972 100644
--- a/src/Formatter/RemoveSensitiveJsonKeysFormatter.php
+++ b/src/Formatter/RemoveSensitiveJsonKeysFormatter.php
@@ -4,6 +4,7 @@
namespace Phpro\HttpTools\Formatter;
+use Closure;
use Http\Message\Formatter as HttpFormatter;
use function preg_quote;
@@ -12,6 +13,9 @@
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
+/**
+ * @psalm-import-type Decorator from FormatterBuilder
+ */
final class RemoveSensitiveJsonKeysFormatter implements HttpFormatter
{
private HttpFormatter $formatter;
@@ -30,6 +34,16 @@ public function __construct(HttpFormatter $formatter, array $sensitiveJsonKeys)
$this->sensitiveJsonKeys = $sensitiveJsonKeys;
}
+ /**
+ * @param non-empty-list $sensitiveJsonKeys
+ *
+ * @return Decorator
+ */
+ public static function createDecorator(array $sensitiveJsonKeys): Closure
+ {
+ return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveJsonKeys);
+ }
+
public function formatRequest(RequestInterface $request): string
{
return $this->removeCredentials(
diff --git a/src/Formatter/RemoveSensitiveQueryStringsFormatter.php b/src/Formatter/RemoveSensitiveQueryStringsFormatter.php
index 9a72360..9c5c160 100644
--- a/src/Formatter/RemoveSensitiveQueryStringsFormatter.php
+++ b/src/Formatter/RemoveSensitiveQueryStringsFormatter.php
@@ -4,10 +4,14 @@
namespace Phpro\HttpTools\Formatter;
+use Closure;
use Http\Message\Formatter as HttpFormatter;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
+/**
+ * @psalm-import-type Decorator from FormatterBuilder
+ */
final class RemoveSensitiveQueryStringsFormatter implements HttpFormatter
{
private HttpFormatter $formatter;
@@ -28,6 +32,16 @@ public function __construct(
$this->sensitiveKeys = $sensitiveKeys;
}
+ /**
+ * @param non-empty-list $sensitiveKeys
+ *
+ * @return Decorator
+ */
+ public static function createDecorator(array $sensitiveKeys): Closure
+ {
+ return static fn (HttpFormatter $formatter): HttpFormatter => new self($formatter, $sensitiveKeys);
+ }
+
public function formatRequest(RequestInterface $request): string
{
return $this->removeQueryStrings($request);
diff --git a/tests/Unit/Client/ClientBuilderTest.php b/tests/Unit/Client/ClientBuilderTest.php
new file mode 100644
index 0000000..698d778
--- /dev/null
+++ b/tests/Unit/Client/ClientBuilderTest.php
@@ -0,0 +1,372 @@
+build();
+
+ self::assertInstanceOf(PluginClient::class, $client);
+ }
+
+ #[Test]
+ public function it_can_construct_with_custom_client_and_middlewares(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(204));
+
+ return $client;
+ });
+
+ $plugin = new Plugin\HeaderSetPlugin(['X-Custom' => 'test']);
+ $builder = new ClientBuilder($mockClient, [$plugin]);
+ $client = $builder->build();
+
+ $response = $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertSame('test', $lastRequest->getHeaderLine('X-Custom'));
+ self::assertSame(204, $response->getStatusCode());
+ }
+
+ #[Test]
+ public function it_can_create_default_builder(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultException($this->createEmptyHttpClientException('Error'));
+
+ return $client;
+ });
+
+ $builder = ClientBuilder::default($mockClient);
+ $client = $builder->build();
+
+ self::expectException(\Psr\Http\Client\ClientExceptionInterface::class);
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ }
+
+ #[Test]
+ public function it_can_add_decorator(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $decoratorCalled = new Ref(false);
+ $builder = new ClientBuilder($mockClient);
+ $builder->addDecorator(function ($client) use ($decoratorCalled) {
+ $decoratorCalled->value = true;
+
+ return $client;
+ });
+
+ $client = $builder->build();
+ $client->sendRequest($this->createRequest('GET', '/test'));
+
+ self::assertTrue($decoratorCalled->value);
+ }
+
+ #[Test]
+ public function it_can_add_plugin(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addPlugin(new Plugin\HeaderSetPlugin(['X-Test' => 'value']));
+ $client = $builder->build();
+
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertSame('value', $lastRequest->getHeaderLine('X-Test'));
+ }
+
+ #[Test]
+ public function it_can_add_plugin_with_currently_configured_client(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->addResponse($this->createResponse(202, 'Internal'));
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addPlugin(new Plugin\HeaderSetPlugin(['X-First' => 'first']));
+
+ $builder->addPluginWithCurrentlyConfiguredClient(
+ function ($client) {
+ // Make an internal request and use its response
+ $testRequest = $this->createRequest('GET', '/internal-test');
+ $internalResponse = $client->sendRequest($testRequest);
+ $statusFromInternal = (string) $internalResponse->getStatusCode();
+
+ return new Plugin\HeaderSetPlugin(['X-Second' => 'status-'.$statusFromInternal]);
+ }
+ );
+
+ $client = $builder->build();
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ // Verify both requests were made
+ self::assertCount(2, $mockClient->getRequests(), 'Should have made 2 requests: internal test + actual request');
+ self::assertSame('/internal-test', $mockClient->getRequests()[0]->getUri()->getPath());
+ self::assertSame('/test', $mockClient->getRequests()[1]->getUri()->getPath());
+
+ // Verify the second header contains the status from the internal request
+ self::assertSame('first', $lastRequest->getHeaderLine('X-First'));
+ self::assertSame('status-202', $lastRequest->getHeaderLine('X-Second'));
+ }
+
+ #[Test]
+ public function it_can_add_authentication(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addAuthentication(new BasicAuth('user', 'pass'));
+ $client = $builder->build();
+
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertStringStartsWith('Basic ', $lastRequest->getHeaderLine('Authorization'));
+ }
+
+ #[Test]
+ public function it_can_add_logger(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $logger = $this->createMock(\Psr\Log\LoggerInterface::class);
+ $logger->expects(self::exactly(2))
+ ->method('info')
+ ->with(
+ self::callback(fn (string $message): bool => str_contains($message, 'GET') || str_contains($message, '200'))
+ );
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addLogger($logger);
+ $client = $builder->build();
+
+ $response = $client->sendRequest($this->createRequest('GET', '/test'));
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ #[Test]
+ public function it_can_add_headers(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addHeaders([
+ 'X-Custom-Header' => 'custom-value',
+ 'X-Another-Header' => 'another-value',
+ ]);
+ $client = $builder->build();
+
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertSame('custom-value', $lastRequest->getHeaderLine('X-Custom-Header'));
+ self::assertSame('another-value', $lastRequest->getHeaderLine('X-Another-Header'));
+ }
+
+ #[Test]
+ public function it_can_add_base_uri_with_string(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addBaseUri('https://example.com');
+ $client = $builder->build();
+
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertSame('example.com', $lastRequest->getUri()->getHost());
+ self::assertSame('https', $lastRequest->getUri()->getScheme());
+ }
+
+ #[Test]
+ public function it_can_add_base_uri_with_uri_interface(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $uri = \Http\Discovery\Psr17FactoryDiscovery::findUriFactory()
+ ->createUri('https://api.example.com');
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addBaseUri($uri);
+ $client = $builder->build();
+
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertSame('api.example.com', $lastRequest->getUri()->getHost());
+ self::assertSame('https', $lastRequest->getUri()->getScheme());
+ }
+
+ #[Test]
+ public function it_can_add_recording(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200, 'OK'));
+
+ return $client;
+ });
+
+ $tempDir = sys_get_temp_dir().'/phpro-http-tools-test-'.uniqid();
+ mkdir($tempDir, 0777, true);
+
+ try {
+ $namingStrategy = new PathNamingStrategy();
+ $recorder = new FilesystemRecorder($tempDir);
+
+ $builder = new ClientBuilder($mockClient);
+ $builder->addRecording($namingStrategy, $recorder);
+ $client = $builder->build();
+
+ // First request - should record
+ $response = $client->sendRequest($this->createRequest('GET', '/test'));
+ self::assertSame(200, $response->getStatusCode());
+ self::assertCount(1, $mockClient->getRequests());
+
+ // Verify recording file was created
+ $recordingFiles = glob($tempDir.'/*');
+ self::assertCount(1, $recordingFiles, 'Recording file should be created');
+ self::assertFileExists($recordingFiles[0]);
+
+ // Second request - should replay from recording, not hit the mock client
+ $response2 = $client->sendRequest($this->createRequest('GET', '/test'));
+ self::assertSame(200, $response2->getStatusCode());
+ self::assertCount(1, $mockClient->getRequests(), 'Second request should use recording, not hit the client');
+ } finally {
+ // Cleanup
+ array_map('unlink', glob($tempDir.'/*') ?: []);
+ rmdir($tempDir);
+ }
+ }
+
+ #[Test]
+ public function it_respects_plugin_priority_order(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $executionOrder = new Ref([]);
+ $builder = new ClientBuilder($mockClient);
+
+ // Add plugins with different priorities
+ $builder->addPlugin(
+ new CallbackPlugin(function ($request, $next, $first) use ($executionOrder) {
+ $executionOrder->value[] = 'default';
+
+ return $next($request);
+ }),
+ ClientBuilder::PRIORITY_LEVEL_DEFAULT
+ );
+
+ $builder->addPlugin(
+ new CallbackPlugin(function ($request, $next, $first) use ($executionOrder) {
+ $executionOrder->value[] = 'security';
+
+ return $next($request);
+ }),
+ ClientBuilder::PRIORITY_LEVEL_SECURITY
+ );
+
+ $builder->addPlugin(
+ new CallbackPlugin(function ($request, $next, $first) use ($executionOrder) {
+ $executionOrder->value[] = 'logging';
+
+ return $next($request);
+ }),
+ ClientBuilder::PRIORITY_LEVEL_LOGGING
+ );
+
+ $client = $builder->build();
+ $client->sendRequest($this->createRequest('GET', '/test'));
+
+ // Higher priority values execute first
+ self::assertSame(['logging', 'security', 'default'], $executionOrder->value);
+ }
+
+ #[Test]
+ public function it_can_add_callback(): void
+ {
+ $mockClient = $this->mockClient(function (Client $client): Client {
+ $client->setDefaultResponse($this->createResponse(200));
+
+ return $client;
+ });
+
+ $callbackExecuted = new Ref(false);
+ $builder = new ClientBuilder($mockClient);
+
+ $builder->addCallback(function ($request, $next, $first) use ($callbackExecuted) {
+ $callbackExecuted->value = true;
+
+ return $next($request->withAddedHeader('X-Callback', 'executed'));
+ });
+
+ $client = $builder->build();
+ $client->sendRequest($this->createRequest('GET', '/test'));
+ $lastRequest = $mockClient->getLastRequest();
+
+ self::assertTrue($callbackExecuted->value);
+ self::assertSame('executed', $lastRequest->getHeaderLine('X-Callback'));
+ }
+}
diff --git a/tests/Unit/Formatter/FormatterBuilderTest.php b/tests/Unit/Formatter/FormatterBuilderTest.php
new file mode 100644
index 0000000..1d1f8a8
--- /dev/null
+++ b/tests/Unit/Formatter/FormatterBuilderTest.php
@@ -0,0 +1,143 @@
+build();
+
+ self::assertInstanceOf(Formatter::class, $formatter);
+ self::assertInstanceOf(SimpleFormatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_create_default_builder_via_static_method(): void
+ {
+ $builder = FormatterBuilder::default();
+ $formatter = $builder->build();
+
+ self::assertInstanceOf(Formatter::class, $formatter);
+ self::assertInstanceOf(SimpleFormatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_enable_debug_mode(): void
+ {
+ $builder = new FormatterBuilder();
+ $builder->withDebug(true);
+ $formatter = $builder->build();
+
+ self::assertInstanceOf(FullHttpMessageFormatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_disable_debug_mode(): void
+ {
+ $builder = new FormatterBuilder();
+ $builder->withDebug(false);
+ $formatter = $builder->build();
+
+ self::assertInstanceOf(SimpleFormatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_set_max_body_length(): void
+ {
+ $builder = new FormatterBuilder();
+ $builder->withDebug(true)->withMaxBodyLength(500);
+ $formatter = $builder->build();
+
+ self::assertInstanceOf(FullHttpMessageFormatter::class, $formatter);
+
+ // Create a request with a body longer than 500 characters
+ $longBody = str_repeat('A', 600);
+ $request = $this->createRequest('POST', '/test')
+ ->withBody($this->createStream($longBody));
+
+ $formatted = $formatter->formatRequest($request);
+
+ // The formatted output should truncate the body
+ self::assertStringNotContainsString(str_repeat('A', 600), $formatted);
+ }
+
+ #[Test]
+ public function it_can_add_decorator(): void
+ {
+ $decoratorCalled = new Ref(false);
+ $builder = new FormatterBuilder();
+
+ $builder->addDecorator(function (Formatter $formatter) use ($decoratorCalled): Formatter {
+ $decoratorCalled->value = true;
+
+ return $formatter;
+ });
+
+ $formatter = $builder->build();
+
+ self::assertTrue($decoratorCalled->value);
+ self::assertInstanceOf(Formatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_add_multiple_decorators(): void
+ {
+ $callOrder = new Ref([]);
+ $builder = new FormatterBuilder();
+
+ $builder->addDecorator(function (Formatter $formatter) use ($callOrder): Formatter {
+ $callOrder->value[] = 'first';
+
+ return $formatter;
+ });
+
+ $builder->addDecorator(function (Formatter $formatter) use ($callOrder): Formatter {
+ $callOrder->value[] = 'second';
+
+ return $formatter;
+ });
+
+ $formatter = $builder->build();
+
+ self::assertSame(['first', 'second'], $callOrder->value);
+ self::assertInstanceOf(Formatter::class, $formatter);
+ }
+
+ #[Test]
+ public function it_can_decorate_formatter_with_custom_wrapper(): void
+ {
+ $builder = new FormatterBuilder();
+
+ $builder->addDecorator(function (Formatter $baseFormatter): Formatter {
+ $mockFormatter = $this->createMock(Formatter::class);
+
+ $mockFormatter->expects(self::once())
+ ->method('formatRequest')
+ ->willReturnCallback(fn ($request) => '[CUSTOM] '.$baseFormatter->formatRequest($request));
+
+ return $mockFormatter;
+ });
+
+ $formatter = $builder->build();
+ $request = $this->createRequest('GET', '/test');
+ $formatted = $formatter->formatRequest($request);
+
+ self::assertStringStartsWith('[CUSTOM]', $formatted);
+ }
+}
diff --git a/tests/Unit/Formatter/RemoveSensitiveHeaderKeysFormatterTest.php b/tests/Unit/Formatter/RemoveSensitiveHeaderKeysFormatterTest.php
index d3937cc..bccb436 100644
--- a/tests/Unit/Formatter/RemoveSensitiveHeaderKeysFormatterTest.php
+++ b/tests/Unit/Formatter/RemoveSensitiveHeaderKeysFormatterTest.php
@@ -17,18 +17,28 @@ final class RemoveSensitiveHeaderKeysFormatterTest extends TestCase
{
use UseHttpFactories;
+ private Formatter $decoratedFormatter;
private RemoveSensitiveHeadersFormatter $formatter;
protected function setUp(): void
{
$this->formatter = new RemoveSensitiveHeadersFormatter(
- new CallbackFormatter(
+ $this->decoratedFormatter = new CallbackFormatter(
fn (MessageInterface $message): string => $this->formatHeaders($message->getHeaders())
),
['X-API-Key', 'X-API-Secret']
);
}
+ #[Test]
+ public function it_can_be_created_as_decorator(): void
+ {
+ self::assertEquals($this->formatter, RemoveSensitiveHeadersFormatter::createDecorator([
+ 'X-API-Key',
+ 'X-API-Secret',
+ ])($this->decoratedFormatter));
+ }
+
#[DataProvider('provideJsonExpectations')]
#[Test]
public function it_can_remove_sensitive_keys_from_request(array $headers, array $expected): void
diff --git a/tests/Unit/Formatter/RemoveSensitiveJsonKeysFormatterTest.php b/tests/Unit/Formatter/RemoveSensitiveJsonKeysFormatterTest.php
index 7d7e6a5..922dc04 100644
--- a/tests/Unit/Formatter/RemoveSensitiveJsonKeysFormatterTest.php
+++ b/tests/Unit/Formatter/RemoveSensitiveJsonKeysFormatterTest.php
@@ -18,16 +18,26 @@ final class RemoveSensitiveJsonKeysFormatterTest extends TestCase
{
use UseHttpFactories;
+ private Formatter $decoratedFormatter;
private RemoveSensitiveJsonKeysFormatter $formatter;
protected function setUp(): void
{
$this->formatter = new RemoveSensitiveJsonKeysFormatter(
- new CallbackFormatter(fn (MessageInterface $message) => $message->getBody()->__toString()),
+ $this->decoratedFormatter = new CallbackFormatter(fn (MessageInterface $message) => $message->getBody()->__toString()),
['password', 'refreshToken']
);
}
+ #[Test]
+ public function it_can_be_created_as_decorator(): void
+ {
+ self::assertEquals($this->formatter, RemoveSensitiveJsonKeysFormatter::createDecorator([
+ 'password',
+ 'refreshToken',
+ ])($this->decoratedFormatter));
+ }
+
#[DataProvider('provideJsonExpectations')]
#[Test]
public function it_can_remove_sensitive_json_keys_from_request(array $content, array $expected): void
diff --git a/tests/Unit/Formatter/RemoveSensitiveQueryStringsFormatterTest.php b/tests/Unit/Formatter/RemoveSensitiveQueryStringsFormatterTest.php
index e1c6699..f28b329 100644
--- a/tests/Unit/Formatter/RemoveSensitiveQueryStringsFormatterTest.php
+++ b/tests/Unit/Formatter/RemoveSensitiveQueryStringsFormatterTest.php
@@ -16,16 +16,26 @@ final class RemoveSensitiveQueryStringsFormatterTest extends TestCase
{
use UseHttpFactories;
+ private Formatter $decoratedFormatter;
private RemoveSensitiveQueryStringsFormatter $formatter;
protected function setUp(): void
{
$this->formatter = new RemoveSensitiveQueryStringsFormatter(
- new SimpleFormatter(),
+ $this->decoratedFormatter = new SimpleFormatter(),
['apiKey', 'token']
);
}
+ #[Test]
+ public function it_can_be_created_as_decorator(): void
+ {
+ self::assertEquals($this->formatter, RemoveSensitiveQueryStringsFormatter::createDecorator([
+ 'apiKey',
+ 'token',
+ ])($this->decoratedFormatter));
+ }
+
#[DataProvider('provideJsonExpectations')]
#[Test]
public function it_can_remove_sensitive_query_strings_from_request(