Skip to content

Commit dfb7c19

Browse files
Merge pull request #26 from MacPaw/main
backmerge
2 parents 3ecd765 + 1db7f52 commit dfb7c19

17 files changed

+303
-307
lines changed

README.md

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# Schema Context Bundle
22

3-
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.
3+
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.
44

55
---
66

77
## Features
88

9-
- Extracts tenant schema param from baggage request header.
9+
- Extracts tenant schema param from W3C standard `baggage` request header.
1010
- Stores schema and baggage context in a global `BaggageSchemaResolver`.
11+
- Validates schema changes based on environment configuration to prevent accidental schema mismatches.
1112
- Injects schema and baggage info into Messenger messages via a middleware.
1213
- Rehydrates schema and baggage on message consumption via a middleware.
13-
- Provide decorator for Http clients to propagate baggage header
14-
- Optional: Adds baggage context to Monolog log records via a processor
14+
- Provide decorator for Http clients to propagate baggage header.
15+
- Optional: Adds baggage context to Monolog log records via a processor.
1516

1617
---
1718

@@ -35,18 +36,28 @@ Add this config to `config/packages/schema_context.yaml`:
3536

3637
```yaml
3738
schema_context:
38-
app_name: '%env(APP_NAME)%' # Application name
39-
header_name: 'X-Tenant' # Request header to extract schema name
40-
default_schema: 'public' # Default schema to fallback to
41-
allowed_app_names: ['develop', 'staging', 'test'] # App names where schema context is allowed to change
39+
environment_name: '%env(APP_ENV)%' # Current environment name (example: 'develop')
40+
header_name: 'X-Tenant' # Key name in baggage header to extract schema name
41+
environment_schema: '%env(ENVIRONMENT_SCHEMA)%' # The schema for this environment (example: 'public')
42+
overridable_environments: ['develop', 'staging', 'test'] # Environments where schema can be overridden via baggage header or Symfony Messenger stamp
4243
```
43-
### 2. Set Environment Parameters
44-
If you're using .env, define the app name:
44+
45+
**Configuration parameters:**
46+
- `environment_name`: The name of the current environment. Best practice is to use `'%env(APP_ENV)%'` to match Symfony's environment.
47+
- `environment_schema`: The schema for this environment.
48+
- `header_name`: The key name in the baggage header used to extract the schema value.
49+
- `overridable_environments`: List of environment names where schema can be overridden via baggage header or Symfony Messenger stamp.
4550

4651
```env
47-
APP_NAME=develop
52+
APP_ENV=develop
53+
ENVIRONMENT_SCHEMA=public
4854
```
4955

56+
### 3. Schema Override Protection
57+
The bundle includes protection against accidental schema changes in production environments:
58+
- 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`.
59+
- In **overridable environments** (e.g., `develop`, `staging`): The schema can be dynamically changed via baggage header for testing and development purposes.
60+
5061
## Usage
5162

5263
```php
@@ -60,6 +71,36 @@ public function index(BaggageSchemaResolver $schemaResolver)
6071
}
6172
```
6273

74+
### Baggage Header Format
75+
76+
The bundle uses W3C standard `baggage` header format. Example request:
77+
78+
```http
79+
GET /api/endpoint HTTP/1.1
80+
Host: example.com
81+
baggage: X-Tenant=tenant_a,user-id=12345,trace-id=abc123
82+
```
83+
84+
The bundle will extract the schema value from the baggage header using the key specified in `header_name` configuration.
85+
86+
## Exception Handling
87+
88+
### EnvironmentSchemaMismatchException
89+
90+
The bundle throws `EnvironmentSchemaMismatchException` when:
91+
- The environment is **not** in the `overridable_environments` list
92+
- A request tries to set a schema via baggage header that differs from `environment_schema`
93+
94+
This exception prevents accidental schema changes in production/staging/etc. environments. Example error message:
95+
96+
```
97+
Schema mismatch in "production" environment: expected "public", got "tenant_a". Allowed override environments: [develop, staging, test].
98+
```
99+
100+
**How to handle:**
101+
- In production/staging/etc.: ensure clients don't send schema baggage headers, or send the correct environment schema
102+
- In development: add your environment to `overridable_environments` list if you need to test different schemas
103+
63104
## Baggage-Aware HTTP Client
64105
Decorate your http client in your service configuration:
65106
```yaml

config/services.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ services:
55
public: false
66

77
Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver:
8+
arguments:
9+
$environmentSchema: '%schema_context.environment_schema%'
10+
$environmentName: '%schema_context.environment_name%'
11+
$schemaOverridableEnvironments: '%schema_context.overridable_environments%'
812
public: true
913
shared: true
1014

