Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
602c311
Merge pull request #21 from MacPaw/develop
serhiidonii Oct 16, 2025
fb65d18
fix: reset resolver values per each message
alekseytupichenkov Oct 16, 2025
a1c241a
Merge pull request #22 from MacPaw/fix/reset_resolver_values_per_each…
serhiidonii Oct 16, 2025
6ab37f1
fix: reset resolver values per each message
alekseytupichenkov Oct 16, 2025
8aaa2cc
Merge branch 'main' into fix/reset_resolver_values_per_each_message_3
alekseytupichenkov Oct 16, 2025
262452d
Merge pull request #24 from MacPaw/fix/reset_resolver_values_per_each…
serhiidonii Oct 16, 2025
b8a4d0e
feat(PLATECO-1668): add default baggage stamp
serhiidonii Oct 16, 2025
e060d7d
Merge remote-tracking branch 'origin/main' into feat(PLATECO-1668)/mo…
serhiidonii Oct 16, 2025
32d5cb9
feat(PLATECO-1668): add default baggage stamp
serhiidonii Oct 16, 2025
2387022
feat(PLATECO-1668): add default baggage stamp
serhiidonii Oct 16, 2025
8f9d1b5
feat(PLATECO-1668): add default baggage stamp
serhiidonii Oct 16, 2025
5697d2d
Merge pull request #25 from MacPaw/feat(PLATECO-1668)/move-messenger-…
serhiidonii Oct 17, 2025
53072f4
feat: rework names and validation
alekseytupichenkov Oct 19, 2025
a1a6495
Merge pull request #27 from MacPaw/feat/rework-names-and-validation
alekseytupichenkov Oct 20, 2025
ea20ea8
fix: remove doctrine transport decorator
alekseytupichenkov Oct 20, 2025
ff8f9b3
Merge branch 'main' into fix/remove-doctrine-transport-decorator
alekseytupichenkov Oct 20, 2025
3d0504b
Merge pull request #28 from MacPaw/fix/remove-doctrine-transport-deco…
alekseytupichenkov Oct 20, 2025
e471165
fix: fix sync transport reset schema
alekseytupichenkov Oct 22, 2025
1db7f52
Merge pull request #29 from MacPaw/fix/fix_sync_transport_reset_schema
alekseytupichenkov Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 52 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }
21 changes: 0 additions & 21 deletions phpstan-baseline.neon

This file was deleted.

3 changes: 0 additions & 3 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
includes:
- phpstan-baseline.neon

parameters:
level: max
paths:
Expand Down
6 changes: 3 additions & 3 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 0 additions & 25 deletions src/DependencyInjection/SchemaContextCompilerPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<decorator>.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);
}
}
6 changes: 3 additions & 3 deletions src/DependencyInjection/SchemaContextExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
28 changes: 6 additions & 22 deletions src/EventListener/BaggageRequestListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -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);
}
}
32 changes: 32 additions & 0 deletions src/Exception/EnvironmentSchemaMismatchException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Macpaw\SchemaContextBundle\Exception;

use Exception;
use Symfony\Component\HttpFoundation\Response;

class EnvironmentSchemaMismatchException extends Exception
{
/**
* @param string[] $schemaOverridableEnvironments
*/
public function __construct(
string $actualSchema,
string $environmentSchema,
string $environmentName,
array $schemaOverridableEnvironments,
) {
parent::__construct(
sprintf(
'Schema mismatch in "%s" environment: expected "%s", got "%s". Allowed override environments: [%s].',
$environmentName,
$environmentSchema,
$actualSchema,
implode(', ', $schemaOverridableEnvironments)
),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
40 changes: 31 additions & 9 deletions src/Messenger/Middleware/BaggageSchemaMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,29 @@
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;
use Symfony\Component\Messenger\Transport\Sync\SyncTransport;

class BaggageSchemaMiddleware implements MiddlewareInterface
{
public function __construct(
private SendersLocatorInterface $sendersLocator,
private BaggageSchemaResolver $baggageSchemaResolver,
private BaggageCodec $baggageCodec
private BaggageCodec $baggageCodec,
) {
}

public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->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);

Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/Messenger/Stamp/BaggageSchemaStamp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
}
}
Loading
Loading