diff --git a/README.md b/README.md index 9f64ed5..cf009fc 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ setono_meta_conversions_api: - id: '%env(META_PIXEL_ID)%' access_token: '%env(META_ACCESS_TOKEN)%' + # The PSR-18 http client used to send events. Defaults to Symfony's default http client, which means requests + # to Meta show up in the profiler and honour the options you configured. Point it at a scoped client to give + # Meta its own timeout + http_client: psr18.http_client + # Send events as test events, so they show up under 'Test events' in Meta's event manager instead of counting # as real conversions test_event_code: @@ -246,6 +251,35 @@ If such an event is raised while handling an HTTP request, for instance a webhoo request properties still describe *that* request, not the customer. Overwrite them in a listener above `PRIORITY_POPULATE` when they matter. +### Giving Meta its own timeout + +Because the client is a normal service, a scoped client works out of the box: + +```yaml +framework: + http_client: + scoped_clients: + meta.client: + base_uri: 'https://graph.facebook.com' + timeout: 2 + max_duration: 5 + +setono_meta_conversions_api: + http_client: meta.client +``` + +Note that a scoped client is a Symfony `HttpClientInterface`, so wrap it for PSR-18: + +```yaml +services: + meta.psr18_client: + class: Symfony\Component\HttpClient\Psr18Client + arguments: ['@meta.client'] + +setono_meta_conversions_api: + http_client: meta.psr18_client +``` + ## Graph API version Events are posted to the Graph API version of the installed `facebook/php-business-sdk` package (the SDK reads diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0054ad3..6bfff6a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -68,6 +68,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->scalarNode('http_client') + ->info('The PSR-18 http client used to send events. Defaults to the application\'s psr18.http_client, i.e. the default Symfony http client. Point it at a scoped client to give Meta its own timeout') + ->defaultValue('psr18.http_client') + ->cannotBeEmpty() + ->end() ->arrayNode('test_event_code') ->info('Send events as test events, see https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api#testEvents') ->addDefaultsIfNotSet() diff --git a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php index 1247cc6..918ac83 100644 --- a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php +++ b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php @@ -26,7 +26,7 @@ public function getConfiguration(array $config, ContainerBuilder $container): Co public function load(array $configs, ContainerBuilder $container): void { /** - * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, test_event_code: array{query_parameter: bool, value: string|null}, filters: array{user_agent: list}} $config + * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, filters: array{user_agent: list}} $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); // The XML format is deprecated since Symfony 7.4 and removed in 8.0. Migrate to PHP config before adding Symfony 8 support @@ -43,6 +43,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('setono_meta_conversions_api.test_event_code.value', '' === $testEventCode ? null : $testEventCode); $container->setParameter('setono_meta_conversions_api.test_event_code.query_parameter', $config['test_event_code']['query_parameter']); + // The reference to this alias is optional, so an application without symfony/http-client simply lets the + // SDK fall back to php-http/discovery + $container->setAlias('setono_meta_conversions_api.http_client', $config['http_client']); + $loader->load('services.xml'); if ($config['test_event_code']['query_parameter']) { diff --git a/src/Resources/config/services/client.xml b/src/Resources/config/services/client.xml index e7cf1e4..ad5da9b 100644 --- a/src/Resources/config/services/client.xml +++ b/src/Resources/config/services/client.xml @@ -6,10 +6,28 @@ + + + + + + + + + + + + diff --git a/tests/Double/RecordingHttpClientFactory.php b/tests/Double/RecordingHttpClientFactory.php new file mode 100644 index 0000000..136bc5a --- /dev/null +++ b/tests/Double/RecordingHttpClientFactory.php @@ -0,0 +1,31 @@ + */ + public static array $requests = []; + + public static function reset(): void + { + self::$requests = []; + } + + public static function create(): MockHttpClient + { + return new MockHttpClient(static function (string $method, string $url): MockResponse { + self::$requests[] = [$method, $url]; + + return new MockResponse('{"events_received":1}'); + }); + } +} diff --git a/tests/Integration/SetonoMetaConversionsApiBundleTest.php b/tests/Integration/SetonoMetaConversionsApiBundleTest.php index aa12100..b672db7 100644 --- a/tests/Integration/SetonoMetaConversionsApiBundleTest.php +++ b/tests/Integration/SetonoMetaConversionsApiBundleTest.php @@ -4,19 +4,28 @@ namespace Setono\MetaConversionsApiBundle\Tests\Integration; +use FacebookAds\ApiConfig; use Nyholm\BundleTest\TestKernel; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\Test; use Setono\BotDetectionBundle\SetonoBotDetectionBundle; use Setono\ConsentBundle\SetonoConsentBundle; +use Setono\MetaConversionsApi\Client\ClientInterface; +use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Pixel\Pixel; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\EventSubscriber\AddEventToTagBagSubscriber; use Setono\MetaConversionsApiBundle\EventSubscriber\AddLibraryToTagBagSubscriber; use Setono\MetaConversionsApiBundle\EventSubscriber\DispatchOnCommandBusSubscriber; use Setono\MetaConversionsApiBundle\Message\Handler\SendEventHandler; use Setono\MetaConversionsApiBundle\SetonoMetaConversionsApiBundle; +use Setono\MetaConversionsApiBundle\Tests\Double\RecordingHttpClientFactory; use Setono\TagBagBundle\SetonoTagBagBundle; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpKernel\KernelInterface; final class SetonoMetaConversionsApiBundleTest extends KernelTestCase @@ -218,6 +227,64 @@ public function it_dispatches_on_the_configured_message_bus(): void ); } + #[Test] + public function it_sends_events_through_the_applications_http_client(): void + { + RecordingHttpClientFactory::reset(); + + self::bootKernel(['config' => function (TestKernel $kernel) { + $kernel->addTestConfig(static function (ContainerBuilder $container) { + $container->loadFromExtension('setono_meta_conversions_api', [ + 'client_side' => false, + ]); + + $container->register('test.psr17_factory', Psr17Factory::class); + $container->register('test.mock_http_client', MockHttpClient::class) + ->setFactory([RecordingHttpClientFactory::class, 'create']); + + // Replacing the application's PSR-18 client must be enough to intercept everything the bundle + // sends. That only holds because the client is wired instead of discovered at runtime + $container->register('psr18.http_client', Psr18Client::class) + ->setArguments([ + new Reference('test.mock_http_client'), + new Reference('test.psr17_factory'), + new Reference('test.psr17_factory'), + ]); + + $container->setAlias('test.conversions_api_client', ClientInterface::class)->setPublic(true); + }); + }]); + + $event = new Event(Event::EVENT_VIEW_CONTENT); + $event->pixels = [new Pixel('1234', 's3cr3t')]; + + $client = self::getContainer()->get('test.conversions_api_client'); + self::assertInstanceOf(ClientInterface::class, $client); + $client->sendEvent($event); + + // The Graph API version follows whichever facebook/php-business-sdk is installed + self::assertSame( + [['POST', sprintf('https://graph.facebook.com/v%s/1234/events', ApiConfig::APIVersion)]], + RecordingHttpClientFactory::$requests, + ); + } + + #[Test] + public function it_boots_when_the_configured_http_client_does_not_exist(): void + { + // Without symfony/http-client there is no psr18.http_client, and the SDK falls back to discovery + self::bootKernel(['config' => function (TestKernel $kernel) { + $kernel->addTestConfig(static function (ContainerBuilder $container) { + $container->loadFromExtension('setono_meta_conversions_api', [ + 'client_side' => false, + 'http_client' => 'a.http.client.that.does.not.exist', + ]); + }); + }]); + + self::assertTrue(self::getContainer()->has(SendEventHandler::class)); + } + #[Test] public function it_works_with_consent_bundle(): void {