Skip to content

Commit

Permalink
Add RequestIdAwareHttpClient (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
bartholdbos authored Dec 4, 2023
1 parent 6992da5 commit 7aa20cb
Show file tree
Hide file tree
Showing 12 changed files with 318 additions and 2 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ return static function (SymfonyRequestIdConfig $config): void {

// Whether to add the twig extension, defaults to true
$config->enableTwig(true);

$config->httpClient()
->enabled(true)
->tagDefaultClient(false)
->header('X-Request-Id');
};
```

Expand Down Expand Up @@ -155,6 +160,13 @@ Here's an example of a template.
</html>
```

## HttpClient integration

By default this bundle will check for services tagged with the `http_client.request_id` tag and decorate them with the RequestIdAwareHttpClient.
When `tagDefaultClient` is enabled the default symfony http client will also be tagged and thus decorated.
This will add the `X-Request-Id` header to all outgoing requests for the tagged clients.
The header name can be changed with the `header` configuration option.

## About us

At 123inkt (Part of Digital Revolution B.V.), every day more than 50 development professionals are working on improving our internal ERP
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"symfony/browser-kit": "^6.3||^7.0",
"symfony/css-selector": "^6.3||^7.0",
"symfony/messenger": "^6.3||^7.0",
"symfony/http-client": "^6.3||^7.0",
"symfony/monolog-bridge": "^6.3||^7.0",
"symfony/monolog-bundle": "^3.10",
"symfony/phpunit-bridge": "^6.3||^7.0",
Expand Down Expand Up @@ -70,6 +71,7 @@
"suggest": {
"ramsey/uuid": "Ramsey's UUID generator",
"symfony/messenger": "Symfony's messenger",
"symfony/uid": "Symfony's UUID generator"
"symfony/uid": "Symfony's UUID generator",
"symfony/http-client": "Symfony's HTTP client"
}
}
10 changes: 10 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ parameters:
count: 1
path: src/DependencyInjection/SymfonyRequestIdExtension.php

-
message: "#^Method DR\\\\SymfonyRequestId\\\\Http\\\\RequestIdAwareHttpClient\\:\\:request\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Http/RequestIdAwareHttpClient.php

-
message: "#^Method DR\\\\SymfonyRequestId\\\\Http\\\\RequestIdAwareHttpClient\\:\\:withOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Http/RequestIdAwareHttpClient.php

-
message: "#^Method DR\\\\SymfonyRequestId\\\\Monolog\\\\RequestIdProcessor\\:\\:__invoke\\(\\) has parameter \\$record with no value type specified in iterable type array\\.$#"
count: 1
Expand Down
50 changes: 50 additions & 0 deletions src/DependencyInjection/Compiler/HttpClientRequestIdPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace DR\SymfonyRequestId\DependencyInjection\Compiler;

use DR\SymfonyRequestId\DependencyInjection\SymfonyRequestIdExtension;
use DR\SymfonyRequestId\Http\RequestIdAwareHttpClient;
use DR\SymfonyRequestId\RequestIdStorageInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Parameter;
use Symfony\Component\DependencyInjection\Reference;

/**
* @codeCoverageIgnore - This is a config class
*/
class HttpClientRequestIdPass implements CompilerPassInterface
{
/**
* @SuppressWarnings(PHPMD.UnusedLocalVariable)
*/
public function process(ContainerBuilder $container)
{
if ($container->hasParameter(SymfonyRequestIdExtension::PARAMETER_KEY . '.http_client.enabled') === false ||
$container->getParameter(SymfonyRequestIdExtension::PARAMETER_KEY . '.http_client.enabled') === false
) {
return;
}

if ($container->getParameter(SymfonyRequestIdExtension::PARAMETER_KEY . '.http_client.tag_default_client') === true &&
$container->hasDefinition('http_client')
) {
$container->getDefinition('http_client')
->addTag('http_client.request_id');
}

$taggedServices = $container->findTaggedServiceIds('http_client.request_id');

foreach ($taggedServices as $id => $tag) {
$container->register($id . '.request_id', RequestIdAwareHttpClient::class)
->setArguments([
new Reference($id . '.request_id' . '.inner'),
new Reference(RequestIdStorageInterface::class),
new Parameter(SymfonyRequestIdExtension::PARAMETER_KEY . '.http_client.header')
])
->setDecoratedService($id, null, 1);
}
}
}
16 changes: 16 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ public function getConfigTreeBuilder(): TreeBuilder
->booleanNode('enable_twig')
->info('Whether or not to enable the twig `request_id()` function. Only works if TwigBundle is present.')
->defaultTrue()
->end()
->arrayNode('http_client')
->children()
->booleanNode('enabled')
->info('Whether or not to enable the request id aware http client')
->defaultTrue()
->end()
->booleanNode('tag_default_client')
->info('Whether or not to tag the default http client')
->defaultFalse()
->end()
->scalarNode('header')
->info('The header the bundle set in the request in the http client')
->defaultValue('X-Request-Id')
->end()
->end()
->end();