@@ -16,12 +20,11 @@ services:
1620
arguments:
1721
$baggageSchemaResolver: '@Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver'
1822
$schemaRequestHeader: '%schema_context.header_name%'
19-
$defaultSchema: '%schema_context.default_schema%'
20-
$appName: '%schema_context.app_name%'
21-
$allowedAppNames: '%schema_context.allowed_app_names%'
2223
tags:
2324
- { name: kernel.event_subscriber }
2425

2526
Macpaw\SchemaContextBundle\Messenger\Middleware\BaggageSchemaMiddleware:
27+
arguments:
28+
$sendersLocator: '@messenger.senders_locator'
2629
tags:
2730
- { name: messenger.middleware }

phpstan-baseline.neon

Lines changed: 0 additions & 21 deletions
This file was deleted.

phpstan.neon

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
includes:
2-
- phpstan-baseline.neon
3-
41
parameters:
52
level: max
63
paths:

src/DependencyInjection/Configuration.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ public function getConfigTreeBuilder(): TreeBuilder
1616
$treeBuilder->getRootNode()
1717
->children()
1818
->scalarNode('header_name')->defaultValue('X-Schema')->end()
19-
->scalarNode('default_schema')->defaultValue('public')->end()
20-
->scalarNode('app_name')->isRequired()->cannotBeEmpty()->end()
21-
->arrayNode('allowed_app_names')
19+
->scalarNode('environment_name')->defaultValue('public')->end()
20+
->scalarNode('environment_schema')->isRequired()->cannotBeEmpty()->end()
21+
->arrayNode('overridable_environments')
2222
->scalarPrototype()->end()
2323
->defaultValue([])
2424
->end()

src/DependencyInjection/SchemaContextCompilerPass.php

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,12 @@
44

55
namespace Macpaw\SchemaContextBundle\DependencyInjection;
66

7-
use Macpaw\SchemaContextBundle\Messenger\Transport\DoctrineTransportFactoryDecorator;
8-
use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver;
97
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
108
use Symfony\Component\DependencyInjection\ContainerBuilder;
11-
use Symfony\Component\DependencyInjection\Definition;
12-
use Symfony\Component\DependencyInjection\Reference;
139

1410
final class SchemaContextCompilerPass implements CompilerPassInterface
1511
{
16-
public const TARGET_ID = 'messenger.transport.doctrine.factory';
17-
public const DECORATOR_ID = 'messenger.doctrine_transport_factory.decorator';
18-
1912
public function process(ContainerBuilder $container): void
2013
{
21-
if ($container->hasDefinition(self::TARGET_ID) === false) {
22-
return;
23-
}
24-
25-
$def = new Definition(DoctrineTransportFactoryDecorator::class);
26-
$def->setAutowired(true); // avoid pulling the chain or adding tags
27-
$def->setAutoconfigured(true);
28-
$def->setPublic(false);
29-
30-
// Decorate the *target* id; explicit inner id is "<decorator>.inner"
31-
$def->setDecoratedService(self::TARGET_ID, self::DECORATOR_ID . '.inner');
32-
33-
// Inject the inner/original factory + your resolver
34-
$def->setArgument('$decoratedFactory', new Reference(self::DECORATOR_ID . '.inner'));
35-
$def->setArgument('$baggageSchemaResolver', new Reference(BaggageSchemaResolver::class));
36-
$def->setArgument('$defaultSchema', $container->getParameter('schema_context.default_schema'));
37-
38-
$container->setDefinition(self::DECORATOR_ID, $def);
3914
}
4015
}

src/DependencyInjection/SchemaContextExtension.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ public function load(array $configs, ContainerBuilder $container)
1717
$config = $this->processConfiguration($configuration, $configs);
1818

1919
$container->setParameter('schema_context.header_name', $config['header_name']);
20-
$container->setParameter('schema_context.default_schema', $config['default_schema']);
21-
$container->setParameter('schema_context.app_name', $config['app_name']);
22-
$container->setParameter('schema_context.allowed_app_names', $config['allowed_app_names']);
20+
$container->setParameter('schema_context.environment_schema', $config['environment_schema']);
21+
$container->setParameter('schema_context.environment_name', $config['environment_name']);
22+
$container->setParameter('schema_context.overridable_environments', $config['overridable_environments']);
2323

2424
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
2525

src/EventListener/BaggageRequestListener.php

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ public function __construct(
1616
private BaggageSchemaResolver $baggageSchemaResolver,
1717
private BaggageCodec $baggageCodec,
1818
private string $schemaRequestHeader,
19-
private string $defaultSchema,
20-
private string $appName,
21-
/** @var string[] */
22-
private array $allowedAppNames,
2319
) {
2420
}
2521

@@ -30,30 +26,18 @@ public static function getSubscribedEvents(): array
3026

