diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4131406..324c284 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -79,7 +79,6 @@ jobs: - "highest" symfony: - - "~5.4.0" - "~6.4.0" steps: @@ -126,7 +125,6 @@ jobs: - "highest" symfony: - - "~5.4.0" - "~6.4.0" steps: @@ -170,7 +168,6 @@ jobs: - "highest" symfony: - - "~5.4.0" - "~6.4.0" steps: @@ -211,7 +208,6 @@ jobs: - "highest" symfony: - - "~5.4.0" - "~6.4.0" steps: diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..16f3cf4 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,5 @@ +{ + "symbol-whitelist": [ + "Setono\\ClientBundle\\Context\\ClientContextInterface" + ] +} diff --git a/composer.json b/composer.json index 20cd8f7..a6fe92e 100644 --- a/composer.json +++ b/composer.json @@ -30,20 +30,20 @@ "sylius/order": "^1.0", "sylius/resource-bundle": "^1.6", "sylius/ui-bundle": "^1.0", - "symfony/config": "^5.4 || ^6.4", - "symfony/console": "^5.4 || ^6.4", - "symfony/dependency-injection": "^5.4 || ^6.4", - "symfony/event-dispatcher": "^5.4 || ^6.4", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", "symfony/event-dispatcher-contracts": "^2.5 || ^3.3", - "symfony/form": "^5.4 || ^6.4", - "symfony/http-foundation": "^5.4 || ^6.4", - "symfony/http-kernel": "^5.4 || ^6.4", - "symfony/messenger": "^5.4 || ^6.4", - "symfony/options-resolver": "^5.4 || ^6.4", - "symfony/routing": "^5.4 || ^6.4", - "symfony/string": "^5.4 || ^6.4", - "symfony/validator": "^5.4 || ^6.4", - "symfony/workflow": "^5.4 || ^6.4", + "symfony/form": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/options-resolver": "^6.4 || ^7.0", + "symfony/routing": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/workflow": "^6.4 || ^7.0", "twig/twig": "^2.15 || ^3.4", "webmozart/assert": "^1.11" }, @@ -57,13 +57,14 @@ "matthiasnoback/symfony-config-test": "^4.3 || ^5.1", "phpunit/phpunit": "^9.6", "psalm/plugin-phpunit": "^0.18", + "setono/client-bundle": "^1.0@beta", "setono/code-quality-pack": "^2.7", "sylius/sylius": "~1.12.15", - "symfony/debug-bundle": "^5.4 || ^6.4", - "symfony/dotenv": "^5.4 || ^6.4", - "symfony/intl": "^5.4 || ^6.4", - "symfony/serializer": "^5.4 || ^6.0.1", - "symfony/web-profiler-bundle": "^5.4 || ^6.4", + "symfony/debug-bundle": "^6.4 || ^7.0", + "symfony/dotenv": "^6.4 || ^7.0", + "symfony/intl": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/webpack-encore-bundle": "^1.17", "weirdan/doctrine-psalm-plugin": "^2.9", "willdurand/negotiation": "^3.1" diff --git a/psalm.xml b/psalm.xml index 3e1dfc5..5fb1104 100644 --- a/psalm.xml +++ b/psalm.xml @@ -23,7 +23,7 @@ - + diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 733e384..a9f4c22 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -28,6 +28,24 @@ public function getConfigTreeBuilder(): TreeBuilder /** @var ArrayNodeDefinition $rootNode */ $rootNode = $treeBuilder->getRootNode(); + /** @psalm-suppress MixedMethodCall,PossiblyNullReference,UndefinedInterfaceMethod */ + $rootNode + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('cookie_name') + ->defaultValue('ssga_tinfo') + ->cannotBeEmpty() + ->info('The name of the cookie to store the tracking information in if the storage is set to cookie') + ->end() + ->scalarNode('storage') + ->defaultValue('cookie') + ->cannotBeEmpty() + ->info('The storage to use for tracking information. Available options are: cookie and client_metadata') + ->validate() + ->ifNotInArray(['cookie', 'client_metadata']) + ->thenInvalid('Invalid storage %s') + ; + $this->addResourcesSection($rootNode); return $treeBuilder; diff --git a/src/DependencyInjection/SetonoSyliusGoogleAdsExtension.php b/src/DependencyInjection/SetonoSyliusGoogleAdsExtension.php index b8bfccd..eb4fa8e 100644 --- a/src/DependencyInjection/SetonoSyliusGoogleAdsExtension.php +++ b/src/DependencyInjection/SetonoSyliusGoogleAdsExtension.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Webmozart\Assert\Assert; final class SetonoSyliusGoogleAdsExtension extends AbstractResourceExtension implements PrependExtensionInterface { @@ -22,7 +23,7 @@ public function load(array $configs, ContainerBuilder $container): void /** * @psalm-suppress PossiblyNullArgument * - * @var array{resources: array} $config + * @var array{cookie_name: string, storage: string, resources: array} $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); @@ -35,6 +36,22 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('setono_sylius_google_ads.qualification_voter') ; + $container->setParameter('setono_sylius_google_ads.cookie_name', $config['cookie_name']); + $container->setParameter('setono_sylius_google_ads.storage', $config['storage']); + + if ('cookie' === $config['storage']) { + $loader->load('services/conditional/storage_cookie.xml'); + } else { + $bundles = $container->getParameter('kernel.bundles'); + Assert::isArray($bundles); + + if (!array_key_exists('SetonoClientBundle', $bundles)) { + throw new \RuntimeException('You need to install the SetonoClientBundle in order to use the client_metadata storage. Run "composer require setono/client-bundle" to install it. See https://github.com/Setono/client-bundle'); + } + + $loader->load('services/conditional/storage_client_metadata.xml'); + } + $loader->load('services.xml'); $this->registerResources( diff --git a/src/EventSubscriber/MigrateStorageSubscriber.php b/src/EventSubscriber/MigrateStorageSubscriber.php new file mode 100644 index 0000000..877a48b --- /dev/null +++ b/src/EventSubscriber/MigrateStorageSubscriber.php @@ -0,0 +1,65 @@ + ['migrate', 5], // we want to run this before the StoreTrackingInformationSubscriber + KernelEvents::RESPONSE => 'remove', + ]; + } + + public function migrate(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + try { + $this->trackingInformation = TrackingInformation::fromCookie($event->getRequest(), $this->cookieName); + } catch (\Throwable) { + return; + } + + $this->trackingInformationStorage->store($this->trackingInformation); + } + + public function remove(ResponseEvent $event): void + { + if (null === $this->trackingInformation || !$event->isMainRequest()) { + return; + } + + try { + $cookie = $this->trackingInformation->toCookie($this->cookieName, 1); + } catch (\Throwable) { + return; + } + + $event->getResponse()->headers->setCookie($cookie); + } +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index c5643f9..0c142cf 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -13,6 +13,5 @@ - diff --git a/src/Resources/config/services/conditional/storage_client_metadata.xml b/src/Resources/config/services/conditional/storage_client_metadata.xml new file mode 100644 index 0000000..e425b9b --- /dev/null +++ b/src/Resources/config/services/conditional/storage_client_metadata.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + %setono_sylius_google_ads.cookie_name% + + + + + diff --git a/src/Resources/config/services/tracking_information.xml b/src/Resources/config/services/conditional/storage_cookie.xml similarity index 67% rename from src/Resources/config/services/tracking_information.xml rename to src/Resources/config/services/conditional/storage_cookie.xml index f8b0db5..101939e 100644 --- a/src/Resources/config/services/tracking_information.xml +++ b/src/Resources/config/services/conditional/storage_cookie.xml @@ -2,14 +2,6 @@ - - - ssga_tinfo - @@ -17,7 +9,7 @@ - %setono_sylius_google_ads.tracking_information_cookie_name% + %setono_sylius_google_ads.cookie_name% diff --git a/src/TrackingInformation/AbstractTrackingInformationStorage.php b/src/TrackingInformation/AbstractTrackingInformationStorage.php new file mode 100644 index 0000000..b4e3b15 --- /dev/null +++ b/src/TrackingInformation/AbstractTrackingInformationStorage.php @@ -0,0 +1,52 @@ +logger = new NullLogger(); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'persist', + ]; + } + + public function store(Request|TrackingInformation $value): void + { + if ($value instanceof Request) { + try { + $value = TrackingInformation::fromQuery($value); + } catch (\InvalidArgumentException) { + return; + } + } + + $this->trackingInformation = $value; + } + + abstract public function persist(ResponseEvent $event): void; + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } +} diff --git a/src/TrackingInformation/ClientMetadataBasedTrackingInformationStorage.php b/src/TrackingInformation/ClientMetadataBasedTrackingInformationStorage.php new file mode 100644 index 0000000..57f6681 --- /dev/null +++ b/src/TrackingInformation/ClientMetadataBasedTrackingInformationStorage.php @@ -0,0 +1,48 @@ +clientContext->getClient()->metadata; + if (!$clientMetadata->has($this->metadataKey)) { + return null; + } + + try { + $data = $clientMetadata->get($this->metadataKey); + Assert::isArray($data); + + return TrackingInformation::fromArray($data); + } catch (\InvalidArgumentException) { + // the data is corrupted, remove it + $clientMetadata->remove($this->metadataKey); + + return null; + } + } + + public function persist(ResponseEvent $event): void + { + if (null === $this->trackingInformation) { + return; + } + + $this->clientContext->getClient()->metadata->set($this->metadataKey, $this->trackingInformation); + } +} diff --git a/src/TrackingInformation/CookieBasedTrackingInformationStorage.php b/src/TrackingInformation/CookieBasedTrackingInformationStorage.php index 3bccd72..d1d5489 100644 --- a/src/TrackingInformation/CookieBasedTrackingInformationStorage.php +++ b/src/TrackingInformation/CookieBasedTrackingInformationStorage.php @@ -4,45 +4,14 @@ namespace Setono\SyliusGoogleAdsPlugin\TrackingInformation; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Event\ResponseEvent; -use Symfony\Component\HttpKernel\KernelEvents; -final class CookieBasedTrackingInformationStorage implements TrackingInformationStorageInterface, EventSubscriberInterface, LoggerAwareInterface +final class CookieBasedTrackingInformationStorage extends AbstractTrackingInformationStorage { - private LoggerInterface $logger; - - private ?TrackingInformation $trackingInformation = null; - public function __construct(private readonly RequestStack $requestStack, private readonly string $cookieName) { - $this->logger = new NullLogger(); - } - - public static function getSubscribedEvents(): array - { - return [ - KernelEvents::RESPONSE => 'persist', - ]; - } - - public function store(Request|TrackingInformation $value): void - { - if ($value instanceof Request) { - try { - $value = TrackingInformation::fromRequest($value); - } catch (\InvalidArgumentException) { - return; - } - } - - $this->trackingInformation = $value; + parent::__construct(); } public function get(): ?TrackingInformation @@ -52,29 +21,10 @@ public function get(): ?TrackingInformation return null; } - $encodedCookieValue = $request->cookies->get($this->cookieName); - if (!is_string($encodedCookieValue) || '' === $encodedCookieValue) { - return null; - } - - $decodedCookieValue = base64_decode($encodedCookieValue, true); - if (false === $decodedCookieValue) { - $this->logger->error(sprintf( - 'The tracking information cookie was present, but the data was corrupt. The encoded data was: "%s"', - $encodedCookieValue, - )); - - return null; - } - try { - return TrackingInformation::fromJson($decodedCookieValue); + return TrackingInformation::fromCookie($request, $this->cookieName); } catch (\Throwable $e) { - $this->logger->error(sprintf( - 'The tracking information cookie was present, but the data was corrupt. The JSON was: "%s" and the error was: %s', - $decodedCookieValue, - $e->getMessage(), - )); + $this->logger->error($e); return null; } @@ -87,26 +37,16 @@ public function persist(ResponseEvent $event): void } try { - $json = json_encode($this->trackingInformation, \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->logger->error(sprintf('Could not JSON encode the tracking information. The error was: %s', $e->getMessage())); + $cookie = $this->trackingInformation->toCookie( + $this->cookieName, + new \DateTimeImmutable('+90 days'), // todo this should be set to the 'Click-through conversion window' in your Google conversion action settings + ); + } catch (\Throwable $e) { + $this->logger->error(sprintf('Could not create a cookie based on the tracking information. The error was: %s', $e->getMessage())); return; } - $event->getResponse()->headers->setCookie(Cookie::create( - $this->cookieName, - base64_encode($json), - new \DateTimeImmutable('+90 days'), // this should be set to the 'Click-through conversion window' in your Google conversion action settings - null, - null, - false, - false, - )); - } - - public function setLogger(LoggerInterface $logger): void - { - $this->logger = $logger; + $event->getResponse()->headers->setCookie($cookie); } } diff --git a/src/TrackingInformation/TrackingInformation.php b/src/TrackingInformation/TrackingInformation.php index 678c6e2..5daa7c0 100644 --- a/src/TrackingInformation/TrackingInformation.php +++ b/src/TrackingInformation/TrackingInformation.php @@ -5,6 +5,7 @@ namespace Setono\SyliusGoogleAdsPlugin\TrackingInformation; use Setono\SyliusGoogleAdsPlugin\Model\ConversionInterface; +use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; use Webmozart\Assert\Assert; @@ -21,9 +22,11 @@ public function __construct( } /** + * Will read the tracking information from the query parameters, if they are present + * * @throws \InvalidArgumentException if none of the tracking parameters are present in the request */ - public static function fromRequest(Request $request): self + public static function fromQuery(Request $request): self { /** @var array $data */ $data = []; @@ -37,23 +40,54 @@ public static function fromRequest(Request $request): self $data[$param] = $val; } - return new self($data['gclid'] ?? null, $data['gbraid'] ?? null, $data['wbraid'] ?? null); + return self::fromArray($data); } /** - * @throws \JsonException if the json is invalid - * @throws \InvalidArgumentException if the decoded data is invalid + * @throws \InvalidArgumentException if the cookie does not exist or is corrupt + * @throws \JsonException if the JSON data is corrupt */ - public static function fromJson(string $json): self + public static function fromCookie(Request $request, string $cookieName): self { + $value = $request->cookies->get($cookieName); + Assert::stringNotEmpty($value); + + $json = base64_decode($value, true); + if (false === $json) { + throw new \InvalidArgumentException(sprintf( + 'The tracking information cookie was present, but the data was corrupt. The encoded data was: "%s"', + $value, + )); + } + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); Assert::isArray($data); + + return self::fromArray($data); + } + + /** + * @throws \InvalidArgumentException if the data is invalid + */ + public static function fromArray(array $data): self + { Assert::allString($data); return new self($data['gclid'] ?? null, $data['gbraid'] ?? null, $data['wbraid'] ?? null); } + public function toCookie(string $cookieName, int|string|\DateTimeInterface $expires): Cookie + { + return Cookie::create( + name: $cookieName, + value: base64_encode(json_encode($this, \JSON_THROW_ON_ERROR)), + expire: $expires, + secure: false, + httpOnly: false, + ); + } + public function jsonSerialize(): array { return array_filter([ diff --git a/tests/Application/.env b/tests/Application/.env index bdf8127..4011279 100644 --- a/tests/Application/.env +++ b/tests/Application/.env @@ -43,5 +43,5 @@ MESSENGER_TRANSPORT_DSN=doctrine://default # For Gmail as a transport, use: "gmail://username:password@localhost" # For a generic SMTP server, use: "smtp://localhost:25?encryption=&auth_mode=" # Delivery is disabled by default via "null://localhost" -MAILER_DSN=smtp://localhost +MAILER_DSN=null://localhost ###< symfony/swiftmailer-bundle ### diff --git a/tests/Application/config/bundles.php b/tests/Application/config/bundles.php index 0c39c5b..5b0bae0 100644 --- a/tests/Application/config/bundles.php +++ b/tests/Application/config/bundles.php @@ -58,4 +58,5 @@ SyliusLabs\Polyfill\Symfony\Security\Bundle\SyliusLabsPolyfillSymfonySecurityBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Sylius\Calendar\SyliusCalendarBundle::class => ['all' => true], + Setono\ClientBundle\SetonoClientBundle::class => ['all' => true], ]; diff --git a/tests/Application/config/packages/setono_sylius_google_ads.yaml b/tests/Application/config/packages/setono_sylius_google_ads.yaml index 9e6a369..049844e 100644 --- a/tests/Application/config/packages/setono_sylius_google_ads.yaml +++ b/tests/Application/config/packages/setono_sylius_google_ads.yaml @@ -1,2 +1,5 @@ imports: - { resource: "@SetonoSyliusGoogleAdsPlugin/Resources/config/app/config.yaml" } + +setono_sylius_google_ads: + storage: client_metadata diff --git a/tests/Application/public/index.php b/tests/Application/public/index.php index 8a19a96..dfa81f5 100644 --- a/tests/Application/public/index.php +++ b/tests/Application/public/index.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Tests\Setono\SyliusGoogleAdsPlugin\Application\Kernel; +use Setono\SyliusGoogleAdsPlugin\Tests\Application\Kernel; use Symfony\Component\ErrorHandler\Debug; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 52af862..0b7e77d 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -7,17 +7,6 @@ use Matthias\SymfonyConfigTest\PhpUnit\ConfigurationTestCaseTrait; use PHPUnit\Framework\TestCase; use Setono\SyliusGoogleAdsPlugin\DependencyInjection\Configuration; -use Setono\SyliusGoogleAdsPlugin\Form\Type\ConnectionMappingType; -use Setono\SyliusGoogleAdsPlugin\Form\Type\ConnectionType; -use Setono\SyliusGoogleAdsPlugin\Model\Connection; -use Setono\SyliusGoogleAdsPlugin\Model\ConnectionMapping; -use Setono\SyliusGoogleAdsPlugin\Model\Conversion; -use Setono\SyliusGoogleAdsPlugin\Repository\ConnectionMappingRepository; -use Setono\SyliusGoogleAdsPlugin\Repository\ConnectionRepository; -use Setono\SyliusGoogleAdsPlugin\Repository\ConversionRepository; -use Sylius\Bundle\ResourceBundle\Controller\ResourceController; -use Sylius\Bundle\ResourceBundle\Form\Type\DefaultResourceType; -use Sylius\Component\Resource\Factory\Factory; /** * See examples of tests and configuration options here: https://github.com/SymfonyTest/SymfonyConfigTest @@ -34,43 +23,12 @@ protected function getConfiguration(): Configuration /** * @test */ - public function values_are_invalid_if_required_value_is_not_provided(): void + public function it_is_invalid_if_the_storage_configuration_is_invalid(): void { - $this->assertProcessedConfigurationEquals( + $this->assertConfigurationIsInvalid([ [ - [], // no values at all + 'storage' => 'invalid', ], - [ - 'resources' => [ - 'connection' => [ - 'classes' => [ - 'model' => Connection::class, - 'controller' => ResourceController::class, - 'repository' => ConnectionRepository::class, - 'factory' => Factory::class, - 'form' => ConnectionType::class, - ], - ], - 'connection_mapping' => [ - 'classes' => [ - 'model' => ConnectionMapping::class, - 'controller' => ResourceController::class, - 'repository' => ConnectionMappingRepository::class, - 'factory' => Factory::class, - 'form' => ConnectionMappingType::class, - ], - ], - 'conversion' => [ - 'classes' => [ - 'model' => Conversion::class, - 'controller' => ResourceController::class, - 'repository' => ConversionRepository::class, - 'factory' => Factory::class, - 'form' => DefaultResourceType::class, - ], - ], - ], - ], - ); + ]); } } diff --git a/tests/TrackingInformation/TrackingInformationTest.php b/tests/TrackingInformation/TrackingInformationTest.php index a74b5d4..b67e093 100644 --- a/tests/TrackingInformation/TrackingInformationTest.php +++ b/tests/TrackingInformation/TrackingInformationTest.php @@ -42,9 +42,14 @@ public function it_json_encodes(): void /** * @test */ - public function it_creates_from_json(): void + public function it_creates_from_cookie(): void { - $trackingInformation = TrackingInformation::fromJson('{"gclid":"gclid","gbraid":"gbraid","wbraid":"wbraid"}'); + $request = new Request( + cookies: [ + 'ssga_tinfo' => 'eyJnY2xpZCI6ImdjbGlkIiwiZ2JyYWlkIjoiZ2JyYWlkIiwid2JyYWlkIjoid2JyYWlkIn0=', + ], + ); + $trackingInformation = TrackingInformation::fromCookie($request, 'ssga_tinfo'); self::assertSame('gclid', $trackingInformation->gclid); self::assertSame('gbraid', $trackingInformation->gbraid); @@ -61,7 +66,7 @@ public function it_creates_from_request(): void 'gbraid' => 'gbraid', 'wbraid' => 'wbraid', ]); - $trackingInformation = TrackingInformation::fromRequest($request); + $trackingInformation = TrackingInformation::fromQuery($request); self::assertSame('gclid', $trackingInformation->gclid); self::assertSame('gbraid', $trackingInformation->gbraid);