Skip to content

Commit

Permalink
Merge pull request #5 from ETSGlobal/add-http-client-support
Browse files Browse the repository at this point in the history
Add Symfony HttpClient support
  • Loading branch information
graillus authored Sep 7, 2020
2 parents cd3355d + da94c73 commit 51f578d
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 3 deletions.
42 changes: 42 additions & 0 deletions DependencyInjection/CompilerPass/HttpClientPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace ETSGlobal\LogBundle\DependencyInjection\CompilerPass;

use ETSGlobal\LogBundle\Tracing\Plugins\Symfony\HttpClientDecorator;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

/**
* This compiler pass will create decorated instances of HttpClient.
*
* All services with tag `http_client.client` will be decorated by a
* HttpClientDecorator so we make sure all scoped clients are taken into account.
* All these original HttpClient services will be substituted by the new decorated services.
*/
class HttpClientPass implements CompilerPassInterface
{
private const TOKEN_COLLECTION_SERVICE_ID = 'ets_global_log.tracing.token_collection';

public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('ets_global_log.tracing.token_collection')) {
throw new \RuntimeException('The token collection service definition is missing.');
}

$taggedServices = $container->findTaggedServiceIds('http_client.client');
foreach ($taggedServices as $id => $attributes) {
$httpClientDefinition = $container->getDefinition($id);

$decorator = new Definition(HttpClientDecorator::class);
$decorator->setDecoratedService($id);
$decorator->setArgument('$httpClient', $httpClientDefinition);
$decorator->setArgument('$tokenCollection', new Reference(self::TOKEN_COLLECTION_SERVICE_ID));

$container->setDefinition(HttpClientDecorator::class, $decorator);
}
}
}
2 changes: 2 additions & 0 deletions ETSGlobalLogBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace ETSGlobal\LogBundle;

use ETSGlobal\LogBundle\DependencyInjection\CompilerPass\HttpClientPass;
use ETSGlobal\LogBundle\DependencyInjection\CompilerPass\LoggerAwarePass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
Expand All @@ -13,5 +14,6 @@ class ETSGlobalLogBundle extends Bundle
public function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new LoggerAwarePass());
$container->addCompilerPass(new HttpClientPass());
}
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Main features:
- Automatically enrich log context with the application name and tracing tokens.
- Slack handler: An extended version of Monolog's slack handler, with custom message contents, and custom filters.
- Provides a Guzzle middleware to forward tokens through HTTP calls.
- Provides a Symfony HttpClient decorator to forward tokens through HTTP calls.

## Installation