3127
public function onKernelRequest(RequestEvent $event): void
3228
{
33-
if (!$this->isAllowedAppName()) {
34-
return;
35-
}
36-
3729
$request = $event->getRequest();
38-
$baggage = $request->headers->get('baggage');
30+
$headerBaggage = $request->headers->get('baggage');
3931

32+
$baggage = null;
4033
$schema = null;
41-
if ($baggage) {
42-
$baggage = $this->baggageCodec->decode($baggage);
43-
$this->baggageSchemaResolver->setBaggage($baggage);
4434

35+
if ($headerBaggage) {
36+
$baggage = $this->baggageCodec->decode($headerBaggage);
4537
$schema = $baggage[$this->schemaRequestHeader] ?? null;
4638
}
4739

48-
if ($schema !== null && $schema !== '') {
49-
$this->baggageSchemaResolver->setSchema($schema);
50-
} else {
51-
$this->baggageSchemaResolver->setSchema($this->defaultSchema);
52-
}
53-
}
54-
55-
private function isAllowedAppName(): bool
56-
{
57-
return in_array($this->appName, $this->allowedAppNames, true);
40+
$this->baggageSchemaResolver->setBaggage($baggage);
41+
$this->baggageSchemaResolver->setSchema($schema);
5842
}
5943
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Macpaw\SchemaContextBundle\Exception;
6+
7+
use Exception;
8+
use Symfony\Component\HttpFoundation\Response;
9+
10+
class EnvironmentSchemaMismatchException extends Exception
11+
{
12+
/**
13+
* @param string[] $schemaOverridableEnvironments
14+
*/
15+
public function __construct(
16+
string $actualSchema,
17+
string $environmentSchema,
18+
string $environmentName,
19+
array $schemaOverridableEnvironments,
20+
) {
21+
parent::__construct(
22+
sprintf(
23+
'Schema mismatch in "%s" environment: expected "%s", got "%s". Allowed override environments: [%s].',
24+
$environmentName,
25+
$environmentSchema,
26+
$actualSchema,
27+
implode(', ', $schemaOverridableEnvironments)
28+
),
29+
Response::HTTP_INTERNAL_SERVER_ERROR
30+
);
31+
}
32+
}

src/Messenger/Middleware/BaggageSchemaMiddleware.php

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,29 @@
1010
use Symfony\Component\Messenger\Envelope;
1111
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
1212
use Symfony\Component\Messenger\Middleware\StackInterface;
13+
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
14+
use Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface;
15+
use Symfony\Component\Messenger\Transport\Sync\SyncTransport;
1316

1417
class BaggageSchemaMiddleware implements MiddlewareInterface
1518
{
1619
public function __construct(
20+
private SendersLocatorInterface $sendersLocator,
1721
private BaggageSchemaResolver $baggageSchemaResolver,
18-
private BaggageCodec $baggageCodec
22+
private BaggageCodec $baggageCodec,
1923
) {
2024
}
2125

2226
public function handle(Envelope $envelope, StackInterface $stack): Envelope
2327
{
2428
$stamp = $envelope->last(BaggageSchemaStamp::class);
2529

26-
if ($stamp instanceof BaggageSchemaStamp) {
27-
$this->baggageSchemaResolver
28-
->setSchema($stamp->schema)
29-
->setBaggage($this->baggageCodec->decode($stamp->baggage));
30+
if ($this->isWorker($envelope) && !$this->isSyncTransport($envelope)) {
31+
if ($stamp instanceof BaggageSchemaStamp) {
32+
$this->baggageSchemaResolver
33+
->setSchema($stamp->schema)
34+
->setBaggage($stamp->baggage === null ? null : $this->baggageCodec->decode($stamp->baggage));
35+
}
3036

3137
$result = $stack->next()->handle($envelope, $stack);
3238

@@ -36,12 +42,28 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope
3642
}
3743

3844
$schema = $this->baggageSchemaResolver->getSchema();
39-
$baggage = $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage() ?? []);
45+
$baggage = $this->baggageSchemaResolver->getBaggage() === null
46+
? null
47+
: $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage());
4048

41-
if ($schema !== null && $schema !== '') {
42-
$envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage));
43-
}
49+
$envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage));
4450

4551
return $stack->next()->handle($envelope, $stack);
4652
}
53+
54+
private function isWorker(Envelope $envelope): bool
55+
{
56+
return (bool) $envelope->last(ReceivedStamp::class);
57+
}
58+
59+
private function isSyncTransport(Envelope $envelope): bool
60+
{
61+
foreach ($this->sendersLocator->getSenders($envelope) as $sender) {
62+
if ($sender instanceof SyncTransport) {
63+
return true;
64+
}
65+
}
66+
67+
return false;
68+
}
4769
}

0 commit comments

Comments
 (0)