diff --git a/composer.json b/composer.json index 171d946b10a..f41bb1f1f1d 100644 --- a/composer.json +++ b/composer.json @@ -19,18 +19,33 @@ }, "require": { "php": "^8.1", - "psr/log": "^1.0 || ^2.0 || ^3.0" + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "php-http/discovery": "^1.19" }, "require-dev": { "phpunit/phpunit": "^10.5 || ^11.0 || ^12.0 || ^13.0", "squizlabs/php_codesniffer": "^3.10", "php-mock/php-mock-phpunit": "^2.10", "phpstan/phpstan": "^1.12", + "nyholm/psr7": "^1.8", "ext-json": "*", "ext-xml": "*", "ext-curl": "*", "ext-pcntl": "*" }, + "suggest": { + "guzzlehttp/guzzle": "PSR-18 HTTP client for TPsrHttpClient; recommended for most users", + "symfony/http-client": "Alternative PSR-18 HTTP client with HTTP/2 and async support", + "php-http/curl-client": "Lightweight PSR-18 HTTP client built on ext-curl" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } + }, "scripts": { "phpstan": "phpstan analyse -c lib/php/phpstan.neon" }, diff --git a/lib/php/lib/Transport/TPsrHttpClient.php b/lib/php/lib/Transport/TPsrHttpClient.php new file mode 100644 index 00000000000..896d773b64e --- /dev/null +++ b/lib/php/lib/Transport/TPsrHttpClient.php @@ -0,0 +1,227 @@ + + */ + protected array $headers = []; + + protected ClientInterface $client; + protected RequestFactoryInterface $requestFactory; + protected StreamFactoryInterface $streamFactory; + + /** + * @throws TTransportException when a dependency must be auto-discovered but + * no PSR-18 client or PSR-17 factory is installed. + */ + public function __construct( + protected string $url, + ?ClientInterface $client = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->client = $client ?? self::discover( + Psr18ClientDiscovery::find(...), + 'PSR-18 client', + 'guzzlehttp/guzzle, symfony/http-client, php-http/curl-client', + ); + $this->requestFactory = $requestFactory ?? self::discover( + Psr17FactoryDiscovery::findRequestFactory(...), + 'PSR-17 request factory', + 'nyholm/psr7 or guzzlehttp/psr7', + ); + $this->streamFactory = $streamFactory ?? self::discover( + Psr17FactoryDiscovery::findStreamFactory(...), + 'PSR-17 stream factory', + 'nyholm/psr7 or guzzlehttp/psr7', + ); + } + + public function isOpen(): bool + { + return true; + } + + public function open(): void + { + } + + public function close(): void + { + $this->request = ''; + $this->response = ''; + } + + public function read(int $len): string + { + if ($len >= strlen($this->response)) { + $ret = $this->response; + $this->response = ''; + + return $ret; + } + + $ret = substr($this->response, 0, $len); + $this->response = substr($this->response, $len); + + return $ret; + } + + /** + * Guarantees that the full amount of data is read. Since the entire HTTP + * response is buffered up-front in {@see self::flush()}, the default + * loop-based readAll cannot be used. + * + * @throws TTransportException if cannot read data + */ + public function readAll(int $len): string + { + $data = $this->read($len); + + if (strlen($data) !== $len) { + throw new TTransportException('TPsrHttpClient could not read ' . $len . ' bytes'); + } + + return $data; + } + + public function write(string $buf): void + { + $this->request .= $buf; + } + + /** + * Sends the buffered request over HTTP using the injected PSR-18 client. + * + * On failure the request buffer is consumed; the caller cannot retry the + * same payload without rewriting it via {@see self::write()}. + * + * @throws TTransportException if the URL or headers are invalid, the + * request fails, or the response is non-200 + */ + public function flush(): void + { + try { + $body = $this->streamFactory->createStream($this->request); + $defaultHeaders = [ + 'Accept' => 'application/x-thrift', + 'Content-Type' => 'application/x-thrift', + 'Content-Length' => (string) ($body->getSize() ?? strlen($this->request)), + 'User-Agent' => 'PHP/TPsrHttpClient', + ]; + $psrRequest = $this->requestFactory->createRequest('POST', $this->url) + ->withBody($body); + foreach (array_merge($defaultHeaders, $this->headers) as $name => $value) { + $psrRequest = $psrRequest->withHeader($name, (string) $value); + } + } catch (\InvalidArgumentException | \RuntimeException $e) { + throw new TTransportException( + 'TPsrHttpClient: invalid request for ' . $this->url . ': ' . $e->getMessage(), + TTransportException::NOT_OPEN, + ); + } + + $this->request = ''; + + try { + $response = $this->client->sendRequest($psrRequest); + } catch (NetworkExceptionInterface $e) { + throw new TTransportException( + 'TPsrHttpClient: Could not connect to ' . $this->url . ': ' . $e->getMessage(), + TTransportException::NOT_OPEN, + ); + } catch (ClientExceptionInterface $e) { + throw new TTransportException( + 'TPsrHttpClient: Request to ' . $this->url . ' failed: ' . $e->getMessage(), + TTransportException::UNKNOWN, + ); + } + + $code = $response->getStatusCode(); + if ($code !== 200) { + throw new TTransportException( + 'TPsrHttpClient: Could not connect to ' . $this->url . ', HTTP status code: ' . $code, + TTransportException::UNKNOWN, + ); + } + + $this->response = (string) $response->getBody(); + } + + /** + * @param array $headers + */ + public function addHeaders(array $headers): void + { + $this->headers = array_merge($this->headers, $headers); + } + + /** + * @template T + * @param callable(): T $find + * @return T + * + * @throws TTransportException when discovery cannot locate the dependency + */ + private static function discover(callable $find, string $name, string $suggested): mixed + { + try { + return $find(); + } catch (NotFoundException) { + throw new TTransportException( + "TPsrHttpClient: no $name found. Install $suggested.", + TTransportException::NOT_OPEN, + ); + } + } +} diff --git a/lib/php/test/Unit/Lib/Transport/TPsrHttpClientTest.php b/lib/php/test/Unit/Lib/Transport/TPsrHttpClientTest.php new file mode 100644 index 00000000000..05976660f4f --- /dev/null +++ b/lib/php/test/Unit/Lib/Transport/TPsrHttpClientTest.php @@ -0,0 +1,303 @@ +psr17 = new Psr17Factory(); + } + + private function makeClient(ResponseInterface|\Throwable $behavior): ClientInterface + { + return new class ($behavior) implements ClientInterface { + public ?RequestInterface $lastRequest = null; + + public function __construct(private ResponseInterface|\Throwable $behavior) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->lastRequest = $request; + if ($this->behavior instanceof \Throwable) { + throw $this->behavior; + } + return $this->behavior; + } + }; + } + + private function makeTransport( + ClientInterface $client, + string $url = 'http://localhost', + ): TPsrHttpClient { + return new TPsrHttpClient( + $url, + $client, + $this->psr17, + $this->psr17, + ); + } + + public function testIsOpenAlwaysTrue(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->assertTrue($transport->isOpen()); + } + + public function testOpenIsNoop(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->assertNull($transport->open()); + } + + public function testCloseClearsBuffers(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->setPropertyValue($transport, 'request', 'pending'); + $this->setPropertyValue($transport, 'response', 'leftover'); + + $transport->close(); + + $this->assertSame('', $this->getPropertyValue($transport, 'request')); + $this->assertSame('', $this->getPropertyValue($transport, 'response')); + } + + public function testWriteAccumulatesBuffer(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + + $transport->write('foo'); + $transport->write('bar'); + + $this->assertSame('foobar', $this->getPropertyValue($transport, 'request')); + } + + public function testReadConsumesResponseBuffer(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->setPropertyValue($transport, 'response', '1234567890'); + + $this->assertSame('12345', $transport->read(5)); + $this->assertSame('67890', $this->getPropertyValue($transport, 'response')); + $this->assertSame('67890', $transport->read(99)); + $this->assertSame('', $this->getPropertyValue($transport, 'response')); + } + + public function testReadAllThrowsWhenShort(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->setPropertyValue($transport, 'response', 'abc'); + + $this->expectException(TTransportException::class); + $this->expectExceptionMessage('TPsrHttpClient could not read 10 bytes'); + + $transport->readAll(10); + } + + public function testAddHeadersMergesWithExisting(): void + { + $transport = $this->makeTransport($this->makeClient($this->psr17->createResponse(200))); + $this->setPropertyValue($transport, 'headers', ['X-Existing' => 'old']); + + $transport->addHeaders(['X-New' => 'new', 'X-Existing' => 'replaced']); + + $this->assertSame( + ['X-Existing' => 'replaced', 'X-New' => 'new'], + $this->getPropertyValue($transport, 'headers'), + ); + } + + public function testFlushSendsPostWithDefaultHeaders(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client); + + $transport->write('payload-bytes'); + $transport->flush(); + + $request = $client->lastRequest; + $this->assertNotNull($request); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('http://localhost', (string) $request->getUri()); + $this->assertSame('payload-bytes', (string) $request->getBody()); + $this->assertSame('application/x-thrift', $request->getHeaderLine('Accept')); + $this->assertSame('application/x-thrift', $request->getHeaderLine('Content-Type')); + $this->assertSame((string) strlen('payload-bytes'), $request->getHeaderLine('Content-Length')); + $this->assertSame('PHP/TPsrHttpClient', $request->getHeaderLine('User-Agent')); + } + + public function testFlushWithEmptyBody(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client); + + $transport->flush(); + + $request = $client->lastRequest; + $this->assertNotNull($request); + $this->assertSame('', (string) $request->getBody()); + $this->assertSame('0', $request->getHeaderLine('Content-Length')); + } + + public function testFlushClearsRequestBufferAfterSend(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client); + $transport->write('payload'); + + $transport->flush(); + + $this->assertSame('', $this->getPropertyValue($transport, 'request')); + } + + #[DataProvider('urlProvider')] + public function testFlushPassesUrlThroughToRequest(string $url): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client, $url); + + $transport->flush(); + + $this->assertSame($url, (string) $client->lastRequest->getUri()); + } + + public static function urlProvider(): iterable + { + yield 'http no path' => ['http://localhost']; + yield 'http with path' => ['http://example.com/rpc']; + yield 'http non-default port' => ['http://example.com:8080/rpc']; + yield 'https with path' => ['https://example.com/rpc']; + yield 'https with port' => ['https://example.com:8443/rpc']; + yield 'nested path' => ['http://example.com/api/v1/thrift']; + } + + public function testFlushAppliesCustomHeaders(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client); + $transport->addHeaders(['X-Test-Header' => 'value-1', 'Authorization' => 'Bearer token']); + + $transport->flush(); + + $request = $client->lastRequest; + $this->assertSame('value-1', $request->getHeaderLine('X-Test-Header')); + $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); + } + + public function testFlushAllowsCustomHeadersToOverrideDefaults(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client); + $transport->addHeaders(['User-Agent' => 'custom-agent']); + + $transport->flush(); + + $this->assertSame('custom-agent', $client->lastRequest->getHeaderLine('User-Agent')); + } + + public function testFlushPopulatesResponseBufferFromBody(): void + { + $response = $this->psr17->createResponse(200) + ->withBody($this->psr17->createStream('response-bytes')); + $client = $this->makeClient($response); + $transport = $this->makeTransport($client); + + $transport->flush(); + + $this->assertSame('response-bytes', $transport->readAll(strlen('response-bytes'))); + } + + public function testFlushWrapsInvalidUrlAsNotOpen(): void + { + $client = $this->makeClient($this->psr17->createResponse(200)); + $transport = $this->makeTransport($client, 'http://example.com:99999/rpc'); + + $this->expectException(TTransportException::class); + $this->expectExceptionCode(TTransportException::NOT_OPEN); + $this->expectExceptionMessage('TPsrHttpClient: invalid request for http://example.com:99999/rpc'); + + $transport->flush(); + } + + public function testFlushThrowsOnNon200(): void + { + $client = $this->makeClient($this->psr17->createResponse(500)); + $transport = $this->makeTransport($client, 'http://example.com:8080/rpc'); + + $this->expectException(TTransportException::class); + $this->expectExceptionMessage('TPsrHttpClient: Could not connect to http://example.com:8080/rpc, HTTP status code: 500'); + $this->expectExceptionCode(TTransportException::UNKNOWN); + + $transport->flush(); + } + + public function testFlushWrapsNetworkExceptionAsNotOpen(): void + { + $networkException = new class ('boom') extends \RuntimeException implements NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + throw new \LogicException('not used'); + } + }; + $transport = $this->makeTransport($this->makeClient($networkException)); + + $this->expectException(TTransportException::class); + $this->expectExceptionCode(TTransportException::NOT_OPEN); + $this->expectExceptionMessage('TPsrHttpClient: Could not connect to http://localhost: boom'); + + $transport->flush(); + } + + public function testFlushWrapsGenericClientExceptionAsUnknown(): void + { + $clientException = new class ('client error') extends \RuntimeException implements ClientExceptionInterface { + }; + $transport = $this->makeTransport($this->makeClient($clientException)); + + $this->expectException(TTransportException::class); + $this->expectExceptionCode(TTransportException::UNKNOWN); + $this->expectExceptionMessage('TPsrHttpClient: Request to http://localhost failed: client error'); + + $transport->flush(); + } +}