diff --git a/README.md b/README.md index 1de066d7..8eed968f 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,27 @@ BBB_SERVER_BASE_URL=https://your-bbb-server.example.com/bigbluebutton/ BBB_SECRET=your-secret ``` +### 5. HTTP Client + +The library uses curl by default and has no HTTP client dependency. Alternatively, inject any PSR-18 client with its PSR-17 factories: + +```php +use BigBlueButton\BigBlueButton; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; + +$factory = new HttpFactory(); +$bbb = BigBlueButton::createWithHttpClient( + new Client(['timeout' => 10]), + $factory, + $factory, + 'https://your-bbb-server.example.com/bigbluebutton/', + 'your-secret', +); +``` + +See the [HTTP Client documentation](docs/src/general/http_client.md) for more examples (Guzzle, Symfony HttpClient, php-http) and behavioral notes. + --- ## ✅ Pre-Commit Checks (CaptainHook) diff --git a/composer.json b/composer.json index 6d04c018..9da423f8 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,9 @@ "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "ext-simplexml": "*" + "ext-simplexml": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1" }, "require-dev": { "bmitch/churn-php": "^1.7", @@ -41,6 +43,8 @@ "fakerphp/faker": "^1.23", "friendsofphp/php-cs-fixer": "^3.54", "nunomaduro/phpinsights": "^2.11", + "nyholm/psr7": "^1.8", + "php-http/curl-client": "^2.4", "phpstan/phpstan": "^1.10", "phpunit/php-code-coverage": "^10.1", "phpunit/phpunit": "^10.5", diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a22135b2..f495efd0 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -4,6 +4,7 @@ - [General]() - [Welcome](./general/home.md) - [Getting Started](./general/getting_started.md) + - [HTTP Client](./general/http_client.md) - [Executing API Calls]() - [Meetings](./api_calls/meetings.md) diff --git a/docs/src/general/http_client.md b/docs/src/general/http_client.md new file mode 100644 index 00000000..6d3e1572 --- /dev/null +++ b/docs/src/general/http_client.md @@ -0,0 +1,97 @@ +{{#include ../header.md}} + +# HTTP Client + +By default, this library sends all requests with PHP's curl extension — no HTTP client package is required: + +```php +use BigBlueButton\BigBlueButton; + +$bbb = new BigBlueButton('https://your-server.example.com/bigbluebutton/', 'your-secret'); +``` + +## Injecting a PSR-18 http client + +Alternatively, you can inject any [PSR-18](https://www.php-fig.org/psr/psr-18/) http client together with the [PSR-17](https://www.php-fig.org/psr/psr-17/) request and stream factories. This makes the library independent of curl and lets you reuse the client, its configuration and its logging/middleware stack from your application: + +```php +use BigBlueButton\BigBlueButton; + +$bbb = BigBlueButton::createWithHttpClient( + $httpClient, // Psr\Http\Client\ClientInterface + $requestFactory, // Psr\Http\Message\RequestFactoryInterface + $streamFactory, // Psr\Http\Message\StreamFactoryInterface + 'https://your-server.example.com/bigbluebutton/', + 'your-secret', +); +``` + +The library itself only requires the two interface packages (`psr/http-client`, `psr/http-factory`) — bring your own client. + +### Example: Guzzle + +```php +use BigBlueButton\BigBlueButton; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; + +$client = new Client(['timeout' => 10]); +$factory = new HttpFactory(); // implements all PSR-17 interfaces + +$bbb = BigBlueButton::createWithHttpClient( + $client, + $factory, + $factory, + 'https://your-server.example.com/bigbluebutton/', + 'your-secret', +); +``` + +### Example: Symfony HttpClient + +```php +use BigBlueButton\BigBlueButton; +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\HttplugClient; + +$client = new HttplugClient(); // PSR-18 compatible +$factory = new Psr17Factory(); + +$bbb = BigBlueButton::createWithHttpClient( + $client, + $factory, + $factory, + 'https://your-server.example.com/bigbluebutton/', + 'your-secret', +); +``` + +### Example: lightweight php-http/curl-client + +```php +use BigBlueButton\BigBlueButton; +use Http\Client\Curl\Client; +use Nyholm\Psr7\Factory\Psr17Factory; + +$factory = new Psr17Factory(); +$client = new Client($factory, $factory, [ + CURLOPT_FOLLOWLOCATION => 1, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, +]); + +$bbb = BigBlueButton::createWithHttpClient( + $client, + $factory, + $factory, + 'https://your-server.example.com/bigbluebutton/', + 'your-secret', +); +``` + +## Behavior with an injected client + +- **Timeouts and transport options are the responsibility of your client.** `setTimeOut()` and `setCurlOpts()` have no effect on an instance created with `createWithHttpClient()`. Configure timeouts, SSL verification and proxies on the http client you pass in. +- **Redirects:** the built-in curl transport follows redirects. If your client does not follow redirects by default, enable it if you rely on redirecting calls (e.g. `join` with `redirect=true`). +- **Multipart uploads** (e.g. uploading a caption track via `putRecordingTextTrack`) are fully supported with an injected client — the library builds the `multipart/form-data` request itself. +- **Error handling stays the same:** non-2xx responses throw a `BadResponseException` regardless of the transport used. diff --git a/src/BigBlueButton.php b/src/BigBlueButton.php index 30f6d248..4fdf8373 100644 --- a/src/BigBlueButton.php +++ b/src/BigBlueButton.php @@ -63,6 +63,10 @@ use BigBlueButton\Responses\SendChatMessageResponse; use BigBlueButton\Responses\UpdateRecordingsResponse; use BigBlueButton\Util\UrlBuilder; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\StreamFactoryInterface; /** * Class BigBlueButton. @@ -96,6 +100,21 @@ class BigBlueButton protected UrlBuilder $urlBuilder; + /** + * An http client, or NULL to fall back to curl. + */ + private ?ClientInterface $httpClient = null; + + /** + * An http request factory, or NULL to fall back to curl. + */ + private ?RequestFactoryInterface $requestFactory = null; + + /** + * A stream factory, or NULL to fall back to curl. + */ + private ?StreamFactoryInterface $streamFactory = null; + /** * @param null|array $opts */ @@ -117,6 +136,33 @@ public function __construct( $this->curlOpts = $opts['curl'] ?? []; } + /** + * Creates an instance with http client and factories. + * + * It is recommended for the http client to have a timeout of e.g. 10 + * seconds, to avoid hanging requests. The timeout from ->setTimeOut() will + * have no effect on an instance created in this way. + * + * @see docs/src/general/http_client.md for usage examples + */ + public static function createWithHttpClient( + ClientInterface $httpClient, + RequestFactoryInterface $requestFactory, + StreamFactoryInterface $streamFactory, + string $baseUrl, + string $secret, + ): static { + // Extending classes need to override this method, if they change the + // constructor signature. + // @phpstan-ignore new.static + $instance = new static($baseUrl, $secret); + $instance->httpClient = $httpClient; + $instance->requestFactory = $requestFactory; + $instance->streamFactory = $streamFactory; + + return $instance; + } + /** * @throws BadResponseException|\RuntimeException */ @@ -552,6 +598,10 @@ public function setJSessionId(string $jSessionId): void } /** + * Sets curl options. + * + * This has no effect if the instance has an http client. + * * @param array $curlOpts */ public function setCurlOpts(array $curlOpts): void @@ -561,6 +611,8 @@ public function setCurlOpts(array $curlOpts): void /** * Set Curl Timeout (Optional), Default 10 Seconds. + * + * This has no effect if the instance has an http client. */ public function setTimeOut(int $TimeOutInSeconds): self { @@ -600,6 +652,102 @@ public function getUrlBuilder(): UrlBuilder // ____________________ INTERNAL CLASS METHODS ___________________ + /** + * A private utility method used by other public methods to request HTTP responses. + * + * Uses the injected PSR http client, or falls back to curl if no client is + * injected. + * + * @param array|string $payload + * + * @throws BadResponseException|\RuntimeException + */ + private function sendRequest(string $url, array|string $payload = '', string $contentType = 'application/xml'): string + { + if (null === $this->httpClient + || null === $this->requestFactory + || null === $this->streamFactory + ) { + return $this->sendRequestWithCurl($url, $payload, $contentType); + } + + if (\is_array($payload)) { + $request = $this->buildMultipartRequest($url, $payload); + } else { + $request = $this->requestFactory->createRequest('GET', $url); + + if ('' !== $payload) { + $request = $request + ->withBody($this->streamFactory->createStream($payload)) + ->withMethod('POST') + ->withHeader('Content-type', $contentType) + ; + } + } + + $response = $this->httpClient->sendRequest($request); + + // JSESSIONID - capture from the Set-Cookie headers with the same + // validation as the curl transport + foreach ($response->getHeader('Set-Cookie') as $cookie) { + if ($this->isValidCookieFormat($cookie)) { + $sessionId = $this->extractJSessionIdSafely($cookie); + + if (null !== $sessionId) { + $this->setJSessionId($sessionId); + } + } + } + + $httpCode = $response->getStatusCode(); + + if ($httpCode < 200 || $httpCode >= 300) { + throw new BadResponseException('Bad response, HTTP code: ' . $httpCode . ', url: ' . $url); + } + + return (string) $response->getBody(); + } + + /** + * Builds a multipart/form-data request, e.g. for the caption track upload. + * + * @param array $payload + */ + private function buildMultipartRequest(string $url, array $payload): RequestInterface + { + if (null === $this->requestFactory || null === $this->streamFactory) { + throw new \RuntimeException('A request factory and a stream factory are required to build a multipart request.'); + } + + $boundary = 'bbb-' . bin2hex(random_bytes(16)); + $body = $this->streamFactory->createStream(''); + + foreach ($payload as $name => $value) { + $body->write("--{$boundary}\r\n"); + + if ($value instanceof \CURLFile) { + $filename = str_replace(["\r", "\n", '"'], '', $value->getPostFilename()); + $body->write(sprintf( + "Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n", + $name, + $filename, + $value->getMimeType() + )); + $body->write((string) $this->streamFactory->createStreamFromFile($value->getFilename())); + $body->write("\r\n"); + } else { + $body->write(sprintf("Content-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n", $name, $value)); + } + } + + $body->write("--{$boundary}--\r\n"); + + return $this->requestFactory->createRequest('POST', $url) + ->withBody($body) + ->withHeader('Content-type', 'multipart/form-data; boundary=' . $boundary) + ; + } + /** * A private utility method used by other public methods to request HTTP responses. * @@ -611,7 +759,7 @@ public function getUrlBuilder(): UrlBuilder * * @throws BadResponseException|\RuntimeException */ - private function sendRequest(string $url, array|string $payload = '', string $contentType = 'application/xml'): string + private function sendRequestWithCurl(string $url, array|string $payload = '', string $contentType = 'application/xml'): string { if (!extension_loaded('curl')) { throw new \RuntimeException('Post XML data set but curl PHP module is not installed or not enabled.'); diff --git a/tests/BigBlueButtonHttpClientTest.php b/tests/BigBlueButtonHttpClientTest.php new file mode 100644 index 00000000..20e74220 --- /dev/null +++ b/tests/BigBlueButtonHttpClientTest.php @@ -0,0 +1,58 @@ +. + */ + +namespace BigBlueButton; + +use Http\Client\Curl\Client; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * Class BigBlueButtonHttpClientTest. + * + * This test verifies that all the functionality that works with curl also works + * with an injected PSR-18 http client. In this case, the lightweight + * php-http/curl-client is used with nyholm/psr7 as PSR-17 factory. + * + * @internal + */ +class BigBlueButtonHttpClientTest extends BigBlueButtonTest +{ + /** + * Setup test class. + */ + public function setUp(): void + { + parent::setUp(); + + $psr17Factory = new Psr17Factory(); + $client = new Client($psr17Factory, $psr17Factory, [ + CURLOPT_FOLLOWLOCATION => 1, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + ]); + $this->bbb = BigBlueButton::createWithHttpClient( + $client, + $psr17Factory, + $psr17Factory, + getenv('BBB_SERVER_BASE_URL') ?: $this->fail(), + getenv('BBB_SECRET') ?: getenv('BBB_SECURITY_SALT') ?: $this->fail(), + ); + } +} diff --git a/tests/BigBlueButtonTest.php b/tests/BigBlueButtonTest.php index 693b7b94..05af7886 100644 --- a/tests/BigBlueButtonTest.php +++ b/tests/BigBlueButtonTest.php @@ -48,7 +48,7 @@ */ class BigBlueButtonTest extends TestCase { - private BigBlueButton $bbb; + protected BigBlueButton $bbb; /** * Setup test class. diff --git a/tests/Core/DocumentUrlTest.php b/tests/Core/DocumentUrlTest.php index 831d4f2f..a9568e22 100644 --- a/tests/Core/DocumentUrlTest.php +++ b/tests/Core/DocumentUrlTest.php @@ -21,6 +21,7 @@ namespace BigBlueButton\Core; use BigBlueButton\TestCase; +use BigBlueButton\TestServices\EnvLoader; /** * Class DocumentUrlTest. @@ -78,10 +79,11 @@ public function testSetAndGetTimeout(): void public function testIsValidWithValidUrl(): void { - // Use a reliable URL that should be accessible - $documentUrl = new DocumentUrl('https://example.com/'); + // Use the BBB-Server of the test environment (same server all other live tests use) + EnvLoader::loadEnvironmentVariables(); + $baseUrl = mb_rtrim((string) getenv('BBB_SERVER_BASE_URL'), '/'); + $documentUrl = new DocumentUrl($baseUrl . '/'); - // This test might be slow due to network call $isValid = $documentUrl->isValid(); $this->assertTrue($isValid); @@ -89,10 +91,11 @@ public function testIsValidWithValidUrl(): void public function testIsValidWithInvalidUrl(): void { - // Use a URL that should return 404 - $documentUrl = new DocumentUrl('https://example.com/nonexistent'); + // Use a path that is answered with 404 by the BBB-Server + EnvLoader::loadEnvironmentVariables(); + $baseUrl = mb_rtrim((string) getenv('BBB_SERVER_BASE_URL'), '/'); + $documentUrl = new DocumentUrl($baseUrl . '/nonexistent-path-for-404'); - // This test might be slow due to network call $isValid = $documentUrl->isValid(); $this->assertFalse($isValid); diff --git a/tests/Util/FixturesHttpClientTest.php b/tests/Util/FixturesHttpClientTest.php new file mode 100644 index 00000000..cacda8e0 --- /dev/null +++ b/tests/Util/FixturesHttpClientTest.php @@ -0,0 +1,55 @@ +. + */ + +namespace BigBlueButton\Util; + +use BigBlueButton\BigBlueButton; +use Http\Client\Curl\Client; +use Nyholm\Psr7\Factory\Psr17Factory; + +/** + * Class FixturesHttpClientTest. + * + * Runs the fixture validation of the FixturesTest with an injected PSR-18 http + * client (php-http/curl-client with nyholm/psr7) instead of curl. + * + * @internal + */ +class FixturesHttpClientTest extends FixturesTest +{ + public function setUp(): void + { + parent::setUp(); + + $psr17Factory = new Psr17Factory(); + $client = new Client($psr17Factory, $psr17Factory, [ + CURLOPT_FOLLOWLOCATION => 1, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + ]); + $this->bbb = BigBlueButton::createWithHttpClient( + $client, + $psr17Factory, + $psr17Factory, + getenv('BBB_SERVER_BASE_URL') ?: $this->fail(), + getenv('BBB_SECRET') ?: getenv('BBB_SECURITY_SALT') ?: $this->fail(), + ); + } +} diff --git a/tests/Util/FixturesTest.php b/tests/Util/FixturesTest.php index 2cf4800c..80d5d467 100644 --- a/tests/Util/FixturesTest.php +++ b/tests/Util/FixturesTest.php @@ -48,7 +48,7 @@ */ class FixturesTest extends TestCase { - private BigBlueButton $bbb; + protected BigBlueButton $bbb; private Fixtures $fixtures; private static Generator $faker;