diff --git a/README.md b/README.md index b9a8eb6..73b8bf0 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # Schema Context Bundle -The **SchemaContextBundle** provides a lightweight way to manage dynamic schema context across your Symfony application, especially useful for multi-tenant setups. It allows schema resolution based on request headers and propagates schema information through Symfony Messenger. +The **SchemaContextBundle** provides a robust way to manage dynamic schema context across your Symfony application, especially useful for multi-tenant setups. It extracts schema information from W3C standard baggage headers (or Symfony Messenger Stamps), validates schema changes based on environment configuration, and propagates schema context throughout your application, including HTTP clients and Symfony Messenger queues. --- ## Features -- Extracts tenant schema param from baggage request header. +- Extracts tenant schema param from W3C standard `baggage` request header. - Stores schema and baggage context in a global `BaggageSchemaResolver`. +- Validates schema changes based on environment configuration to prevent accidental schema mismatches. - Injects schema and baggage info into Messenger messages via a middleware. - Rehydrates schema and baggage on message consumption via a middleware. -- Provide decorator for Http clients to propagate baggage header -- Optional: Adds baggage context to Monolog log records via a processor +- Provide decorator for Http clients to propagate baggage header. +- Optional: Adds baggage context to Monolog log records via a processor. --- @@ -35,18 +36,28 @@ Add this config to `config/packages/schema_context.yaml`: ```yaml schema_context: - app_name: '%env(APP_NAME)%' # Application name - header_name: 'X-Tenant' # Request header to extract schema name - default_schema: 'public' # Default schema to fallback to - allowed_app_names: ['develop', 'staging', 'test'] # App names where schema context is allowed to change + environment_name: '%env(APP_ENV)%' # Current environment name (example: 'develop') + header_name: 'X-Tenant' # Key name in baggage header to extract schema name + environment_schema: '%env(ENVIRONMENT_SCHEMA)%' # The schema for this environment (example: 'public') + overridable_environments: ['develop', 'staging', 'test'] # Environments where schema can be overridden via baggage header or Symfony Messenger stamp ``` -### 2. Set Environment Parameters -If you're using .env, define the app name: + +**Configuration parameters:** +- `environment_name`: The name of the current environment. Best practice is to use `'%env(APP_ENV)%'` to match Symfony's environment. +- `environment_schema`: The schema for this environment. +- `header_name`: The key name in the baggage header used to extract the schema value. +- `overridable_environments`: List of environment names where schema can be overridden via baggage header or Symfony Messenger stamp. ```env -APP_NAME=develop +APP_ENV=develop +ENVIRONMENT_SCHEMA=public ``` +### 3. Schema Override Protection +The bundle includes protection against accidental schema changes in production environments: +- In **non-overridable environments** (e.g., `production`): The schema is always fixed to `environment_schema`. Any attempt to override it via baggage header will throw `EnvironmentSchemaMismatchException`. +- In **overridable environments** (e.g., `develop`, `staging`): The schema can be dynamically changed via baggage header for testing and development purposes. + ## Usage ```php @@ -60,6 +71,36 @@ public function index(BaggageSchemaResolver $schemaResolver) } ``` +### Baggage Header Format + +The bundle uses W3C standard `baggage` header format. Example request: + +```http +GET /api/endpoint HTTP/1.1 +Host: example.com +baggage: X-Tenant=tenant_a,user-id=12345,trace-id=abc123 +``` + +The bundle will extract the schema value from the baggage header using the key specified in `header_name` configuration. + +## Exception Handling + +### EnvironmentSchemaMismatchException + +The bundle throws `EnvironmentSchemaMismatchException` when: +- The environment is **not** in the `overridable_environments` list +- A request tries to set a schema via baggage header that differs from `environment_schema` + +This exception prevents accidental schema changes in production/staging/etc. environments. Example error message: + +``` +Schema mismatch in "production" environment: expected "public", got "tenant_a". Allowed override environments: [develop, staging, test]. +``` + +**How to handle:** +- In production/staging/etc.: ensure clients don't send schema baggage headers, or send the correct environment schema +- In development: add your environment to `overridable_environments` list if you need to test different schemas + ## Baggage-Aware HTTP Client Decorate your http client in your service configuration: ```yaml diff --git a/config/services.yaml b/config/services.yaml index 948657d..d0ef403 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,6 +5,10 @@ services: public: false Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver: + arguments: + $environmentSchema: '%schema_context.environment_schema%' + $environmentName: '%schema_context.environment_name%' + $schemaOverridableEnvironments: '%schema_context.overridable_environments%' public: true shared: true @@ -16,12 +20,11 @@ services: arguments: $baggageSchemaResolver: '@Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver' $schemaRequestHeader: '%schema_context.header_name%' - $defaultSchema: '%schema_context.default_schema%' - $appName: '%schema_context.app_name%' - $allowedAppNames: '%schema_context.allowed_app_names%' tags: - { name: kernel.event_subscriber } Macpaw\SchemaContextBundle\Messenger\Middleware\BaggageSchemaMiddleware: + arguments: + $sendersLocator: '@messenger.senders_locator' tags: - { name: messenger.middleware } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index f509d41..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,21 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Class Macpaw\\\\SchemaContextBundle\\\\Messenger\\\\Transport\\\\DoctrineTransportFactoryDecorator implements generic interface Symfony\\\\Component\\\\Messenger\\\\Transport\\\\TransportFactoryInterface but does not specify its types\\: TTransport$#" - count: 1 - path: src/Messenger/Transport/DoctrineTransportFactoryDecorator.php - - - - message: "#^Method Macpaw\\\\SchemaContextBundle\\\\Messenger\\\\Transport\\\\DoctrineTransportFactoryDecorator\\:\\:__construct\\(\\) has parameter \\$decoratedFactory with generic interface Symfony\\\\Component\\\\Messenger\\\\Transport\\\\TransportFactoryInterface but does not specify its types\\: TTransport$#" - count: 1 - path: src/Messenger/Transport/DoctrineTransportFactoryDecorator.php - - - - message: "#^Method Macpaw\\\\SchemaContextBundle\\\\Messenger\\\\Transport\\\\DoctrineTransportFactoryDecorator\\:\\:createTransport\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Messenger/Transport/DoctrineTransportFactoryDecorator.php - - - - message: "#^Method Macpaw\\\\SchemaContextBundle\\\\Messenger\\\\Transport\\\\DoctrineTransportFactoryDecorator\\:\\:supports\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Messenger/Transport/DoctrineTransportFactoryDecorator.php diff --git a/phpstan.neon b/phpstan.neon index 4fd315c..f6ecc8a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,3 @@ -includes: - - phpstan-baseline.neon - parameters: level: max paths: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f68fdc9..c4da212 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -16,9 +16,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->children() ->scalarNode('header_name')->defaultValue('X-Schema')->end() - ->scalarNode('default_schema')->defaultValue('public')->end() - ->scalarNode('app_name')->isRequired()->cannotBeEmpty()->end() - ->arrayNode('allowed_app_names') + ->scalarNode('environment_name')->defaultValue('public')->end() + ->scalarNode('environment_schema')->isRequired()->cannotBeEmpty()->end() + ->arrayNode('overridable_environments') ->scalarPrototype()->end() ->defaultValue([]) ->end() diff --git a/src/DependencyInjection/SchemaContextCompilerPass.php b/src/DependencyInjection/SchemaContextCompilerPass.php index fc6d470..5ce7836 100644 --- a/src/DependencyInjection/SchemaContextCompilerPass.php +++ b/src/DependencyInjection/SchemaContextCompilerPass.php @@ -4,37 +4,12 @@ namespace Macpaw\SchemaContextBundle\DependencyInjection; -use Macpaw\SchemaContextBundle\Messenger\Transport\DoctrineTransportFactoryDecorator; -use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; final class SchemaContextCompilerPass implements CompilerPassInterface { - public const TARGET_ID = 'messenger.transport.doctrine.factory'; - public const DECORATOR_ID = 'messenger.doctrine_transport_factory.decorator'; - public function process(ContainerBuilder $container): void { - if ($container->hasDefinition(self::TARGET_ID) === false) { - return; - } - - $def = new Definition(DoctrineTransportFactoryDecorator::class); - $def->setAutowired(true); // avoid pulling the chain or adding tags - $def->setAutoconfigured(true); - $def->setPublic(false); - - // Decorate the *target* id; explicit inner id is ".inner" - $def->setDecoratedService(self::TARGET_ID, self::DECORATOR_ID . '.inner'); - - // Inject the inner/original factory + your resolver - $def->setArgument('$decoratedFactory', new Reference(self::DECORATOR_ID . '.inner')); - $def->setArgument('$baggageSchemaResolver', new Reference(BaggageSchemaResolver::class)); - $def->setArgument('$defaultSchema', $container->getParameter('schema_context.default_schema')); - - $container->setDefinition(self::DECORATOR_ID, $def); } } diff --git a/src/DependencyInjection/SchemaContextExtension.php b/src/DependencyInjection/SchemaContextExtension.php index f2a578f..ae3cef6 100644 --- a/src/DependencyInjection/SchemaContextExtension.php +++ b/src/DependencyInjection/SchemaContextExtension.php @@ -17,9 +17,9 @@ public function load(array $configs, ContainerBuilder $container) $config = $this->processConfiguration($configuration, $configs); $container->setParameter('schema_context.header_name', $config['header_name']); - $container->setParameter('schema_context.default_schema', $config['default_schema']); - $container->setParameter('schema_context.app_name', $config['app_name']); - $container->setParameter('schema_context.allowed_app_names', $config['allowed_app_names']); + $container->setParameter('schema_context.environment_schema', $config['environment_schema']); + $container->setParameter('schema_context.environment_name', $config['environment_name']); + $container->setParameter('schema_context.overridable_environments', $config['overridable_environments']); $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); diff --git a/src/EventListener/BaggageRequestListener.php b/src/EventListener/BaggageRequestListener.php index 7ff35c4..936bf85 100644 --- a/src/EventListener/BaggageRequestListener.php +++ b/src/EventListener/BaggageRequestListener.php @@ -16,10 +16,6 @@ public function __construct( private BaggageSchemaResolver $baggageSchemaResolver, private BaggageCodec $baggageCodec, private string $schemaRequestHeader, - private string $defaultSchema, - private string $appName, - /** @var string[] */ - private array $allowedAppNames, ) { } @@ -30,30 +26,18 @@ public static function getSubscribedEvents(): array public function onKernelRequest(RequestEvent $event): void { - if (!$this->isAllowedAppName()) { - return; - } - $request = $event->getRequest(); - $baggage = $request->headers->get('baggage'); + $headerBaggage = $request->headers->get('baggage'); + $baggage = null; $schema = null; - if ($baggage) { - $baggage = $this->baggageCodec->decode($baggage); - $this->baggageSchemaResolver->setBaggage($baggage); + if ($headerBaggage) { + $baggage = $this->baggageCodec->decode($headerBaggage); $schema = $baggage[$this->schemaRequestHeader] ?? null; } - if ($schema !== null && $schema !== '') { - $this->baggageSchemaResolver->setSchema($schema); - } else { - $this->baggageSchemaResolver->setSchema($this->defaultSchema); - } - } - - private function isAllowedAppName(): bool - { - return in_array($this->appName, $this->allowedAppNames, true); + $this->baggageSchemaResolver->setBaggage($baggage); + $this->baggageSchemaResolver->setSchema($schema); } } diff --git a/src/Exception/EnvironmentSchemaMismatchException.php b/src/Exception/EnvironmentSchemaMismatchException.php new file mode 100644 index 0000000..6b1b544 --- /dev/null +++ b/src/Exception/EnvironmentSchemaMismatchException.php @@ -0,0 +1,32 @@ +last(BaggageSchemaStamp::class); - if ($stamp instanceof BaggageSchemaStamp) { - $this->baggageSchemaResolver - ->setSchema($stamp->schema) - ->setBaggage($this->baggageCodec->decode($stamp->baggage)); + if ($this->isWorker($envelope) && !$this->isSyncTransport($envelope)) { + if ($stamp instanceof BaggageSchemaStamp) { + $this->baggageSchemaResolver + ->setSchema($stamp->schema) + ->setBaggage($stamp->baggage === null ? null : $this->baggageCodec->decode($stamp->baggage)); + } $result = $stack->next()->handle($envelope, $stack); @@ -36,12 +42,28 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope } $schema = $this->baggageSchemaResolver->getSchema(); - $baggage = $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage() ?? []); + $baggage = $this->baggageSchemaResolver->getBaggage() === null + ? null + : $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage()); - if ($schema !== null && $schema !== '') { - $envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage)); - } + $envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage)); return $stack->next()->handle($envelope, $stack); } + + private function isWorker(Envelope $envelope): bool + { + return (bool) $envelope->last(ReceivedStamp::class); + } + + private function isSyncTransport(Envelope $envelope): bool + { + foreach ($this->sendersLocator->getSenders($envelope) as $sender) { + if ($sender instanceof SyncTransport) { + return true; + } + } + + return false; + } } diff --git a/src/Messenger/Stamp/BaggageSchemaStamp.php b/src/Messenger/Stamp/BaggageSchemaStamp.php index 8f64aa6..88c47ad 100644 --- a/src/Messenger/Stamp/BaggageSchemaStamp.php +++ b/src/Messenger/Stamp/BaggageSchemaStamp.php @@ -8,7 +8,7 @@ class BaggageSchemaStamp implements StampInterface { - public function __construct(public string $schema, public string $baggage) + public function __construct(public ?string $schema, public ?string $baggage) { } } diff --git a/src/Messenger/Transport/DoctrineTransportFactoryDecorator.php b/src/Messenger/Transport/DoctrineTransportFactoryDecorator.php deleted file mode 100644 index 2ad641b..0000000 --- a/src/Messenger/Transport/DoctrineTransportFactoryDecorator.php +++ /dev/null @@ -1,52 +0,0 @@ -baggageSchemaResolver->getSchema(); - - // If we have a schema and it's not the default 'public' schema, modify the table name - if ($currentSchema !== null && $currentSchema !== $this->defaultSchema) { - $originalTableName = sprintf( - '"%s"."%s"', - $currentSchema, - $options['table_name'] ?? 'messenger_messages', - ); - - // Create transport with schema-prefixed table name - $options['table_name'] = $originalTableName; - } - - // Create transport with the original factory - return $this->decoratedFactory->createTransport($dsn, $options, $serializer); - } - - public function supports(string $dsn, array $options): bool - { - return $this->decoratedFactory->supports($dsn, $options); - } -} diff --git a/src/Service/BaggageSchemaResolver.php b/src/Service/BaggageSchemaResolver.php index b33e4ea..207c677 100644 --- a/src/Service/BaggageSchemaResolver.php +++ b/src/Service/BaggageSchemaResolver.php @@ -4,6 +4,8 @@ namespace Macpaw\SchemaContextBundle\Service; +use Macpaw\SchemaContextBundle\Exception\EnvironmentSchemaMismatchException; + class BaggageSchemaResolver { /** @@ -11,6 +13,22 @@ class BaggageSchemaResolver */ private ?array $baggage = null; private ?string $schema = null; + private bool $isSchemaOverridableEnvironment; + + /** + * @param string[] $schemaOverridableEnvironments + */ + public function __construct( + private readonly string $environmentSchema, + private readonly string $environmentName, + private readonly array $schemaOverridableEnvironments, + ) { + $this->isSchemaOverridableEnvironment = in_array( + $this->environmentName, + $this->schemaOverridableEnvironments, + true + ); + } /** * @return array|null @@ -23,21 +41,64 @@ public function getBaggage(): ?array /** * @param array $baggage */ - public function setBaggage(array $baggage): self + public function setBaggage(?array $baggage): self { + if (is_array($baggage) && count($baggage) <= 0) { + $baggage = null; + } + $this->baggage = $baggage; return $this; } - public function setSchema(string $schema): self + public function setSchema(?string $schema): self { + if (is_string($schema)) { + $schema = trim($schema); + + $schema = $schema !== '' ? $schema : null; + } + + // check that the schema hasn't changed in a non-schema-overridable environment + if ( + $this->isSchemaOverridableEnvironment === false + && $schema !== null + && $schema !== $this->environmentSchema + ) { + throw new EnvironmentSchemaMismatchException( + $schema, + $this->environmentSchema, + $this->environmentName, + $this->schemaOverridableEnvironments, + ); + } + $this->schema = $schema; return $this; } - public function getSchema(): ?string + public function getSchema(): string + { + if ($this->isSchemaOverridableEnvironment === true && $this->schema !== null && $this->schema !== '') { + return $this->schema; + } + + return $this->environmentSchema; + } + + public function isSchemaOverridableEnvironment(): bool + { + return $this->isSchemaOverridableEnvironment; + } + + public function getEnvironmentSchema(): string + { + return $this->environmentSchema; + } + + public function getProvidedSchema(): ?string { return $this->schema; } diff --git a/tests/EventListener/BaggageRequestListenerTest.php b/tests/EventListener/BaggageRequestListenerTest.php index 4f6ac3b..e5b7b00 100644 --- a/tests/EventListener/BaggageRequestListenerTest.php +++ b/tests/EventListener/BaggageRequestListenerTest.php @@ -16,15 +16,16 @@ class BaggageRequestListenerTest extends TestCase { public function testBaggageFromHeaderIsSet(): void { - $resolver = new BaggageSchemaResolver(); + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); $baggageCodec = new BaggageCodec(); $listener = new BaggageRequestListener( $resolver, $baggageCodec, 'X-Schema', - 'default', - 'test-app', - ['test-app'], ); $request = new Request([], [], [], [], [], ['HTTP_BAGGAGE' => 'X-Schema=tenant1']); @@ -41,15 +42,16 @@ public function testBaggageFromHeaderIsSet(): void public function testBaggageFromHeaderIsSetWithMultiplyParameters(): void { - $resolver = new BaggageSchemaResolver(); + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); $baggageCodec = new BaggageCodec(); $listener = new BaggageRequestListener( $resolver, $baggageCodec, 'X-Schema', - 'default', - 'test-app', - ['test-app'], ); $request = new Request([], [], [], [], [], ['HTTP_BAGGAGE' => 'X-Schema= tenant1 ,test , foo=bar']); @@ -68,15 +70,16 @@ public function testBaggageFromHeaderIsSetWithMultiplyParameters(): void public function testDefaultSchemaIsUsedIfHeaderMissing(): void { - $resolver = new BaggageSchemaResolver(); + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); $baggageCodec = new BaggageCodec(); $listener = new BaggageRequestListener( $resolver, $baggageCodec, 'X-Schema', - 'fallback', - 'test-app', - ['test-app'], ); $request = new Request(); @@ -85,7 +88,34 @@ public function testDefaultSchemaIsUsedIfHeaderMissing(): void $listener->onKernelRequest($event); - self::assertSame('fallback', $resolver->getSchema()); + self::assertSame('default', $resolver->getSchema()); self::assertNull($resolver->getBaggage()); } + + // TODO!! + public function testFail(): void + { + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); + $baggageCodec = new BaggageCodec(); + $listener = new BaggageRequestListener( + $resolver, + $baggageCodec, + 'X-Schema', + ); + + $request = new Request([], [], [], [], [], ['HTTP_BAGGAGE' => 'X-Schema=tenant1']); + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $listener->onKernelRequest($event); + + self::assertSame('tenant1', $resolver->getSchema()); + self::assertSame([ + 'X-Schema' => 'tenant1', + ], $resolver->getBaggage()); + } } diff --git a/tests/HttpClient/BaggageAwareHttpClientTest.php b/tests/HttpClient/BaggageAwareHttpClientTest.php index a10b48c..38d9c07 100644 --- a/tests/HttpClient/BaggageAwareHttpClientTest.php +++ b/tests/HttpClient/BaggageAwareHttpClientTest.php @@ -24,6 +24,15 @@ public function testItInjectsSchemaIntoBaggageHeader( array $arrayBaggage, string $expectedSentBaggage ): void { + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + $baggageSchemaResolver = new BaggageSchemaResolver( + $environmentSchema, + $environmentName, + $schemaOverridableEnvironments + ); + $mockClient = $this->createMock(HttpClientInterface::class); $mockClient ->expects($this->once()) @@ -40,7 +49,7 @@ public function testItInjectsSchemaIntoBaggageHeader( ) ->willReturn(new MockResponse('OK')); - $baggageSchemaResolver = (new BaggageSchemaResolver())->setBaggage($arrayBaggage); + $baggageSchemaResolver->setBaggage($arrayBaggage); $baggageCodec = new BaggageCodec(); $client = new BaggageAwareHttpClient( diff --git a/tests/Messenger/Middleware/BaggageSchemaMiddlewareTest.php b/tests/Messenger/Middleware/BaggageSchemaMiddlewareTest.php index 40326ee..bccd3d6 100644 --- a/tests/Messenger/Middleware/BaggageSchemaMiddlewareTest.php +++ b/tests/Messenger/Middleware/BaggageSchemaMiddlewareTest.php @@ -12,22 +12,30 @@ use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Middleware\MiddlewareInterface; use Symfony\Component\Messenger\Middleware\StackInterface; +use Symfony\Component\Messenger\Stamp\ReceivedStamp; +use Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface; class BaggageSchemaMiddlewareTest extends TestCase { public function testSchemaIsSetFromStamp(): void { + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + $schema = 'tenant1'; $rawBaggage = 'X-Schema=tenant1'; $baggage = [ 'X-Schema' => 'tenant1', ]; - $resolver = new BaggageSchemaResolver(); + $sendersLocator = $this->createMock(SendersLocatorInterface::class); + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); $baggageCodec = new BaggageCodec(); - $middleware = new BaggageSchemaMiddleware($resolver, $baggageCodec); + $middleware = new BaggageSchemaMiddleware($sendersLocator, $resolver, $baggageCodec); $stamp = new BaggageSchemaStamp($schema, $rawBaggage); $envelope = (new Envelope(new \stdClass()))->with($stamp); + $envelope = $envelope->with(new ReceivedStamp('async')); $stack = $this->createMock(StackInterface::class); $nextMiddleware = new class implements MiddlewareInterface { public function handle(Envelope $envelope, StackInterface $stack): Envelope @@ -52,23 +60,29 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $this->assertSame($schema, $result['schema']); $this->assertSame($baggage, $baggageCodec->decode($result['baggage'])); - $this->assertNull($resolver->getSchema()); + $this->assertSame($environmentSchema, $resolver->getSchema()); $this->assertNull($resolver->getBaggage()); } public function testSchemaStampIsInjectedIfMissing(): void { + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + $schema = 'tenant1'; $rawBaggage = 'X-Schema=tenant1'; $baggage = [ 'X-Schema' => 'tenant1', ]; - $resolver = new BaggageSchemaResolver(); + + $sendersLocator = $this->createMock(SendersLocatorInterface::class); + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); $resolver ->setSchema($schema) ->setBaggage($baggage); $baggageCodec = new BaggageCodec(); - $middleware = new BaggageSchemaMiddleware($resolver, $baggageCodec); + $middleware = new BaggageSchemaMiddleware($sendersLocator, $resolver, $baggageCodec); $originalEnvelope = new Envelope(new \stdClass()); $stack = $this->createMock(StackInterface::class); @@ -91,4 +105,37 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope $this->assertSame($schema, $stamp->schema); $this->assertSame($rawBaggage, $stamp->baggage); } + + public function testSchemaStampIsDefaultSchema(): void + { + $environmentSchema = 'default'; + $environmentName = 'dev'; + $schemaOverridableEnvironments = ['dev', 'test']; + + $sendersLocator = $this->createMock(SendersLocatorInterface::class); + $resolver = new BaggageSchemaResolver($environmentSchema, $environmentName, $schemaOverridableEnvironments); + $baggageCodec = new BaggageCodec(); + $middleware = new BaggageSchemaMiddleware($sendersLocator, $resolver, $baggageCodec); + $originalEnvelope = new Envelope(new \stdClass()); + $stack = $this->createMock(StackInterface::class); + + $stack->expects($this->once()) + ->method('next') + ->willReturnCallback(function () { + return new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $envelope; + } + }; + }); + + $resultEnvelope = $middleware->handle($originalEnvelope, $stack); + + $stamp = $resultEnvelope->last(BaggageSchemaStamp::class); + + $this->assertInstanceOf(BaggageSchemaStamp::class, $stamp); + $this->assertSame($environmentSchema, $stamp->schema); + $this->assertNull($stamp->baggage); + } } diff --git a/tests/Messenger/Transport/DoctrineTransportFactoryDecoratorTest.php b/tests/Messenger/Transport/DoctrineTransportFactoryDecoratorTest.php deleted file mode 100644 index 2c60877..0000000 --- a/tests/Messenger/Transport/DoctrineTransportFactoryDecoratorTest.php +++ /dev/null @@ -1,132 +0,0 @@ -createMock(ContainerBuilder::class); - - $containerBuilder->expects(self::once()) - ->method('hasDefinition') - ->willReturn(false); - - $containerBuilder->expects(self::never()) - ->method('setDefinition'); - - $compilerPass->process($containerBuilder); - } - - public function testCompilerPassRegisterDecoratorService(): void - { - $compilerPass = new SchemaContextCompilerPass(); - - $containerBuilder = $this->createMock(ContainerBuilder::class); - - $containerBuilder->expects(self::once()) - ->method('hasDefinition') - ->willReturn(true); - - $containerBuilder->expects(self::once()) - ->method('setDefinition') - ->with( - self::equalTo(SchemaContextCompilerPass::DECORATOR_ID), - self::callback(function (Definition $definition): bool { - // You can assert partial properties here - self::assertSame( - DoctrineTransportFactoryDecorator::class, - $definition->getClass(), - ); - - self::assertFalse($definition->isPublic()); - self::assertTrue($definition->isAutowired()); - self::assertTrue($definition->isAutoconfigured()); - self::assertIsArray($definition->getDecoratedService()); - self::assertArrayHasKey(0, $definition->getDecoratedService()); - self::assertEquals( - SchemaContextCompilerPass::TARGET_ID, - $definition->getDecoratedService()[0], - ); - - // Optional: check arguments only if needed - $args = $definition->getArguments(); - self::assertArrayHasKey('$decoratedFactory', $args); - - return true; - }), - ); - - $compilerPass->process($containerBuilder); - } - - public function testSchemaIsOverride(): void - { - $doctrineTransportMock = $this->createMock(TransportFactoryInterface::class); - $baggage = new BaggageSchemaResolver(); - $baggage->setSchema('test_schema'); - - $decorator = new DoctrineTransportFactoryDecorator( - $doctrineTransportMock, - $baggage, - ); - - $doctrineTransportMock->expects(self::once()) - ->method('createTransport') - ->with( - self::equalTo(''), - self::callback(function (array $options): bool { - self::assertArrayHasKey('table_name', $options); - self::assertEquals('"test_schema"."messenger_messages"', $options['table_name']); - - return true; - }), - ); - - $decorator->createTransport('', [], $this->createMock(SerializerInterface::class)); - } - - public function testSchemaIsDefault(): void - { - $doctrineTransportMock = $this->createMock(TransportFactoryInterface::class); - $baggage = new BaggageSchemaResolver(); - $baggage->setSchema('default'); - - $decorator = new DoctrineTransportFactoryDecorator( - $doctrineTransportMock, - $baggage, - 'default', - ); - - $doctrineTransportMock->expects(self::once()) - ->method('createTransport') - ->with( - self::equalTo(''), - self::callback(function (array $options): bool { - self::assertArrayHasKey('table_name', $options); - self::assertEquals('messenger_messages', $options['table_name']); - - return true; - }), - ); - - $decorator->createTransport( - '', - ['table_name' => 'messenger_messages'], - $this->createMock(SerializerInterface::class), - ); - } -}