Expand Down Expand Up @@ -121,6 +122,26 @@ App\MyService:
- { name: "ets_global_log.logger_aware" }
```

### Symfony HttpClient decorator

Install `symfony/http-client`:

```bash
composer require symfony/http-client
```

Configure your scoped client:
```yaml
framework:
http_client:
scoped_clients:
my.client:
base_uri: 'example.com/api/'
```

Just inject the `my.client` HttpClient in your services like normally.
The `HttpClientDecorator` will decorate the HttpClient to automatically inject the `token_global` in the request.

### Guzzle middleware

Install `csa/guzzle-bundle`:
Expand Down
77 changes: 77 additions & 0 deletions Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace DependencyInjection\CompilerPass;

use ETSGlobal\LogBundle\DependencyInjection\CompilerPass\HttpClientPass;
use ETSGlobal\LogBundle\Tracing\Plugins\Symfony\HttpClientDecorator;
use ETSGlobal\LogBundle\Tracing\TokenCollection;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class HttpClientPassTest extends TestCase
{
/** @var ContainerBuilder */
private $containerBuilder;

protected function setUp(): void
{
$this->containerBuilder = new ContainerBuilder();
$this->containerBuilder->addCompilerPass(new HttpClientPass());
}

public function testThrowsExceptionWhenMissingTokenCollectionService(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('The token collection service definition is missing.');

$this->containerBuilder->compile();
}

public function testDoesNothingWhenNoHttpClientTag(): void
{
$tokenCollectionDefinition = new Definition(TokenCollection::class);
$this->containerBuilder->setDefinition('ets_global_log.tracing.token_collection', $tokenCollectionDefinition);

$this->containerBuilder->compile();

Assert::assertFalse($this->containerBuilder->hasDefinition(HttpClientDecorator::class));
}

public function testDecoratesHttpClient(): void
{
if (version_compare(Kernel::VERSION, '4.3.0', '<=')) {
$this->markTestSkipped('HttpClient is not supported until Symfony 4.3.0');
}

$tokenCollectionDefinition = new Definition(TokenCollection::class);
$tokenCollectionDefinition->addMethodCall('add', ['process']);
$this->containerBuilder->setDefinition('ets_global_log.tracing.token_collection', $tokenCollectionDefinition);

$serviceDefinition = new Definition(HttpClientInterface::class);
$serviceDefinition->setPublic(true); // Avoid service being removed because never used by other services.
$serviceDefinition->addTag('http_client.client');

$this->containerBuilder->setDefinition('my_client', $serviceDefinition);

$this->containerBuilder->compile();

Assert::assertTrue($this->containerBuilder->hasDefinition('my_client'));

$compiledDefinition = $this->containerBuilder->getDefinition('my_client');
Assert::assertSame(HttpClientDecorator::class, $compiledDefinition->getClass());

Assert::assertTrue($compiledDefinition->hasTag('http_client.client'));

Assert::assertCount(2, $compiledDefinition->getArguments());

/** @var Definition $tokenCollectionArgument */
$tokenCollectionArgument = $compiledDefinition->getArgument(1);
Assert::assertCount(1, $tokenCollectionArgument->getMethodCalls());
}
}
90 changes: 90 additions & 0 deletions Tests/Tracing/Plugins/Symfony/HttpClientDecoratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace ETSGlobal\LogBundle\Tests\Tracing\Plugins\Symfony;

use ETSGlobal\LogBundle\Tracing\Plugins\Symfony\HttpClientDecorator;
use ETSGlobal\LogBundle\Tracing\TokenCollection;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;

class HttpClientDecoratorTest extends TestCase
{
use ProphecyTrait;

/** @var HttpClientInterface|ObjectProphecy */
private $httpClientMock;

/** @var TokenCollection|ObjectProphecy */
private $tokenCollectionMock;

/** @var HttpClientDecorator */
private $decorator;

protected function setUp(): void
{
$this->httpClientMock = $this->prophesize(HttpClientInterface::class);
$this->tokenCollectionMock = $this->prophesize(TokenCollection::class);
$this->decorator = new HttpClientDecorator(
$this->httpClientMock->reveal(),
$this->tokenCollectionMock->reveal()
);
}

public function testInjectsTokenGlobalInRequestHeaders(): void
{
$options = [];
$expectedOptions = [
'headers' => [
'X-Token-Global' => 'token_global',
],
];

$this->tokenCollectionMock
->getTokenValue('global')
->willReturn('token_global')
;

$this->httpClientMock
->request('GET', 'example.com/api', $expectedOptions)
->shouldBeCalled()
;

$this->decorator->request('GET', 'example.com/api', $options);
}

public function testForwardsResponse(): void
{
$expectedResponse = $this->prophesize(ResponseInterface::class)->reveal();

$this->httpClientMock
->request('GET', 'example.com/api', Argument::type('array'))
->willReturn($expectedResponse)
;

$response = $this->decorator->request('GET', 'example.com/api', []);

Assert::assertSame($response, $expectedResponse);
}

public function testForwardsStreamResponse(): void
{
$expectedResponse = $this->prophesize(ResponseStreamInterface::class)->reveal();

$this->httpClientMock
->stream([], 30)
->willReturn($expectedResponse)
;

$response = $this->decorator->stream([], 30);

Assert::assertSame($response, $expectedResponse);
}
}
43 changes: 43 additions & 0 deletions Tracing/Plugins/Symfony/HttpClientDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace ETSGlobal\LogBundle\Tracing\Plugins\Symfony;

use ETSGlobal\LogBundle\Tracing\TokenCollection;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;

/**
* Decorates a HttpClientInterface and injects the tracing token in the request.
*/
class HttpClientDecorator implements HttpClientInterface
{
/** @var HttpClientInterface */
private $httpClient;

/** @var TokenCollection */
private $tokenCollection;

public function __construct(HttpClientInterface $httpClient, TokenCollection $tokenCollection)
{
$this->httpClient = $httpClient;
$this->tokenCollection = $tokenCollection;
}

public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options['headers']['X-Token-Global'] = $this->tokenCollection->getTokenValue('global');

return $this->httpClient->request($method, $url, $options);
}

/**
* {@inheritDoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->httpClient->stream($responses, $timeout);
}
}
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@
"psr/http-message": "^1.0",
"slevomat/coding-standard": "^6.4",
"squizlabs/php_codesniffer": "^3.5",
"symfony/phpunit-bridge": "^5.1"
"symfony/phpunit-bridge": "^5.1",
"symfony/http-client-contracts": "^2.1"
},
"suggest": {
"csa/guzzle-bundle": "Allow token forwarding through Guzzle HTTP calls"
"csa/guzzle-bundle": "Allow token forwarding through Guzzle HTTP calls",
"symfony/http-client": "Allow token forwarding through Symfony's HttpClient HTTP calls"
},
"autoload": {
"psr-4": {
"ETSGlobal\\LogBundle\\": ""
"ETSGlobal\\LogBundle\\": "\\"
}
},
"autoload-dev": {
Expand Down

0 comments on commit 51f578d

Please sign in to comment.