diff --git a/README.md b/README.md index dbf53bb..adbe57d 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ The **SchemaContextBundle** provides a lightweight way to manage dynamic schema ## Features -- Extracts tenant schema from request headers. +- Extracts tenant schema param from baggage request header. - Stores schema context in a global `SchemaResolver`. - Injects schema info into Messenger messages via a middleware. - Rehydrates schema on message consumption via a middleware. +- Provide decorator for Http clients to propagate schema header --- @@ -57,6 +58,19 @@ public function index(SchemaResolver $schemaResolver) } ``` +## Schema-Aware HTTP Client +Decorate your http client in your service configuration: +```yaml +services: + schema_aware_payment_http_client: + class: Macpaw\SchemaContextBundle\HttpClient\SchemaAwareHttpClient + decorates: payment_http_client #http client to decorate + arguments: + - '@schema_aware_payment_http_client.inner' + - '@Macpaw\SchemaContextBundle\Service\SchemaResolver' + - '%schema_context.header_name%' +``` + ## Messenger Integration The bundle provides a middleware that automatically: diff --git a/composer.json b/composer.json index 37a7d71..1c7ca29 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "symfony/messenger": "^6.4 || ^7.0", "symfony/http-kernel": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/config": "^6.4 || ^7.0" + "symfony/config": "^6.4 || ^7.0", + "symfony/http-client": "^7.3" }, "require-dev": { "phpstan/phpstan": "^1.10", diff --git a/src/EventListener/SchemaRequestListener.php b/src/EventListener/SchemaRequestListener.php index aa40ea5..0f3c785 100644 --- a/src/EventListener/SchemaRequestListener.php +++ b/src/EventListener/SchemaRequestListener.php @@ -32,7 +32,24 @@ public function onKernelRequest(RequestEvent $event): void return; } - $schema = $event->getRequest()->headers->get($this->schemaRequestHeader, $this->defaultSchema); + $request = $event->getRequest(); + $baggage = $request->headers->get('baggage'); + + if ($baggage) { + foreach (explode(',', $baggage) as $part) { + [$key, $value] = array_map( + static fn(?string $v): ?string => $v !== null ? trim($v) : null, + explode('=', $part, 2) + [null, null] + ); + + if ($key === $this->schemaRequestHeader && $value !== null) { + $schema = $value; + break; + } + } + } + + $schema ??= $this->defaultSchema; if ($schema !== null && $schema !== '') { $this->schemaResolver->setSchema($schema); diff --git a/src/HttpClient/SchemaAwareHttpClient.php b/src/HttpClient/SchemaAwareHttpClient.php new file mode 100644 index 0000000..a820419 --- /dev/null +++ b/src/HttpClient/SchemaAwareHttpClient.php @@ -0,0 +1,58 @@ + $options + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $schema = $this->schemaResolver->getSchema(); + $baggageHeader = $this->schemaRequestHeader . '=' . $schema; + $headers = isset($options['headers']) && is_array($options['headers']) + ? $options['headers'] + : []; + + if (isset($headers['baggage'])) { + $headers['baggage'] .= ',' . $baggageHeader; + } else { + $headers['baggage'] = $baggageHeader; + } + + $options['headers'] = $headers; + + return $this->inner->request($method, $url, $options); + } + + public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface + { + return $this->inner->stream($responses, $timeout); + } + + /** + * @param array $options + */ + public function withOptions(array $options): static + { + $wrapped = $this->inner->withOptions($options); + + /** @phpstan-ignore-next-line */ + return new self($wrapped, $this->schemaResolver, $this->schemaRequestHeader); + } +} diff --git a/tests/EventListener/SchemaRequestListenerTest.php b/tests/EventListener/SchemaRequestListenerTest.php index baf2658..45024ed 100644 --- a/tests/EventListener/SchemaRequestListenerTest.php +++ b/tests/EventListener/SchemaRequestListenerTest.php @@ -24,7 +24,7 @@ public function testSchemaFromHeaderIsSet(): void ['test-app'], ); - $request = new Request([], [], [], [], [], ['HTTP_X_SCHEMA' => 'tenant1']); + $request = new Request([], [], [], [], [], ['HTTP_BAGGAGE' => 'X-Schema=tenant1']); $kernel = $this->createMock(HttpKernelInterface::class); $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); diff --git a/tests/HttpClient/SchemaAwareHttpClientTest.php b/tests/HttpClient/SchemaAwareHttpClientTest.php new file mode 100644 index 0000000..e8439e3 --- /dev/null +++ b/tests/HttpClient/SchemaAwareHttpClientTest.php @@ -0,0 +1,55 @@ +createMock(HttpClientInterface::class); + $mockClient + ->expects($this->once()) + ->method('request') + ->with( + 'GET', + 'https://api.example.com/test', + $this->callback(function (array $options) use ($expectedSchema, $schemaRequestHeader) { + $headers = $options['headers'] ?? []; + $baggage = $headers['baggage'] ?? null; + + if (is_array($baggage)) { + $baggage = implode(',', $baggage); + } + + return is_string($baggage) && str_contains($baggage, $schemaRequestHeader . '=' . $expectedSchema); + }) + ) + ->willReturn($mockResponse); + + $schemaResolver = $this->createMock(SchemaResolver::class); + $schemaResolver->method('getSchema')->willReturn($expectedSchema); + + $client = new SchemaAwareHttpClient( + $mockClient, + $schemaResolver, + $schemaRequestHeader, + ); + + $response = $client->request('GET', 'https://api.example.com/test'); + + self::assertInstanceOf(ResponseInterface::class, $response); + } +}