Skip to content

Commit 53072f4

Browse files
feat: rework names and validation
1 parent 5697d2d commit 53072f4

15 files changed

+263
-97
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: 4 additions & 5 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,14 +20,9 @@ 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:
26-
arguments:
27-
$defaultSchema: '%schema_context.default_schema%'
2827
tags:
2928
- { name: messenger.middleware }

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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ public function process(ContainerBuilder $container): void
3333
// Inject the inner/original factory + your resolver
3434
$def->setArgument('$decoratedFactory', new Reference(self::DECORATOR_ID . '.inner'));
3535
$def->setArgument('$baggageSchemaResolver', new Reference(BaggageSchemaResolver::class));
36-
$def->setArgument('$defaultSchema', $container->getParameter('schema_context.default_schema'));
3736

3837
$container->setDefinition(self::DECORATOR_ID, $def);
3938
}

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: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ class BaggageSchemaMiddleware implements MiddlewareInterface
1717
public function __construct(
1818
private BaggageSchemaResolver $baggageSchemaResolver,
1919
private BaggageCodec $baggageCodec,
20-
private string $defaultSchema,
2120
) {
2221
}
2322

@@ -29,7 +28,7 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope
2928
if ($stamp instanceof BaggageSchemaStamp) {
3029
$this->baggageSchemaResolver
3130
->setSchema($stamp->schema)
32-
->setBaggage($this->baggageCodec->decode($stamp->baggage));
31+
->setBaggage($stamp->baggage === null ? null : $this->baggageCodec->decode($stamp->baggage));
3332
}
3433

3534
$result = $stack->next()->handle($envelope, $stack);
@@ -40,13 +39,11 @@ public function handle(Envelope $envelope, StackInterface $stack): Envelope
4039
}
4140

4241
$schema = $this->baggageSchemaResolver->getSchema();
43-
$baggage = $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage() ?? []);
42+
$baggage = $this->baggageSchemaResolver->getBaggage() === null
43+
? null
44+
: $this->baggageCodec->encode($this->baggageSchemaResolver->getBaggage());
4445

45-
if ($schema !== null && $schema !== '') {
46-
$envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage));
47-
} else {
48-
$envelope = $envelope->with(new BaggageSchemaStamp($this->defaultSchema, $baggage));
49-
}
46+
$envelope = $envelope->with(new BaggageSchemaStamp($schema, $baggage));
5047

5148
return $stack->next()->handle($envelope, $stack);
5249
}

src/Messenger/Stamp/BaggageSchemaStamp.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
class BaggageSchemaStamp implements StampInterface
1010
{
11-
public function __construct(public string $schema, public string $baggage)
11+
public function __construct(public ?string $schema, public ?string $baggage)
1212
{
1313
}
1414
}

src/Messenger/Transport/DoctrineTransportFactoryDecorator.php

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,21 @@ final class DoctrineTransportFactoryDecorator implements TransportFactoryInterfa
2020
public function __construct(
2121
private readonly TransportFactoryInterface $decoratedFactory,
2222
private readonly BaggageSchemaResolver $baggageSchemaResolver,
23-
private readonly string $defaultSchema = 'public',
2423
) {
2524
}
2625

2726
public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface
2827
{
29-
// Get current schema from BaggageSchemaResolver
3028
$currentSchema = $this->baggageSchemaResolver->getSchema();
3129

32-
// If we have a schema and it's not the default 'public' schema, modify the table name
33-
if ($currentSchema !== null && $currentSchema !== $this->defaultSchema) {
34-
$originalTableName = sprintf(
35-
'"%s"."%s"',
36-
$currentSchema,
37-
$options['table_name'] ?? 'messenger_messages',
38-
);
39-
40-
// Create transport with schema-prefixed table name
41-
$options['table_name'] = $originalTableName;
42-
}
30+
$originalTableName = sprintf(
31+
'"%s"."%s"',
32+
$currentSchema,
33+
$options['table_name'] ?? 'messenger_messages',
34+
);
35+
36+
// Create transport with schema-prefixed table name
37+
$options['table_name'] = $originalTableName;
4338

4439
// Create transport with the original factory
4540
return $this->decoratedFactory->createTransport($dsn, $options, $serializer);

0 commit comments

Comments
 (0)