From 2304da984045423c08798ea9fe3c1f0eb8d5020f Mon Sep 17 00:00:00 2001 From: rgraillon Date: Tue, 1 Sep 2020 16:35:00 +0200 Subject: [PATCH 1/3] Add Symfony HttpClient support --- .../CompilerPass/HttpClientPass.php | 41 +++++++++ ETSGlobalLogBundle.php | 2 + README.md | 21 +++++ .../CompilerPass/HttpClientPassTest.php | 72 +++++++++++++++ .../Symfony/HttpClientDecoratorTest.php | 90 +++++++++++++++++++ .../Plugins/Symfony/HttpClientDecorator.php | 40 +++++++++ composer.json | 8 +- 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 DependencyInjection/CompilerPass/HttpClientPass.php create mode 100644 Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php create mode 100644 Tests/Tracing/Plugins/Symfony/HttpClientDecoratorTest.php create mode 100644 Tracing/Plugins/Symfony/HttpClientDecorator.php diff --git a/DependencyInjection/CompilerPass/HttpClientPass.php b/DependencyInjection/CompilerPass/HttpClientPass.php new file mode 100644 index 0000000..8f2a472 --- /dev/null +++ b/DependencyInjection/CompilerPass/HttpClientPass.php @@ -0,0 +1,41 @@ +hasDefinition('ets_global_log.tracing.token_collection')) { + throw new \RuntimeException('The token collection service definition is missing.'); + } + $tokenCollectionDefinition = $container->getDefinition('ets_global_log.tracing.token_collection'); + + $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('ets_global_log.tracing.token_collection')); + + $container->setDefinition(HttpClientDecorator::class, $decorator); + } + } +} diff --git a/ETSGlobalLogBundle.php b/ETSGlobalLogBundle.php index 5887b1a..ae8a5d6 100644 --- a/ETSGlobalLogBundle.php +++ b/ETSGlobalLogBundle.php @@ -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; @@ -13,5 +14,6 @@ class ETSGlobalLogBundle extends Bundle public function build(ContainerBuilder $container): void { $container->addCompilerPass(new LoggerAwarePass()); + $container->addCompilerPass(new HttpClientPass()); } } diff --git a/README.md b/README.md index c595f57..bdc475f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`: diff --git a/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php new file mode 100644 index 0000000..5497f3d --- /dev/null +++ b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php @@ -0,0 +1,72 @@ +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 + { + $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()); + } +} diff --git a/Tests/Tracing/Plugins/Symfony/HttpClientDecoratorTest.php b/Tests/Tracing/Plugins/Symfony/HttpClientDecoratorTest.php new file mode 100644 index 0000000..893759a --- /dev/null +++ b/Tests/Tracing/Plugins/Symfony/HttpClientDecoratorTest.php @@ -0,0 +1,90 @@ +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); + } +} diff --git a/Tracing/Plugins/Symfony/HttpClientDecorator.php b/Tracing/Plugins/Symfony/HttpClientDecorator.php new file mode 100644 index 0000000..88a6780 --- /dev/null +++ b/Tracing/Plugins/Symfony/HttpClientDecorator.php @@ -0,0 +1,40 @@ +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); + } + + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + return $this->httpClient->stream($responses, $timeout); + } +} diff --git a/composer.json b/composer.json index 205de5c..85c42d6 100644 --- a/composer.json +++ b/composer.json @@ -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": { From 48e6cb9654dbff29a2ccd7cf3c8a45b1f637904f Mon Sep 17 00:00:00 2001 From: rgraillon Date: Wed, 2 Sep 2020 15:14:50 +0200 Subject: [PATCH 2/3] Fix tests --- DependencyInjection/CompilerPass/HttpClientPass.php | 5 +++-- .../DependencyInjection/CompilerPass/HttpClientPassTest.php | 2 +- Tracing/Plugins/Symfony/HttpClientDecorator.php | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/DependencyInjection/CompilerPass/HttpClientPass.php b/DependencyInjection/CompilerPass/HttpClientPass.php index 8f2a472..239a885 100644 --- a/DependencyInjection/CompilerPass/HttpClientPass.php +++ b/DependencyInjection/CompilerPass/HttpClientPass.php @@ -19,12 +19,13 @@ */ 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.'); } - $tokenCollectionDefinition = $container->getDefinition('ets_global_log.tracing.token_collection'); $taggedServices = $container->findTaggedServiceIds('http_client.client'); foreach ($taggedServices as $id => $attributes) { @@ -33,7 +34,7 @@ public function process(ContainerBuilder $container): void $decorator = new Definition(HttpClientDecorator::class); $decorator->setDecoratedService($id); $decorator->setArgument('$httpClient', $httpClientDefinition); - $decorator->setArgument('$tokenCollection', new Reference('ets_global_log.tracing.token_collection')); + $decorator->setArgument('$tokenCollection', new Reference(self::TOKEN_COLLECTION_SERVICE_ID)); $container->setDefinition(HttpClientDecorator::class, $decorator); } diff --git a/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php index 5497f3d..51fc6b4 100644 --- a/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php +++ b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php @@ -22,8 +22,8 @@ protected function setUp(): void { $this->containerBuilder = new ContainerBuilder(); $this->containerBuilder->addCompilerPass(new HttpClientPass()); - } + public function testThrowsExceptionWhenMissingTokenCollectionService(): void { $this->expectException(\RuntimeException::class); diff --git a/Tracing/Plugins/Symfony/HttpClientDecorator.php b/Tracing/Plugins/Symfony/HttpClientDecorator.php index 88a6780..861ceb9 100644 --- a/Tracing/Plugins/Symfony/HttpClientDecorator.php +++ b/Tracing/Plugins/Symfony/HttpClientDecorator.php @@ -33,7 +33,10 @@ public function request(string $method, string $url, array $options = []): Respo return $this->httpClient->request($method, $url, $options); } - public function stream($responses, float $timeout = null): ResponseStreamInterface + /** + * {@inheritDoc} + */ + public function stream($responses, ?float $timeout = null): ResponseStreamInterface { return $this->httpClient->stream($responses, $timeout); } From da94c731cb3024691d699fce8db35609efa7c4e6 Mon Sep 17 00:00:00 2001 From: rgraillon Date: Wed, 2 Sep 2020 15:42:37 +0200 Subject: [PATCH 3/3] Skip test on Symfony versions <4.3.0 --- .../DependencyInjection/CompilerPass/HttpClientPassTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php index 51fc6b4..0be8b0d 100644 --- a/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php +++ b/Tests/DependencyInjection/CompilerPass/HttpClientPassTest.php @@ -11,6 +11,7 @@ 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 @@ -44,6 +45,10 @@ public function testDoesNothingWhenNoHttpClientTag(): void 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);