return $tree;
Expand Down
23 changes: 23 additions & 0 deletions src/DependencyInjection/SymfonyRequestIdExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @codeCoverageIgnore - This is a configuration class, tested by the functional test
* @internal
*/
final class SymfonyRequestIdExtension extends ConfigurableExtension
{
public const PARAMETER_KEY = 'digital_revolution.symfony_request_id';

/**
* @param array{
* request_header: string,
Expand All @@ -38,6 +41,11 @@ final class SymfonyRequestIdExtension extends ConfigurableExtension
* enable_console: bool,
* enable_messenger: bool,
* enable_twig: bool,
* http_client: array{
* enabled: bool,
* tag_default_client: bool,
* header: string
* }
* } $mergedConfig
*/
protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void
Expand Down Expand Up @@ -111,5 +119,20 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
->setPublic(false)
->addTag('twig.extension');
}

$container->setParameter(self::PARAMETER_KEY . '.http_client.enabled', false);

if ($mergedConfig['http_client']['enabled']) {
if (interface_exists(HttpClientInterface::class) === false) {
throw new LogicException(
'HttpClient support cannot be enabled as the HttpClient component is not installed. ' .
'Try running "composer require symfony/http-client".'
);
}

$container->setParameter(self::PARAMETER_KEY . '.http_client.enabled', $mergedConfig['http_client']['enabled']);
$container->setParameter(self::PARAMETER_KEY . '.http_client.tag_default_client', $mergedConfig['http_client']['tag_default_client']);
$container->setParameter(self::PARAMETER_KEY . '.http_client.header', $mergedConfig['http_client']['header']);
}
}
}
56 changes: 56 additions & 0 deletions src/Http/RequestIdAwareHttpClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace DR\SymfonyRequestId\Http;

use DR\SymfonyRequestId\RequestIdStorageInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;

class RequestIdAwareHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface
{
public function __construct(
private HttpClientInterface $client,
private readonly RequestIdStorageInterface $storage,
private readonly string $requestIdHeader
) {
}

public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options['headers'][$this->requestIdHeader] = $this->storage->getRequestId();

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

public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}

public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}

public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}

public function withOptions(array $options): static
{
$this->client = $this->client->withOptions($options);

return $this;
}
}
9 changes: 9 additions & 0 deletions src/RequestIdBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace DR\SymfonyRequestId;

use DR\SymfonyRequestId\DependencyInjection\Compiler\HttpClientRequestIdPass;
use DR\SymfonyRequestId\DependencyInjection\SymfonyRequestIdExtension;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;

Expand All @@ -13,6 +15,13 @@
*/
final class RequestIdBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);

$container->addCompilerPass(new HttpClientRequestIdPass());
}

/**
* @inheritdoc
*/
Expand Down
31 changes: 31 additions & 0 deletions tests/Functional/App/Service/MockClientCallbackHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DR\SymfonyRequestId\Tests\Functional\App\Service;

use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;

class MockClientCallbackHelper
{
/**
* @param array{
* headers: string[]
* } $options
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function __invoke(string $method, string $url, array $options): ResponseInterface
{
$headers = [];

foreach ($options['headers'] as $header) {
[$key, $value] = explode(': ', $header);
$headers[$key] = $value;
}

return new MockResponse('success', [
'response_headers' => $headers
]);
}
}
12 changes: 11 additions & 1 deletion tests/Functional/App/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ symfony_request_id:
trust_request_header: true
storage_service: request.id.storage
enable_messenger: true
http_client:
enabled: true
header: Request-Id

twig:
strict_variables: "%kernel.debug%"
Expand All @@ -48,7 +51,14 @@ services:
class: DR\SymfonyRequestId\Tests\Functional\App\EventSubscriber\StopWorkerEventSubscriber
tags:
- { name: kernel.event_subscriber }

DR\SymfonyRequestId\Tests\Functional\App\Service\MockClientCallbackHelper:
test.http_client:
class: Symfony\Component\HttpClient\MockHttpClient
public: true
arguments:
- '@DR\SymfonyRequestId\Tests\Functional\App\Service\MockClientCallbackHelper'
tags:
- { name: 'http_client.request_id' }
monolog:
handlers:
main:
Expand Down
29 changes: 29 additions & 0 deletions tests/Functional/HttpClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Functional;

use DR\SymfonyRequestId\Tests\Functional\App\Service\TestRequestIdStorage;
use PHPUnit\Framework\Attributes\CoversNothing;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[CoversNothing]
class HttpClientTest extends KernelTestCase
{
public function testHttpClientIsDecorated(): void
{
/** @var TestRequestIdStorage $storage */
$storage = static::getContainer()->get('request.id.storage');
/** @var HttpClientInterface $client */
$client = static::getContainer()->get('test.http_client');

$storage->setRequestId('123');

$response = $client->request('GET', 'https://example.com');

self::assertArrayHasKey('request-id', $response->getHeaders());
self::assertSame('123', $response->getHeaders()['request-id'][0]);
}
}
Loading

0 comments on commit 7aa20cb

Please sign in to comment.