From c67f925085b350540fefc87896c6746599afb17d Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 19:11:02 -0600 Subject: [PATCH 01/14] feat: adds support for transporter options --- .../Contracts/ClientWithOptionsInterface.php | 34 ++ .../Contracts/HttpTransporterInterface.php | 4 +- src/Providers/Http/DTO/Request.php | 136 ++++++- src/Providers/Http/DTO/RequestOptions.php | 351 ++++++++++++++++++ src/Providers/Http/HttpTransporter.php | 12 +- src/Providers/Models/DTO/ModelConfig.php | 49 ++- 6 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 src/Providers/Http/Contracts/ClientWithOptionsInterface.php create mode 100644 src/Providers/Http/DTO/RequestOptions.php diff --git a/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/Providers/Http/Contracts/ClientWithOptionsInterface.php new file mode 100644 index 00000000..6ec29ef3 --- /dev/null +++ b/src/Providers/Http/Contracts/ClientWithOptionsInterface.php @@ -0,0 +1,34 @@ +>, - * body?: string|null + * body?: string|null, + * options?: RequestOptionsArrayShape * } * * @extends AbstractDataTransferObject @@ -34,6 +36,7 @@ class Request extends AbstractDataTransferObject public const KEY_URI = 'uri'; public const KEY_HEADERS = 'headers'; public const KEY_BODY = 'body'; + public const KEY_OPTIONS = 'options'; /** * @var HttpMethodEnum The HTTP method. @@ -60,6 +63,11 @@ class Request extends AbstractDataTransferObject */ protected ?string $body = null; + /** + * @var RequestOptions|null Request transport options. + */ + protected ?RequestOptions $options = null; + /** * Constructor. * @@ -69,11 +77,17 @@ class Request extends AbstractDataTransferObject * @param string $uri The request URI. * @param array> $headers The request headers. * @param string|array|null $data The request data. + * @param RequestOptions|null $options The request transport options. * * @throws InvalidArgumentException If the URI is empty. */ - public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null) - { + public function __construct( + HttpMethodEnum $method, + string $uri, + array $headers = [], + $data = null, + ?RequestOptions $options = null + ) { if (empty($uri)) { throw new InvalidArgumentException('URI cannot be empty.'); } @@ -88,6 +102,8 @@ public function __construct(HttpMethodEnum $method, string $uri, array $headers } elseif (is_array($data)) { $this->data = $data; } + + $this->options = $options; } /** @@ -281,6 +297,107 @@ public function getData(): ?array return $this->data; } + /** + * Gets the request options. + * + * @since n.e.x.t + * + * @return RequestOptions|null Request transport options when configured. + */ + public function getOptions(): ?RequestOptions + { + return $this->options; + } + + /** + * Sets the request timeout in seconds. + * + * @since n.e.x.t + * + * @param float|null $timeout Timeout in seconds. + */ + public function setTimeout(?float $timeout): void + { + $options = $this->ensureOptions(); + $this->options = $options->withTimeout($timeout); + } + + /** + * Sets the connection timeout in seconds. + * + * @since n.e.x.t + * + * @param float|null $timeout Connection timeout in seconds. + */ + public function setConnectTimeout(?float $timeout): void + { + $options = $this->ensureOptions(); + $this->options = $options->withConnectTimeout($timeout); + } + + /** + * Sets whether redirects are automatically followed. + * + * @since n.e.x.t + * + * @param bool $allowRedirects Whether redirects should be followed. + */ + public function setAllowRedirects(bool $allowRedirects): void + { + $options = $this->ensureOptions(); + + if ($allowRedirects) { + $this->options = $options->withRedirects(); + return; + } + + $this->options = $options->withoutRedirects(); + } + + /** + * Sets the maximum number of redirects to follow. + * + * @since n.e.x.t + * + * @param int|null $maxRedirects Maximum redirects when enabled. + */ + public function setMaxRedirects(?int $maxRedirects): void + { + $options = $this->ensureOptions(); + $this->options = $options->withMaxRedirects($maxRedirects); + } + + /** + * Ensures request options instance exists. + * + * @since n.e.x.t + * + * @return RequestOptions The ensured request options instance. + */ + private function ensureOptions(): RequestOptions + { + if ($this->options === null) { + $this->options = new RequestOptions(); + } + + return $this->options; + } + + /** + * Returns a new instance with the specified request options. + * + * @since n.e.x.t + * + * @param RequestOptions|null $options The request options to apply. + * @return self A new instance with the options. + */ + public function withOptions(?RequestOptions $options): self + { + $new = clone $this; + $new->options = $options; + return $new; + } + /** * {@inheritDoc} * @@ -311,6 +428,7 @@ public static function getJsonSchema(): array 'type' => ['string'], 'description' => 'The request body.', ], + self::KEY_OPTIONS => RequestOptions::getJsonSchema(), ], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS], ]; @@ -337,6 +455,13 @@ public function toArray(): array $array[self::KEY_BODY] = $body; } + if ($this->options !== null) { + $optionsArray = $this->options->toArray(); + if (!empty($optionsArray)) { + $array[self::KEY_OPTIONS] = $optionsArray; + } + } + return $array; } @@ -353,7 +478,10 @@ public static function fromArray(array $array): self HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], - $array[self::KEY_BODY] ?? null + $array[self::KEY_BODY] ?? null, + isset($array[self::KEY_OPTIONS]) + ? RequestOptions::fromArray($array[self::KEY_OPTIONS]) + : null ); } diff --git a/src/Providers/Http/DTO/RequestOptions.php b/src/Providers/Http/DTO/RequestOptions.php new file mode 100644 index 00000000..b87b302b --- /dev/null +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -0,0 +1,351 @@ + + */ +class RequestOptions extends AbstractDataTransferObject +{ + public const KEY_TIMEOUT = 'timeout'; + public const KEY_CONNECT_TIMEOUT = 'connectTimeout'; + public const KEY_ALLOW_REDIRECTS = 'allowRedirects'; + public const KEY_MAX_REDIRECTS = 'maxRedirects'; + + /** + * @var float|null Maximum duration in seconds to wait for the full response. + */ + protected ?float $timeout; + + /** + * @var float|null Maximum duration in seconds to wait for the initial connection. + */ + protected ?float $connectTimeout; + + /** + * @var bool|null Whether HTTP redirects should be automatically followed. + */ + protected ?bool $allowRedirects; + + /** + * @var int|null Maximum number of redirects to follow when enabled. + */ + protected ?int $maxRedirects; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param float|null $timeout Maximum duration in seconds to wait for the response. + * @param float|null $connectTimeout Maximum duration in seconds to wait for the connection. + * @param bool|null $allowRedirects Whether redirects should be followed. + * @param int|null $maxRedirects Maximum redirects to follow when enabled. + * + * @throws InvalidArgumentException When timeout values or redirect limits are invalid. + */ + public function __construct( + ?float $timeout = null, + ?float $connectTimeout = null, + ?bool $allowRedirects = null, + ?int $maxRedirects = null + ) { + $this->validateTimeout($timeout, self::KEY_TIMEOUT); + $this->validateTimeout($connectTimeout, self::KEY_CONNECT_TIMEOUT); + $this->validateRedirectLimit($allowRedirects, $maxRedirects); + + $this->timeout = $timeout; + $this->connectTimeout = $connectTimeout; + $this->allowRedirects = $allowRedirects; + $this->maxRedirects = $allowRedirects === true ? $maxRedirects : null; + } + + /** + * Returns a new instance with an updated request timeout. + * + * @since n.e.x.t + * + * @param float|null $timeout Timeout in seconds. + * @return self Options instance with updated timeout. + */ + public function withTimeout(?float $timeout): self + { + $this->validateTimeout($timeout, self::KEY_TIMEOUT); + + $clone = clone $this; + $clone->timeout = $timeout; + return $clone; + } + + /** + * Returns a new instance with an updated connection timeout. + * + * @since n.e.x.t + * + * @param float|null $timeout Connection timeout in seconds. + * @return self Options instance with updated connection timeout. + */ + public function withConnectTimeout(?float $timeout): self + { + $this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT); + + $clone = clone $this; + $clone->connectTimeout = $timeout; + return $clone; + } + + /** + * Returns a new instance with redirects enabled. + * + * @since n.e.x.t + * + * @param int|null $maxRedirects Maximum redirects to follow, or null to leave unspecified. + * @return self Options instance with redirects enabled. + */ + public function withRedirects(?int $maxRedirects = null): self + { + $limit = $maxRedirects ?? $this->maxRedirects; + $this->validateRedirectLimit(true, $limit); + + $clone = clone $this; + $clone->allowRedirects = true; + $clone->maxRedirects = $limit; + return $clone; + } + + /** + * Returns a new instance with redirects disabled. + * + * @since n.e.x.t + * + * @return self Options instance with redirects disabled. + */ + public function withoutRedirects(): self + { + $clone = clone $this; + $clone->allowRedirects = false; + $clone->maxRedirects = null; + return $clone; + } + + /** + * Returns a new instance with an updated redirect limit. + * + * @since n.e.x.t + * + * @param int|null $maxRedirects Maximum redirects to follow. + * @return self Options instance with updated redirect limit. + */ + public function withMaxRedirects(?int $maxRedirects): self + { + $this->validateRedirectLimit($this->allowRedirects, $maxRedirects); + + $clone = clone $this; + + if ($this->allowRedirects === true) { + $clone->maxRedirects = $maxRedirects; + } else { + $clone->maxRedirects = null; + } + + return $clone; + } + + /** + * Gets the request timeout in seconds. + * + * @since n.e.x.t + * + * @return float|null Timeout in seconds. + */ + public function getTimeout(): ?float + { + return $this->timeout; + } + + /** + * Gets the connection timeout in seconds. + * + * @since n.e.x.t + * + * @return float|null Connection timeout in seconds. + */ + public function getConnectTimeout(): ?float + { + return $this->connectTimeout; + } + + /** + * Checks whether redirects are allowed. + * + * @since n.e.x.t + * + * @return bool|null True when redirects are allowed, false when disabled, null when unspecified. + */ + public function allowsRedirects(): ?bool + { + return $this->allowRedirects; + } + + /** + * Gets the maximum number of redirects to follow. + * + * @since n.e.x.t + * + * @return int|null Maximum redirects or null when not specified. + */ + public function getMaxRedirects(): ?int + { + return $this->maxRedirects; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return RequestOptionsArrayShape + */ + public function toArray(): array + { + $data = []; + + if ($this->timeout !== null) { + $data[self::KEY_TIMEOUT] = $this->timeout; + } + + if ($this->connectTimeout !== null) { + $data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout; + } + + if ($this->allowRedirects !== null) { + $data[self::KEY_ALLOW_REDIRECTS] = $this->allowRedirects; + } + + if ($this->maxRedirects !== null) { + $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + $timeout = $array[self::KEY_TIMEOUT] ?? null; + $connectTimeout = $array[self::KEY_CONNECT_TIMEOUT] ?? null; + $allowRedirects = $array[self::KEY_ALLOW_REDIRECTS] ?? null; + $maxRedirects = $array[self::KEY_MAX_REDIRECTS] ?? null; + + return new self( + $timeout !== null ? (float) $timeout : null, + $connectTimeout !== null ? (float) $connectTimeout : null, + $allowRedirects !== null ? (bool) $allowRedirects : null, + $maxRedirects !== null ? (int) $maxRedirects : null + ); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_TIMEOUT => [ + 'type' => ['number', 'null'], + 'minimum' => 0, + 'description' => 'Maximum duration in seconds to wait for the full response.', + ], + self::KEY_CONNECT_TIMEOUT => [ + 'type' => ['number', 'null'], + 'minimum' => 0, + 'description' => 'Maximum duration in seconds to wait for the initial connection.', + ], + self::KEY_ALLOW_REDIRECTS => [ + 'type' => ['boolean', 'null'], + 'description' => 'Whether HTTP redirects should be automatically followed.', + ], + self::KEY_MAX_REDIRECTS => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'Maximum number of redirects to follow when enabled.', + ], + ], + 'additionalProperties' => false, + ]; + } + + /** + * Validates timeout values. + * + * @since n.e.x.t + * + * @param float|null $value Timeout to validate. + * @param string $fieldName Field name for the error message. + * + * @throws InvalidArgumentException When timeout is negative. + */ + private function validateTimeout(?float $value, string $fieldName): void + { + if ($value !== null && $value < 0) { + throw new InvalidArgumentException( + sprintf('Request option "%s" must be greater than or equal to 0.', $fieldName) + ); + } + } + + /** + * Validates redirect configuration. + * + * @since n.e.x.t + * + * @param bool|null $allowRedirects Whether redirects are enabled. + * @param int|null $maxRedirects Maximum redirects. + * + * @throws InvalidArgumentException When redirect count is invalid. + */ + private function validateRedirectLimit(?bool $allowRedirects, ?int $maxRedirects): void + { + if ($allowRedirects === true) { + if ($maxRedirects !== null && $maxRedirects < 0) { + throw new InvalidArgumentException( + 'Request option "maxRedirects" must be greater than or equal to 0 when redirects are enabled.' + ); + } + + return; + } + + if ($maxRedirects !== null) { + throw new InvalidArgumentException( + 'Request option "maxRedirects" can only be set when redirects are enabled.' + ); + } + } +} diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 3942364f..cd43a971 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -12,8 +12,10 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\NetworkException; @@ -66,13 +68,19 @@ public function __construct( * {@inheritDoc} * * @since 0.1.0 + * @since n.e.x.t Added optional RequestOptions parameter and ClientWithOptions support. */ - public function send(Request $request): Response + public function send(Request $request, ?RequestOptions $options = null): Response { $psr7Request = $this->convertToPsr7Request($request); + $options = $options ?? $request->getOptions(); try { - $psr7Response = $this->client->sendRequest($psr7Request); + if ($this->client instanceof ClientWithOptionsInterface) { + $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $options); + } else { + $psr7Response = $this->client->sendRequest($psr7Request); + } } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\Psr\Http\Client\ClientExceptionInterface $e) { diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index f0a88d59..8365d196 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -23,6 +24,7 @@ * * @phpstan-import-type FunctionDeclarationArrayShape from FunctionDeclaration * @phpstan-import-type WebSearchArrayShape from WebSearch + * @phpstan-import-type RequestOptionsArrayShape from RequestOptions * * @phpstan-type ModelConfigArrayShape array{ * outputModalities?: list, @@ -45,7 +47,8 @@ * outputMediaOrientation?: string, * outputMediaAspectRatio?: string, * outputSpeechVoice?: string, - * customOptions?: array + * customOptions?: array, + * requestOptions?: RequestOptionsArrayShape * } * * @extends AbstractDataTransferObject @@ -73,6 +76,7 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; public const KEY_CUSTOM_OPTIONS = 'customOptions'; + public const KEY_REQUEST_OPTIONS = 'requestOptions'; /* * Note: This key is not an actual model config key, but specified here for convenience. @@ -186,6 +190,11 @@ class ModelConfig extends AbstractDataTransferObject */ protected array $customOptions = []; + /** + * @var RequestOptions|null HTTP request options. + */ + protected ?RequestOptions $requestOptions = null; + /** * Sets the output modalities. * @@ -736,6 +745,30 @@ public function getCustomOptions(): array return $this->customOptions; } + /** + * Sets the HTTP request options for all transporter calls. + * + * @since n.e.x.t + * + * @param RequestOptions $requestOptions Request options to apply. + */ + public function setRequestOptions(RequestOptions $requestOptions): void + { + $this->requestOptions = $requestOptions; + } + + /** + * Gets the HTTP request options. + * + * @since n.e.x.t + * + * @return RequestOptions|null Request options when configured, null otherwise. + */ + public function getRequestOptions(): ?RequestOptions + { + return $this->requestOptions; + } + /** * {@inheritDoc} * @@ -848,6 +881,9 @@ public static function getJsonSchema(): array 'additionalProperties' => true, 'description' => 'Custom provider-specific options.', ], + self::KEY_REQUEST_OPTIONS => RequestOptions::getJsonSchema() + [ + 'description' => 'HTTP request transport options.', + ], ], 'additionalProperties' => false, ]; @@ -958,6 +994,13 @@ static function (FunctionDeclaration $function_declaration): array { $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; } + if ($this->requestOptions !== null) { + $requestOptions = $this->requestOptions->toArray(); + if (!empty($requestOptions)) { + $data[self::KEY_REQUEST_OPTIONS] = $requestOptions; + } + } + return $data; } @@ -1063,6 +1106,10 @@ static function (array $function_declaration_data): FunctionDeclaration { $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); } + if (isset($array[self::KEY_REQUEST_OPTIONS])) { + $config->setRequestOptions(RequestOptions::fromArray($array[self::KEY_REQUEST_OPTIONS])); + } + return $config; } } From d1c865722dad460e11eb8bfcdb5f66e6dee395fd Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 19:11:30 -0600 Subject: [PATCH 02/14] test: adds transporter option tests --- tests/mocks/MockHttpTransporter.php | 19 +++- .../Providers/Http/DTO/RequestOptionsTest.php | 100 ++++++++++++++++++ tests/unit/Providers/Http/DTO/RequestTest.php | 92 ++++++++++++++++ .../Providers/Models/Enums/OptionEnumTest.php | 3 + 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Providers/Http/DTO/RequestOptionsTest.php create mode 100644 tests/unit/Providers/Http/DTO/RequestTest.php diff --git a/tests/mocks/MockHttpTransporter.php b/tests/mocks/MockHttpTransporter.php index 739f90e4..3f943b8a 100644 --- a/tests/mocks/MockHttpTransporter.php +++ b/tests/mocks/MockHttpTransporter.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\DTO\Response; /** @@ -18,6 +19,11 @@ class MockHttpTransporter implements HttpTransporterInterface */ private ?Request $lastRequest = null; + /** + * @var RequestOptions|null The last options that were provided. + */ + private ?RequestOptions $lastOptions = null; + /** * @var Response|null The response to return. */ @@ -26,9 +32,10 @@ class MockHttpTransporter implements HttpTransporterInterface /** * {@inheritDoc} */ - public function send(Request $request): Response + public function send(Request $request, ?RequestOptions $options = null): Response { $this->lastRequest = $request; + $this->lastOptions = $options; return $this->responseToReturn ?? new Response(200, [], '{"status":"success"}'); } @@ -42,6 +49,16 @@ public function getLastRequest(): ?Request return $this->lastRequest; } + /** + * Gets the last request options that were provided. + * + * @return RequestOptions|null + */ + public function getLastOptions(): ?RequestOptions + { + return $this->lastOptions; + } + /** * Sets the response to return for subsequent requests. * diff --git a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php new file mode 100644 index 00000000..54c2b5e4 --- /dev/null +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -0,0 +1,100 @@ +assertNull($options->getTimeout()); + $this->assertNull($options->getConnectTimeout()); + $this->assertNull($options->allowsRedirects()); + $this->assertNull($options->getMaxRedirects()); + $this->assertSame([], $options->toArray()); + } + + /** + * Tests immutable helpers modify the cloned instance only. + * + * @return void + */ + public function testWithTimeoutReturnsUpdatedClone(): void + { + $options = new RequestOptions(); + $updated = $options->withTimeout(5.0); + + $this->assertNotSame($options, $updated); + $this->assertNull($options->getTimeout()); + $this->assertSame(5.0, $updated->getTimeout()); + } + + /** + * Tests enabling redirects with a limit. + * + * @return void + */ + public function testWithRedirectsSetsFlagsAndLimit(): void + { + $options = new RequestOptions(); + $updated = $options->withRedirects(3); + + $this->assertTrue($updated->allowsRedirects()); + $this->assertSame(3, $updated->getMaxRedirects()); + } + + /** + * Tests disabling redirects clears the maximum. + * + * @return void + */ + public function testWithoutRedirectsClearsRedirectLimit(): void + { + $options = (new RequestOptions())->withRedirects(4); + $disabled = $options->withoutRedirects(); + + $this->assertFalse($disabled->allowsRedirects()); + $this->assertNull($disabled->getMaxRedirects()); + } + + /** + * Tests validation when attempting to set a redirect limit while redirects are not enabled. + * + * @return void + */ + public function testWithMaxRedirectsThrowsWhenRedirectsDisabled(): void + { + $options = new RequestOptions(); + + $this->expectException(InvalidArgumentException::class); + $options->withMaxRedirects(2); + } + + /** + * Tests that the JSON schema reflects nullable redirect flag. + * + * @return void + */ + public function testGetJsonSchemaDefinesNullableRedirectFlag(): void + { + $schema = RequestOptions::getJsonSchema(); + + $this->assertSame(['boolean', 'null'], $schema['properties'][RequestOptions::KEY_ALLOW_REDIRECTS]['type']); + $this->assertSame(['integer', 'null'], $schema['properties'][RequestOptions::KEY_MAX_REDIRECTS]['type']); + } +} diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php new file mode 100644 index 00000000..25f73c91 --- /dev/null +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -0,0 +1,92 @@ +assertNull($request->getOptions()); + + $array = $request->toArray(); + $this->assertArrayNotHasKey(Request::KEY_OPTIONS, $array); + } + + /** + * Tests the withOptions helper stores the provided options immutably. + * + * @return void + */ + public function testWithOptionsStoresProvidedOptions(): void + { + $request = new Request(HttpMethodEnum::post(), 'https://example.com'); + $options = (new RequestOptions())->withTimeout(1.5); + + $updated = $request->withOptions($options); + + $this->assertNotSame($request, $updated); + $this->assertSame($options, $updated->getOptions()); + $this->assertNull($request->getOptions()); + } + + /** + * Tests that convenience setters lazily instantiate request options. + * + * @return void + */ + public function testSettersInstantiateRequestOptions(): void + { + $request = new Request(HttpMethodEnum::post(), 'https://example.com'); + $this->assertNull($request->getOptions()); + + $request->setTimeout(2.0); + $request->setConnectTimeout(1.0); + $request->setAllowRedirects(true); + $request->setMaxRedirects(5); + + $options = $request->getOptions(); + $this->assertInstanceOf(RequestOptions::class, $options); + $this->assertSame(2.0, $options->getTimeout()); + $this->assertSame(1.0, $options->getConnectTimeout()); + $this->assertTrue($options->allowsRedirects()); + $this->assertSame(5, $options->getMaxRedirects()); + + $array = $request->toArray(); + $this->assertArrayHasKey(Request::KEY_OPTIONS, $array); + $this->assertArrayHasKey(RequestOptions::KEY_TIMEOUT, $array[Request::KEY_OPTIONS]); + } + + /** + * Tests that disabling redirects clears the limit serialized in toArray. + * + * @return void + */ + public function testDisablingRedirectsClearsLimit(): void + { + $request = new Request(HttpMethodEnum::post(), 'https://example.com'); + $request->setAllowRedirects(true); + $request->setMaxRedirects(3); + $request->setAllowRedirects(false); + + $options = $request->getOptions(); + $this->assertFalse($options->allowsRedirects()); + $this->assertNull($options->getMaxRedirects()); + } +} diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index 9248a9ad..b9b0b55a 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -58,6 +58,7 @@ protected function getExpectedValues(): array 'OUTPUT_MEDIA_ASPECT_RATIO' => 'outputMediaAspectRatio', 'OUTPUT_SPEECH_VOICE' => 'outputSpeechVoice', 'CUSTOM_OPTIONS' => 'customOptions', + 'REQUEST_OPTIONS' => 'requestOptions', ]; } @@ -112,6 +113,7 @@ public function testDynamicallyLoadedConstants(): void $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaOrientation()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaAspectRatio()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::customOptions()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::requestOptions()); } /** @@ -135,5 +137,6 @@ public function testGetValuesIncludesDynamicConstants(): void $this->assertContains('outputMediaOrientation', $values); $this->assertContains('outputMediaAspectRatio', $values); $this->assertContains('customOptions', $values); + $this->assertContains('requestOptions', $values); } } From 6b744d7fe73f563843e6f66f6d088c37ab37484c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 19:25:16 -0600 Subject: [PATCH 03/14] feat: adds support for Guzzle --- src/Providers/Http/HttpTransporter.php | 118 ++++++++++++++++++ tests/mocks/GuzzleLikeClient.php | 93 ++++++++++++++ .../Providers/Http/HttpTransporterTest.php | 46 +++++++ 3 files changed, 257 insertions(+) create mode 100644 tests/mocks/GuzzleLikeClient.php diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index cd43a971..1a5b51dd 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -78,6 +78,8 @@ public function send(Request $request, ?RequestOptions $options = null): Respons try { if ($this->client instanceof ClientWithOptionsInterface) { $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $options); + } elseif ($this->isGuzzleClient($this->client)) { + $psr7Response = $this->sendWithGuzzle($psr7Request, $options); } else { $psr7Response = $this->client->sendRequest($psr7Request); } @@ -99,6 +101,122 @@ public function send(Request $request, ?RequestOptions $options = null): Respons return $this->convertFromPsr7Response($psr7Response); } + /** + * Determines if the underlying client matches the Guzzle client shape. + * + * @since n.e.x.t + * + * @param ClientInterface $client The HTTP client instance. + * @return bool True when the client exposes Guzzle's send signature. + */ + private function isGuzzleClient(ClientInterface $client): bool + { + $reflection = new \ReflectionObject($client); + + if (!is_callable([$client, 'send'])) { + return false; + } + + if (!$reflection->hasMethod('send')) { + return false; + } + + $method = $reflection->getMethod('send'); + + if (!$method->isPublic() || $method->isStatic()) { + return false; + } + + $parameters = $method->getParameters(); + + if (count($parameters) < 2) { + return false; + } + + $firstParameter = $parameters[0]->getType(); + if (!$firstParameter instanceof \ReflectionNamedType || $firstParameter->isBuiltin()) { + return false; + } + + if (!is_a($firstParameter->getName(), RequestInterface::class, true)) { + return false; + } + + $secondParameter = $parameters[1]; + $secondType = $secondParameter->getType(); + + if (!$secondType instanceof \ReflectionNamedType || $secondType->getName() !== 'array') { + return false; + } + + return true; + } + + /** + * Sends a request using a Guzzle-compatible client. + * + * @since n.e.x.t + * + * @param RequestInterface $request The PSR-7 request to send. + * @param RequestOptions|null $options The request options. + * @return ResponseInterface The PSR-7 response received. + */ + private function sendWithGuzzle(RequestInterface $request, ?RequestOptions $options): ResponseInterface + { + $guzzleOptions = $this->buildGuzzleOptions($options); + + /** @var callable $callable */ + $callable = [$this->client, 'send']; + + /** @var ResponseInterface $response */ + $response = $callable($request, $guzzleOptions); + + return $response; + } + + /** + * Converts request options to a Guzzle-compatible options array. + * + * @since n.e.x.t + * + * @param RequestOptions|null $options The request options. + * @return array Guzzle-compatible options. + */ + private function buildGuzzleOptions(?RequestOptions $options): array + { + if ($options === null) { + return []; + } + + $guzzleOptions = []; + + $timeout = $options->getTimeout(); + if ($timeout !== null) { + $guzzleOptions['timeout'] = $timeout; + } + + $connectTimeout = $options->getConnectTimeout(); + if ($connectTimeout !== null) { + $guzzleOptions['connect_timeout'] = $connectTimeout; + } + + $allowRedirects = $options->allowsRedirects(); + if ($allowRedirects !== null) { + if ($allowRedirects) { + $redirectOptions = []; + $maxRedirects = $options->getMaxRedirects(); + if ($maxRedirects !== null) { + $redirectOptions['max'] = $maxRedirects; + } + $guzzleOptions['allow_redirects'] = !empty($redirectOptions) ? $redirectOptions : true; + } else { + $guzzleOptions['allow_redirects'] = false; + } + } + + return $guzzleOptions; + } + /** * Converts a custom Request to a PSR-7 request. * diff --git a/tests/mocks/GuzzleLikeClient.php b/tests/mocks/GuzzleLikeClient.php new file mode 100644 index 00000000..e98998a6 --- /dev/null +++ b/tests/mocks/GuzzleLikeClient.php @@ -0,0 +1,93 @@ +|null The last options passed to send. + */ + private ?array $lastOptions = null; + + /** + * @var bool Whether sendRequest was used instead of send. + */ + private bool $sendRequestCalled = false; + + /** + * Constructor. + * + * @param ResponseInterface $response The response to return. + */ + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + /** + * {@inheritDoc} + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->lastRequest = $request; + $this->lastOptions = null; + $this->sendRequestCalled = true; + + return $this->response; + } + + /** + * Emulates Guzzle's send method that accepts options. + * + * @param RequestInterface $request The request being sent. + * @param array $options The request options. + * @return ResponseInterface The response instance. + */ + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + $this->lastRequest = $request; + $this->lastOptions = $options; + $this->sendRequestCalled = false; + + return $this->response; + } + + /** + * Gets the last options provided to the client. + * + * @return array|null The options or null when sendRequest was used. + */ + public function getLastOptions(): ?array + { + return $this->lastOptions; + } + + /** + * Determines whether sendRequest was called instead of send. + * + * @return bool True when sendRequest was called. + */ + public function wasSendRequestCalled(): bool + { + return $this->sendRequestCalled; + } +} diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index 2cc4399e..1407cff8 100644 --- a/tests/unit/Providers/Http/HttpTransporterTest.php +++ b/tests/unit/Providers/Http/HttpTransporterTest.php @@ -9,9 +9,11 @@ use Http\Mock\Client as MockClient; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Http\HttpTransporter; +use WordPress\AiClient\Tests\mocks\GuzzleLikeClient; /** * Tests for HttpTransporter class. @@ -235,6 +237,50 @@ public function testSendPostRequestWithArrayDataAsForm(): void $this->assertEquals('name=test&value=123', (string) $sentRequest->getBody()); } + /** + * Tests that Guzzle-like clients receive request options through the send method. + * + * @covers ::send + * @covers ::buildGuzzleOptions + * @covers ::isGuzzleClient + * + * @return void + */ + public function testSendUsesGuzzleClientOptions(): void + { + $response = new Psr7Response(204); + $guzzleClient = new GuzzleLikeClient($response); + $transporter = new HttpTransporter( + $guzzleClient, + $this->httpFactory, + $this->httpFactory + ); + + $options = (new RequestOptions()) + ->withTimeout(5.0) + ->withConnectTimeout(1.0) + ->withRedirects(3); + + $request = new Request( + HttpMethodEnum::GET(), + 'https://api.example.com/guzzle-test', + [], + null, + $options + ); + + $result = $transporter->send($request); + + $this->assertEquals(204, $result->getStatusCode()); + $this->assertFalse($guzzleClient->wasSendRequestCalled()); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertSame(5.0, $lastOptions['timeout']); + $this->assertSame(1.0, $lastOptions['connect_timeout']); + $this->assertSame(['max' => 3], $lastOptions['allow_redirects']); + } + /** * Tests case-insensitive header access in Request. * From 300f0b9ac4afbf779cd55954ed24953d7bd4070c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 19:35:45 -0600 Subject: [PATCH 04/14] refactor: simplifies logic --- src/Providers/Http/DTO/Request.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index fc15fc68..ccc523bf 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -346,12 +346,9 @@ public function setAllowRedirects(bool $allowRedirects): void { $options = $this->ensureOptions(); - if ($allowRedirects) { - $this->options = $options->withRedirects(); - return; - } - - $this->options = $options->withoutRedirects(); + $this->options = $allowRedirects + ? $options->withRedirects() + : $options->withoutRedirects(); } /** From 9305352119b3b107fd827b4b88afc2d4c24be1d8 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 19:45:06 -0600 Subject: [PATCH 05/14] test: adds missing Request tests --- tests/unit/Providers/Http/DTO/RequestTest.php | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php index 25f73c91..5a6b28b3 100644 --- a/tests/unit/Providers/Http/DTO/RequestTest.php +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -4,7 +4,9 @@ namespace WordPress\AiClient\Tests\unit\Providers\Http\DTO; +use GuzzleHttp\Psr7\Request as Psr7Request; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\InvalidArgumentException as AiInvalidArgumentException; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; @@ -89,4 +91,234 @@ public function testDisablingRedirectsClearsLimit(): void $this->assertFalse($options->allowsRedirects()); $this->assertNull($options->getMaxRedirects()); } + + /** + * Tests that GET requests with array data append data as query parameters. + * + * @return void + */ + public function testGetUriAppendsQueryParametersForGetRequest(): void + { + $request = new Request( + HttpMethodEnum::get(), + 'https://example.com/search', + [], + ['q' => 'php', 'page' => '2'] + ); + + $this->assertSame('https://example.com/search?q=php&page=2', $request->getUri()); + $this->assertNull($request->getBody()); + $this->assertSame(['q' => 'php', 'page' => '2'], $request->getData()); + } + + /** + * Tests JSON body generation when Content-Type is application/json. + * + * @return void + */ + public function testGetBodyEncodesJsonData(): void + { + $request = new Request( + HttpMethodEnum::post(), + 'https://example.com/resources', + ['Content-Type' => 'application/json'], + ['title' => 'Test', 'published' => true] + ); + + $this->assertSame('{"title":"Test","published":true}', $request->getBody()); + $this->assertSame(['title' => 'Test', 'published' => true], $request->getData()); + } + + /** + * Tests form body generation when Content-Type is application/x-www-form-urlencoded. + * + * @return void + */ + public function testGetBodyEncodesFormData(): void + { + $request = new Request( + HttpMethodEnum::post(), + 'https://example.com/resources', + ['Content-Type' => 'application/x-www-form-urlencoded'], + ['name' => 'Example', 'value' => '123'] + ); + + $this->assertSame('name=Example&value=123', $request->getBody()); + } + + /** + * Tests string body pass-through when provided directly. + * + * @return void + */ + public function testGetBodyReturnsExplicitString(): void + { + $request = new Request( + HttpMethodEnum::post(), + 'https://example.com/raw', + ['Content-Type' => 'text/plain'], + 'raw-body' + ); + + $this->assertSame('raw-body', $request->getBody()); + } + + /** + * Tests header access methods are case-insensitive. + * + * @return void + */ + public function testHeaderAccessIsCaseInsensitive(): void + { + $request = new Request( + HttpMethodEnum::get(), + 'https://example.com', + ['X-Test' => ['A', 'B']] + ); + + $this->assertTrue($request->hasHeader('x-test')); + $this->assertSame(['A', 'B'], $request->getHeader('X-TEST')); + $this->assertSame('A, B', $request->getHeaderAsString('x-test')); + } + + /** + * Tests withHeader returns cloned instance with updated header. + * + * @return void + */ + public function testWithHeaderReturnsNewInstance(): void + { + $request = new Request(HttpMethodEnum::get(), 'https://example.com'); + $updated = $request->withHeader('X-New', 'value'); + + $this->assertNotSame($request, $updated); + $this->assertFalse($request->hasHeader('X-New')); + $this->assertSame('value', $updated->getHeaderAsString('X-New')); + } + + /** + * Tests withData toggles between body and data fields. + * + * @return void + */ + public function testWithDataReplacesBodyAndData(): void + { + $request = new Request(HttpMethodEnum::post(), 'https://example.com', [], 'initial-body'); + $requestWithArray = $request->withData(['foo' => 'bar']); + + $this->assertNotSame($request, $requestWithArray); + $this->assertSame(['foo' => 'bar'], $requestWithArray->getData()); + $this->assertSame('foo=bar', $requestWithArray->getBody()); + + $requestWithString = $requestWithArray->withData('string-body'); + $this->assertSame('string-body', $requestWithString->getBody()); + $this->assertNull($requestWithString->getData()); + } + + /** + * Tests toArray includes headers, body, and options when present. + * + * @return void + */ + public function testToArrayIncludesBodyAndOptions(): void + { + $options = (new RequestOptions()) + ->withTimeout(1.0) + ->withRedirects(2); + + $request = new Request( + HttpMethodEnum::post(), + 'https://example.com', + ['Content-Type' => 'application/json'], + ['key' => 'value'], + $options + ); + + $array = $request->toArray(); + + $this->assertSame(HttpMethodEnum::post()->value, $array[Request::KEY_METHOD]); + $this->assertSame('https://example.com', $array[Request::KEY_URI]); + $this->assertSame(['application/json'], $array[Request::KEY_HEADERS]['Content-Type']); + $this->assertSame('{"key":"value"}', $array[Request::KEY_BODY]); + $this->assertSame( + ['timeout' => 1.0, 'allowRedirects' => true, 'maxRedirects' => 2], + $array[Request::KEY_OPTIONS] + ); + } + + /** + * Tests fromArray creates a request instance including options when supplied. + * + * @return void + */ + public function testFromArrayRestoresRequestAndOptions(): void + { + $array = [ + Request::KEY_METHOD => HttpMethodEnum::post()->value, + Request::KEY_URI => 'https://example.com', + Request::KEY_HEADERS => ['Accept' => ['application/json']], + Request::KEY_BODY => 'payload', + Request::KEY_OPTIONS => [ + RequestOptions::KEY_TIMEOUT => 4.0, + RequestOptions::KEY_ALLOW_REDIRECTS => true, + RequestOptions::KEY_MAX_REDIRECTS => 1, + ], + ]; + + $request = Request::fromArray($array); + + $this->assertSame('payload', $request->getBody()); + $options = $request->getOptions(); + $this->assertInstanceOf(RequestOptions::class, $options); + $this->assertSame(4.0, $options->getTimeout()); + $this->assertTrue($options->allowsRedirects()); + $this->assertSame(1, $options->getMaxRedirects()); + } + + /** + * Tests fromArray works without options. + * + * @return void + */ + public function testFromArrayWithoutOptionsLeavesOptionsNull(): void + { + $array = [ + Request::KEY_METHOD => HttpMethodEnum::get()->value, + Request::KEY_URI => 'https://example.com', + Request::KEY_HEADERS => [], + ]; + + $request = Request::fromArray($array); + + $this->assertNull($request->getOptions()); + } + + /** + * Tests fromPsrRequest converts PSR-7 request into DTO. + * + * @return void + */ + public function testFromPsrRequest(): void + { + $psrRequest = (new Psr7Request('POST', 'https://example.com', ['Content-Type' => 'text/plain'], 'body')) + ->withAddedHeader('X-Test', 'value'); + + $request = Request::fromPsrRequest($psrRequest); + + $this->assertSame(HttpMethodEnum::post()->value, $request->getMethod()->value); + $this->assertSame('https://example.com', $request->getUri()); + $this->assertSame('body', $request->getBody()); + $this->assertSame(['value'], $request->getHeader('X-Test')); + } + + /** + * Ensures constructor throws when URI is empty. + * + * @return void + */ + public function testConstructorThrowsWhenUriIsEmpty(): void + { + $this->expectException(AiInvalidArgumentException::class); + new Request(HttpMethodEnum::get(), ''); + } } From 164c0ec1cbb9a8e076f29d70b26b5d16ec79292f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 14 Oct 2025 20:01:23 -0600 Subject: [PATCH 06/14] chore: updates architecture doc --- docs/ARCHITECTURE.md | 57 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c18ec4e..dc5751ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -794,7 +794,9 @@ This section describes the HTTP communication architecture that differs from the 4. **PSR Compliance**: The transporter uses PSR-7 (HTTP messages), PSR-17 (HTTP factories), and PSR-18 (HTTP client) internally 5. **No Direct Coupling**: The library remains decoupled from any specific HTTP client implementation 6. **Provider Domain Location**: HTTP components are located within the Providers domain (`src/Providers/Http/`) as they are provider-specific infrastructure -7. **Synchronous Only**: Currently supports only synchronous HTTP requests. Async support may be added in the future if needed +7. **Per-request Transport Options**: Request-specific transport settings flow through a `RequestOptions` DTO, allowing callers to control timeouts and redirect handling on a per-request basis +8. **Extensible Client Support**: HTTP clients can opt into receiving request options by implementing `ClientWithOptionsInterface`, and the transporter automatically bridges well-known client shapes such as Guzzle's `send($request, array $options)` signature +9. **Synchronous Only**: Currently supports only synchronous HTTP requests. Async support may be added in the future if needed ### HTTP Communication Flow @@ -802,19 +804,33 @@ This section describes the HTTP communication architecture that differs from the sequenceDiagram participant Model participant HttpTransporter + participant RequestOptions participant PSR17Factory - participant PSR18Client - - Model->>HttpTransporter: send(Request) + participant Client + + Model->>HttpTransporter: send(Request, ?RequestOptions) + HttpTransporter-->>RequestOptions: buildOptions(Request) HttpTransporter->>PSR17Factory: createRequest(Request) PSR17Factory-->>HttpTransporter: PSR-7 Request - HttpTransporter->>PSR18Client: sendRequest(PSR-7 Request) - PSR18Client-->>HttpTransporter: PSR-7 Response + alt Client implements ClientWithOptionsInterface + HttpTransporter->>Client: sendRequestWithOptions(PSR-7 Request, RequestOptions) + else Client has Guzzle send signature + HttpTransporter->>Client: send(PSR-7 Request, guzzleOptions) + else Plain PSR-18 client + HttpTransporter->>Client: sendRequest(PSR-7 Request) + end + Client-->>HttpTransporter: PSR-7 Response HttpTransporter->>PSR17Factory: parseResponse(PSR-7 Response) PSR17Factory-->>HttpTransporter: Response HttpTransporter-->>Model: Response ``` +Whenever request options are present, the transporter enriches the PSR-18 call path: it translates the `RequestOptions` DTO into the client’s native format. Clients that implement `ClientWithOptionsInterface` receive the DTO directly, while Guzzle-style clients are detected through reflection and receive an options array (e.g., `timeout`, `connect_timeout`, `allow_redirects`). + +### ClientWithOptionsInterface + +`ClientWithOptionsInterface` is a lightweight extension point for HTTP clients that already support per-request configuration. By implementing it, a client (for example, a wrapper around Guzzle or the WordPress AI Client’s richer transporter) can accept a `RequestOptions` instance directly through `sendRequestWithOptions()`. The transporter prefers this pathway, falling back to Guzzle detection or plain PSR-18 `sendRequest()` when the interface is not implemented, keeping the core agnostic while still allowing rich integrations. + ### Details: Class diagram for AI extenders @@ -889,7 +905,10 @@ direction LR namespace AiClientNamespace.Providers.Http.Contracts { class HttpTransporterInterface { - +send(Request $request) Response + +send(Request $request, ?RequestOptions $options) Response + } + interface ClientWithOptionsInterface { + +sendRequestWithOptions(RequestInterface $request, RequestOptions $options) ResponseInterface } class RequestAuthenticationInterface { +authenticateRequest(Request $request) Request @@ -912,6 +931,30 @@ direction LR +getHeaders() array< string, string[] > +getBody() ?string +getData() ?array< string, mixed > + +getOptions() ?RequestOptions + +setTimeout(?float $timeout) void + +setConnectTimeout(?float $timeout) void + +setAllowRedirects(bool $allowRedirects) void + +setMaxRedirects(?int $maxRedirects) void + +withHeader(string $name, string|list< string > $value) self + +withData(string|array< string, mixed > $data) self + +withOptions(?RequestOptions $options) self + +toArray() array< string, mixed > + +getJsonSchema() array< string, mixed >$ + +fromArray(array< string, mixed > $array) self$ + +fromPsrRequest(RequestInterface $psrRequest) self$ + } + class RequestOptions { + +withTimeout(?float $timeout) self + +withConnectTimeout(?float $timeout) self + +withRedirects(?int $maxRedirects) self + +withoutRedirects() self + +withMaxRedirects(?int $maxRedirects) self + +getTimeout() ?float + +getConnectTimeout() ?float + +allowsRedirects() ?bool + +getMaxRedirects() ?int + +toArray() array< string, mixed > +getJsonSchema() array< string, mixed >$ } From 88b0075d0e64d53ce6392bd654b530ecb0b9fa25 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 15 Oct 2025 16:54:50 -0600 Subject: [PATCH 07/14] refactor: removes pass-through methods --- src/Providers/Http/DTO/Request.php | 71 ------------------- tests/unit/Providers/Http/DTO/RequestTest.php | 44 ------------ 2 files changed, 115 deletions(-) diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index ccc523bf..57d5151d 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -309,77 +309,6 @@ public function getOptions(): ?RequestOptions return $this->options; } - /** - * Sets the request timeout in seconds. - * - * @since n.e.x.t - * - * @param float|null $timeout Timeout in seconds. - */ - public function setTimeout(?float $timeout): void - { - $options = $this->ensureOptions(); - $this->options = $options->withTimeout($timeout); - } - - /** - * Sets the connection timeout in seconds. - * - * @since n.e.x.t - * - * @param float|null $timeout Connection timeout in seconds. - */ - public function setConnectTimeout(?float $timeout): void - { - $options = $this->ensureOptions(); - $this->options = $options->withConnectTimeout($timeout); - } - - /** - * Sets whether redirects are automatically followed. - * - * @since n.e.x.t - * - * @param bool $allowRedirects Whether redirects should be followed. - */ - public function setAllowRedirects(bool $allowRedirects): void - { - $options = $this->ensureOptions(); - - $this->options = $allowRedirects - ? $options->withRedirects() - : $options->withoutRedirects(); - } - - /** - * Sets the maximum number of redirects to follow. - * - * @since n.e.x.t - * - * @param int|null $maxRedirects Maximum redirects when enabled. - */ - public function setMaxRedirects(?int $maxRedirects): void - { - $options = $this->ensureOptions(); - $this->options = $options->withMaxRedirects($maxRedirects); - } - - /** - * Ensures request options instance exists. - * - * @since n.e.x.t - * - * @return RequestOptions The ensured request options instance. - */ - private function ensureOptions(): RequestOptions - { - if ($this->options === null) { - $this->options = new RequestOptions(); - } - - return $this->options; - } - /** * Returns a new instance with the specified request options. * diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php index 5a6b28b3..6454ec88 100644 --- a/tests/unit/Providers/Http/DTO/RequestTest.php +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -48,50 +48,6 @@ public function testWithOptionsStoresProvidedOptions(): void $this->assertNull($request->getOptions()); } - /** - * Tests that convenience setters lazily instantiate request options. - * - * @return void - */ - public function testSettersInstantiateRequestOptions(): void - { - $request = new Request(HttpMethodEnum::post(), 'https://example.com'); - $this->assertNull($request->getOptions()); - - $request->setTimeout(2.0); - $request->setConnectTimeout(1.0); - $request->setAllowRedirects(true); - $request->setMaxRedirects(5); - - $options = $request->getOptions(); - $this->assertInstanceOf(RequestOptions::class, $options); - $this->assertSame(2.0, $options->getTimeout()); - $this->assertSame(1.0, $options->getConnectTimeout()); - $this->assertTrue($options->allowsRedirects()); - $this->assertSame(5, $options->getMaxRedirects()); - - $array = $request->toArray(); - $this->assertArrayHasKey(Request::KEY_OPTIONS, $array); - $this->assertArrayHasKey(RequestOptions::KEY_TIMEOUT, $array[Request::KEY_OPTIONS]); - } - - /** - * Tests that disabling redirects clears the limit serialized in toArray. - * - * @return void - */ - public function testDisablingRedirectsClearsLimit(): void - { - $request = new Request(HttpMethodEnum::post(), 'https://example.com'); - $request->setAllowRedirects(true); - $request->setMaxRedirects(3); - $request->setAllowRedirects(false); - - $options = $request->getOptions(); - $this->assertFalse($options->allowsRedirects()); - $this->assertNull($options->getMaxRedirects()); - } - /** * Tests that GET requests with array data append data as query parameters. * From ba6cf361d6d5856f730714bc6383ac27a560f9a7 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 15 Oct 2025 17:00:48 -0600 Subject: [PATCH 08/14] refactor: change RequestOptions back to mutable DTO --- docs/ARCHITECTURE.md | 13 +- src/Providers/Http/DTO/RequestOptions.php | 144 +++++++----------- .../Providers/Http/DTO/RequestOptionsTest.php | 37 ++--- tests/unit/Providers/Http/DTO/RequestTest.php | 10 +- .../Providers/Http/HttpTransporterTest.php | 9 +- 5 files changed, 86 insertions(+), 127 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc5751ba..e310b357 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -932,10 +932,6 @@ direction LR +getBody() ?string +getData() ?array< string, mixed > +getOptions() ?RequestOptions - +setTimeout(?float $timeout) void - +setConnectTimeout(?float $timeout) void - +setAllowRedirects(bool $allowRedirects) void - +setMaxRedirects(?int $maxRedirects) void +withHeader(string $name, string|list< string > $value) self +withData(string|array< string, mixed > $data) self +withOptions(?RequestOptions $options) self @@ -945,11 +941,10 @@ direction LR +fromPsrRequest(RequestInterface $psrRequest) self$ } class RequestOptions { - +withTimeout(?float $timeout) self - +withConnectTimeout(?float $timeout) self - +withRedirects(?int $maxRedirects) self - +withoutRedirects() self - +withMaxRedirects(?int $maxRedirects) self + +setTimeout(?float $timeout) void + +setConnectTimeout(?float $timeout) void + +setAllowRedirects(bool $allowRedirects) void + +setMaxRedirects(?int $maxRedirects) void +getTimeout() ?float +getConnectTimeout() ?float +allowsRedirects() ?bool diff --git a/src/Providers/Http/DTO/RequestOptions.php b/src/Providers/Http/DTO/RequestOptions.php index b87b302b..60d9e854 100644 --- a/src/Providers/Http/DTO/RequestOptions.php +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -10,7 +10,7 @@ /** * Represents optional HTTP transport configuration for a single request. * - * Provides immutable helpers for working with timeouts and redirect handling. + * Provides mutable setters for working with timeouts and redirect handling. * * @since n.e.x.t * @@ -33,140 +33,92 @@ class RequestOptions extends AbstractDataTransferObject /** * @var float|null Maximum duration in seconds to wait for the full response. */ - protected ?float $timeout; + protected ?float $timeout = null; /** * @var float|null Maximum duration in seconds to wait for the initial connection. */ - protected ?float $connectTimeout; + protected ?float $connectTimeout = null; /** * @var bool|null Whether HTTP redirects should be automatically followed. */ - protected ?bool $allowRedirects; + protected ?bool $allowRedirects = null; /** * @var int|null Maximum number of redirects to follow when enabled. */ - protected ?int $maxRedirects; + protected ?int $maxRedirects = null; /** - * Constructor. - * - * @since n.e.x.t - * - * @param float|null $timeout Maximum duration in seconds to wait for the response. - * @param float|null $connectTimeout Maximum duration in seconds to wait for the connection. - * @param bool|null $allowRedirects Whether redirects should be followed. - * @param int|null $maxRedirects Maximum redirects to follow when enabled. - * - * @throws InvalidArgumentException When timeout values or redirect limits are invalid. - */ - public function __construct( - ?float $timeout = null, - ?float $connectTimeout = null, - ?bool $allowRedirects = null, - ?int $maxRedirects = null - ) { - $this->validateTimeout($timeout, self::KEY_TIMEOUT); - $this->validateTimeout($connectTimeout, self::KEY_CONNECT_TIMEOUT); - $this->validateRedirectLimit($allowRedirects, $maxRedirects); - - $this->timeout = $timeout; - $this->connectTimeout = $connectTimeout; - $this->allowRedirects = $allowRedirects; - $this->maxRedirects = $allowRedirects === true ? $maxRedirects : null; - } - - /** - * Returns a new instance with an updated request timeout. + * Sets the request timeout in seconds. * * @since n.e.x.t * * @param float|null $timeout Timeout in seconds. - * @return self Options instance with updated timeout. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. */ - public function withTimeout(?float $timeout): self + public function setTimeout(?float $timeout): void { $this->validateTimeout($timeout, self::KEY_TIMEOUT); - - $clone = clone $this; - $clone->timeout = $timeout; - return $clone; + $this->timeout = $timeout; } /** - * Returns a new instance with an updated connection timeout. + * Sets the connection timeout in seconds. * * @since n.e.x.t * * @param float|null $timeout Connection timeout in seconds. - * @return self Options instance with updated connection timeout. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. */ - public function withConnectTimeout(?float $timeout): self + public function setConnectTimeout(?float $timeout): void { $this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT); - - $clone = clone $this; - $clone->connectTimeout = $timeout; - return $clone; + $this->connectTimeout = $timeout; } /** - * Returns a new instance with redirects enabled. + * Sets whether redirects should be automatically followed. * * @since n.e.x.t * - * @param int|null $maxRedirects Maximum redirects to follow, or null to leave unspecified. - * @return self Options instance with redirects enabled. + * @param bool $allowRedirects Whether redirects should be followed. + * @return void */ - public function withRedirects(?int $maxRedirects = null): self + public function setAllowRedirects(bool $allowRedirects): void { - $limit = $maxRedirects ?? $this->maxRedirects; - $this->validateRedirectLimit(true, $limit); + $this->allowRedirects = $allowRedirects; - $clone = clone $this; - $clone->allowRedirects = true; - $clone->maxRedirects = $limit; - return $clone; + // Clear maxRedirects when disabling redirects + if ($allowRedirects === false) { + $this->maxRedirects = null; + } } /** - * Returns a new instance with redirects disabled. + * Sets the maximum number of redirects to follow. * * @since n.e.x.t * - * @return self Options instance with redirects disabled. - */ - public function withoutRedirects(): self - { - $clone = clone $this; - $clone->allowRedirects = false; - $clone->maxRedirects = null; - return $clone; - } - - /** - * Returns a new instance with an updated redirect limit. - * - * @since n.e.x.t + * @param int|null $maxRedirects Maximum redirects to follow when enabled. + * @return void * - * @param int|null $maxRedirects Maximum redirects to follow. - * @return self Options instance with updated redirect limit. + * @throws InvalidArgumentException When redirect count is invalid. */ - public function withMaxRedirects(?int $maxRedirects): self + public function setMaxRedirects(?int $maxRedirects): void { $this->validateRedirectLimit($this->allowRedirects, $maxRedirects); - $clone = clone $this; - if ($this->allowRedirects === true) { - $clone->maxRedirects = $maxRedirects; + $this->maxRedirects = $maxRedirects; } else { - $clone->maxRedirects = null; + $this->maxRedirects = null; } - - return $clone; } /** @@ -254,17 +206,25 @@ public function toArray(): array */ public static function fromArray(array $array): self { - $timeout = $array[self::KEY_TIMEOUT] ?? null; - $connectTimeout = $array[self::KEY_CONNECT_TIMEOUT] ?? null; - $allowRedirects = $array[self::KEY_ALLOW_REDIRECTS] ?? null; - $maxRedirects = $array[self::KEY_MAX_REDIRECTS] ?? null; - - return new self( - $timeout !== null ? (float) $timeout : null, - $connectTimeout !== null ? (float) $connectTimeout : null, - $allowRedirects !== null ? (bool) $allowRedirects : null, - $maxRedirects !== null ? (int) $maxRedirects : null - ); + $instance = new self(); + + if (isset($array[self::KEY_TIMEOUT])) { + $instance->setTimeout((float) $array[self::KEY_TIMEOUT]); + } + + if (isset($array[self::KEY_CONNECT_TIMEOUT])) { + $instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]); + } + + if (isset($array[self::KEY_ALLOW_REDIRECTS])) { + $instance->setAllowRedirects((bool) $array[self::KEY_ALLOW_REDIRECTS]); + } + + if (isset($array[self::KEY_MAX_REDIRECTS])) { + $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]); + } + + return $instance; } /** diff --git a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php index 54c2b5e4..d79acdf6 100644 --- a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -18,7 +18,7 @@ class RequestOptionsTest extends TestCase * * @return void */ - public function testConstructorDefaultsToNullValues(): void + public function testDefaultsToNullValues(): void { $options = new RequestOptions(); @@ -30,18 +30,16 @@ public function testConstructorDefaultsToNullValues(): void } /** - * Tests immutable helpers modify the cloned instance only. + * Tests mutable setters modify the same instance. * * @return void */ - public function testWithTimeoutReturnsUpdatedClone(): void + public function testSetTimeoutModifiesInstance(): void { $options = new RequestOptions(); - $updated = $options->withTimeout(5.0); + $options->setTimeout(5.0); - $this->assertNotSame($options, $updated); - $this->assertNull($options->getTimeout()); - $this->assertSame(5.0, $updated->getTimeout()); + $this->assertSame(5.0, $options->getTimeout()); } /** @@ -49,13 +47,14 @@ public function testWithTimeoutReturnsUpdatedClone(): void * * @return void */ - public function testWithRedirectsSetsFlagsAndLimit(): void + public function testSetAllowRedirectsEnablesRedirects(): void { $options = new RequestOptions(); - $updated = $options->withRedirects(3); + $options->setAllowRedirects(true); + $options->setMaxRedirects(3); - $this->assertTrue($updated->allowsRedirects()); - $this->assertSame(3, $updated->getMaxRedirects()); + $this->assertTrue($options->allowsRedirects()); + $this->assertSame(3, $options->getMaxRedirects()); } /** @@ -63,13 +62,15 @@ public function testWithRedirectsSetsFlagsAndLimit(): void * * @return void */ - public function testWithoutRedirectsClearsRedirectLimit(): void + public function testSetAllowRedirectsFalseClearsRedirectLimit(): void { - $options = (new RequestOptions())->withRedirects(4); - $disabled = $options->withoutRedirects(); + $options = new RequestOptions(); + $options->setAllowRedirects(true); + $options->setMaxRedirects(4); + $options->setAllowRedirects(false); - $this->assertFalse($disabled->allowsRedirects()); - $this->assertNull($disabled->getMaxRedirects()); + $this->assertFalse($options->allowsRedirects()); + $this->assertNull($options->getMaxRedirects()); } /** @@ -77,12 +78,12 @@ public function testWithoutRedirectsClearsRedirectLimit(): void * * @return void */ - public function testWithMaxRedirectsThrowsWhenRedirectsDisabled(): void + public function testSetMaxRedirectsThrowsWhenRedirectsDisabled(): void { $options = new RequestOptions(); $this->expectException(InvalidArgumentException::class); - $options->withMaxRedirects(2); + $options->setMaxRedirects(2); } /** diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php index 6454ec88..9762a756 100644 --- a/tests/unit/Providers/Http/DTO/RequestTest.php +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -39,7 +39,8 @@ public function testOptionsDefaultToNull(): void public function testWithOptionsStoresProvidedOptions(): void { $request = new Request(HttpMethodEnum::post(), 'https://example.com'); - $options = (new RequestOptions())->withTimeout(1.5); + $options = new RequestOptions(); + $options->setTimeout(1.5); $updated = $request->withOptions($options); @@ -178,9 +179,10 @@ public function testWithDataReplacesBodyAndData(): void */ public function testToArrayIncludesBodyAndOptions(): void { - $options = (new RequestOptions()) - ->withTimeout(1.0) - ->withRedirects(2); + $options = new RequestOptions(); + $options->setTimeout(1.0); + $options->setAllowRedirects(true); + $options->setMaxRedirects(2); $request = new Request( HttpMethodEnum::post(), diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index 1407cff8..ad56e6b9 100644 --- a/tests/unit/Providers/Http/HttpTransporterTest.php +++ b/tests/unit/Providers/Http/HttpTransporterTest.php @@ -256,10 +256,11 @@ public function testSendUsesGuzzleClientOptions(): void $this->httpFactory ); - $options = (new RequestOptions()) - ->withTimeout(5.0) - ->withConnectTimeout(1.0) - ->withRedirects(3); + $options = new RequestOptions(); + $options->setTimeout(5.0); + $options->setConnectTimeout(1.0); + $options->setAllowRedirects(true); + $options->setMaxRedirects(3); $request = new Request( HttpMethodEnum::GET(), From 65513527e5a32b70ee3de484c175442465f564e7 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 15 Oct 2025 17:07:20 -0600 Subject: [PATCH 09/14] refactor: absorbs allow redirect into max redirect --- docs/ARCHITECTURE.md | 1 - src/Providers/Http/DTO/RequestOptions.php | 99 ++++--------------- .../Providers/Http/DTO/RequestOptionsTest.php | 24 ++--- tests/unit/Providers/Http/DTO/RequestTest.php | 4 +- .../Providers/Http/HttpTransporterTest.php | 1 - 5 files changed, 32 insertions(+), 97 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e310b357..6f360233 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -943,7 +943,6 @@ direction LR class RequestOptions { +setTimeout(?float $timeout) void +setConnectTimeout(?float $timeout) void - +setAllowRedirects(bool $allowRedirects) void +setMaxRedirects(?int $maxRedirects) void +getTimeout() ?float +getConnectTimeout() ?float diff --git a/src/Providers/Http/DTO/RequestOptions.php b/src/Providers/Http/DTO/RequestOptions.php index 60d9e854..b4f06720 100644 --- a/src/Providers/Http/DTO/RequestOptions.php +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -17,7 +17,6 @@ * @phpstan-type RequestOptionsArrayShape array{ * timeout?: float|null, * connectTimeout?: float|null, - * allowRedirects?: bool|null, * maxRedirects?: int|null * } * @@ -27,7 +26,6 @@ class RequestOptions extends AbstractDataTransferObject { public const KEY_TIMEOUT = 'timeout'; public const KEY_CONNECT_TIMEOUT = 'connectTimeout'; - public const KEY_ALLOW_REDIRECTS = 'allowRedirects'; public const KEY_MAX_REDIRECTS = 'maxRedirects'; /** @@ -41,12 +39,7 @@ class RequestOptions extends AbstractDataTransferObject protected ?float $connectTimeout = null; /** - * @var bool|null Whether HTTP redirects should be automatically followed. - */ - protected ?bool $allowRedirects = null; - - /** - * @var int|null Maximum number of redirects to follow when enabled. + * @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified. */ protected ?int $maxRedirects = null; @@ -82,43 +75,28 @@ public function setConnectTimeout(?float $timeout): void $this->connectTimeout = $timeout; } - /** - * Sets whether redirects should be automatically followed. - * - * @since n.e.x.t - * - * @param bool $allowRedirects Whether redirects should be followed. - * @return void - */ - public function setAllowRedirects(bool $allowRedirects): void - { - $this->allowRedirects = $allowRedirects; - - // Clear maxRedirects when disabling redirects - if ($allowRedirects === false) { - $this->maxRedirects = null; - } - } - /** * Sets the maximum number of redirects to follow. * + * Set to 0 to disable redirects, null for unspecified, or a positive integer + * to enable redirects with a maximum count. + * * @since n.e.x.t * - * @param int|null $maxRedirects Maximum redirects to follow when enabled. + * @param int|null $maxRedirects Maximum redirects to follow, or 0 to disable, or null for unspecified. * @return void * - * @throws InvalidArgumentException When redirect count is invalid. + * @throws InvalidArgumentException When redirect count is negative. */ public function setMaxRedirects(?int $maxRedirects): void { - $this->validateRedirectLimit($this->allowRedirects, $maxRedirects); - - if ($this->allowRedirects === true) { - $this->maxRedirects = $maxRedirects; - } else { - $this->maxRedirects = null; + if ($maxRedirects !== null && $maxRedirects < 0) { + throw new InvalidArgumentException( + 'Request option "maxRedirects" must be greater than or equal to 0.' + ); } + + $this->maxRedirects = $maxRedirects; } /** @@ -150,11 +128,17 @@ public function getConnectTimeout(): ?float * * @since n.e.x.t * - * @return bool|null True when redirects are allowed, false when disabled, null when unspecified. + * @return bool|null True when redirects are allowed (maxRedirects > 0), + * false when disabled (maxRedirects = 0), + * null when unspecified (maxRedirects = null). */ public function allowsRedirects(): ?bool { - return $this->allowRedirects; + if ($this->maxRedirects === null) { + return null; + } + + return $this->maxRedirects > 0; } /** @@ -188,10 +172,6 @@ public function toArray(): array $data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout; } - if ($this->allowRedirects !== null) { - $data[self::KEY_ALLOW_REDIRECTS] = $this->allowRedirects; - } - if ($this->maxRedirects !== null) { $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects; } @@ -216,10 +196,6 @@ public static function fromArray(array $array): self $instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]); } - if (isset($array[self::KEY_ALLOW_REDIRECTS])) { - $instance->setAllowRedirects((bool) $array[self::KEY_ALLOW_REDIRECTS]); - } - if (isset($array[self::KEY_MAX_REDIRECTS])) { $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]); } @@ -247,14 +223,10 @@ public static function getJsonSchema(): array 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the initial connection.', ], - self::KEY_ALLOW_REDIRECTS => [ - 'type' => ['boolean', 'null'], - 'description' => 'Whether HTTP redirects should be automatically followed.', - ], self::KEY_MAX_REDIRECTS => [ 'type' => ['integer', 'null'], 'minimum' => 0, - 'description' => 'Maximum number of redirects to follow when enabled.', + 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.', ], ], 'additionalProperties' => false, @@ -279,33 +251,4 @@ private function validateTimeout(?float $value, string $fieldName): void ); } } - - /** - * Validates redirect configuration. - * - * @since n.e.x.t - * - * @param bool|null $allowRedirects Whether redirects are enabled. - * @param int|null $maxRedirects Maximum redirects. - * - * @throws InvalidArgumentException When redirect count is invalid. - */ - private function validateRedirectLimit(?bool $allowRedirects, ?int $maxRedirects): void - { - if ($allowRedirects === true) { - if ($maxRedirects !== null && $maxRedirects < 0) { - throw new InvalidArgumentException( - 'Request option "maxRedirects" must be greater than or equal to 0 when redirects are enabled.' - ); - } - - return; - } - - if ($maxRedirects !== null) { - throw new InvalidArgumentException( - 'Request option "maxRedirects" can only be set when redirects are enabled.' - ); - } - } } diff --git a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php index d79acdf6..12f77446 100644 --- a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -47,10 +47,9 @@ public function testSetTimeoutModifiesInstance(): void * * @return void */ - public function testSetAllowRedirectsEnablesRedirects(): void + public function testSetMaxRedirectsEnablesRedirects(): void { $options = new RequestOptions(); - $options->setAllowRedirects(true); $options->setMaxRedirects(3); $this->assertTrue($options->allowsRedirects()); @@ -58,44 +57,41 @@ public function testSetAllowRedirectsEnablesRedirects(): void } /** - * Tests disabling redirects clears the maximum. + * Tests disabling redirects by setting maxRedirects to 0. * * @return void */ - public function testSetAllowRedirectsFalseClearsRedirectLimit(): void + public function testSetMaxRedirectsToZeroDisablesRedirects(): void { $options = new RequestOptions(); - $options->setAllowRedirects(true); - $options->setMaxRedirects(4); - $options->setAllowRedirects(false); + $options->setMaxRedirects(0); $this->assertFalse($options->allowsRedirects()); - $this->assertNull($options->getMaxRedirects()); + $this->assertSame(0, $options->getMaxRedirects()); } /** - * Tests validation when attempting to set a redirect limit while redirects are not enabled. + * Tests validation when attempting to set a negative redirect limit. * * @return void */ - public function testSetMaxRedirectsThrowsWhenRedirectsDisabled(): void + public function testSetMaxRedirectsThrowsWhenNegative(): void { $options = new RequestOptions(); $this->expectException(InvalidArgumentException::class); - $options->setMaxRedirects(2); + $options->setMaxRedirects(-1); } /** - * Tests that the JSON schema reflects nullable redirect flag. + * Tests that the JSON schema reflects nullable maxRedirects. * * @return void */ - public function testGetJsonSchemaDefinesNullableRedirectFlag(): void + public function testGetJsonSchemaDefinesNullableMaxRedirects(): void { $schema = RequestOptions::getJsonSchema(); - $this->assertSame(['boolean', 'null'], $schema['properties'][RequestOptions::KEY_ALLOW_REDIRECTS]['type']); $this->assertSame(['integer', 'null'], $schema['properties'][RequestOptions::KEY_MAX_REDIRECTS]['type']); } } diff --git a/tests/unit/Providers/Http/DTO/RequestTest.php b/tests/unit/Providers/Http/DTO/RequestTest.php index 9762a756..d004678c 100644 --- a/tests/unit/Providers/Http/DTO/RequestTest.php +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -181,7 +181,6 @@ public function testToArrayIncludesBodyAndOptions(): void { $options = new RequestOptions(); $options->setTimeout(1.0); - $options->setAllowRedirects(true); $options->setMaxRedirects(2); $request = new Request( @@ -199,7 +198,7 @@ public function testToArrayIncludesBodyAndOptions(): void $this->assertSame(['application/json'], $array[Request::KEY_HEADERS]['Content-Type']); $this->assertSame('{"key":"value"}', $array[Request::KEY_BODY]); $this->assertSame( - ['timeout' => 1.0, 'allowRedirects' => true, 'maxRedirects' => 2], + ['timeout' => 1.0, 'maxRedirects' => 2], $array[Request::KEY_OPTIONS] ); } @@ -218,7 +217,6 @@ public function testFromArrayRestoresRequestAndOptions(): void Request::KEY_BODY => 'payload', Request::KEY_OPTIONS => [ RequestOptions::KEY_TIMEOUT => 4.0, - RequestOptions::KEY_ALLOW_REDIRECTS => true, RequestOptions::KEY_MAX_REDIRECTS => 1, ], ]; diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index ad56e6b9..19ff2df2 100644 --- a/tests/unit/Providers/Http/HttpTransporterTest.php +++ b/tests/unit/Providers/Http/HttpTransporterTest.php @@ -259,7 +259,6 @@ public function testSendUsesGuzzleClientOptions(): void $options = new RequestOptions(); $options->setTimeout(5.0); $options->setConnectTimeout(1.0); - $options->setAllowRedirects(true); $options->setMaxRedirects(3); $request = new Request( From 90e01552f037fef981abe7b944577d4e7c96a2f4 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 15 Oct 2025 17:12:33 -0600 Subject: [PATCH 10/14] refactor: removes request options from ModelConfig --- src/Providers/Models/DTO/ModelConfig.php | 49 +------------------ .../Providers/Models/Enums/OptionEnumTest.php | 3 -- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index 8365d196..f0a88d59 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -9,7 +9,6 @@ use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; -use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -24,7 +23,6 @@ * * @phpstan-import-type FunctionDeclarationArrayShape from FunctionDeclaration * @phpstan-import-type WebSearchArrayShape from WebSearch - * @phpstan-import-type RequestOptionsArrayShape from RequestOptions * * @phpstan-type ModelConfigArrayShape array{ * outputModalities?: list, @@ -47,8 +45,7 @@ * outputMediaOrientation?: string, * outputMediaAspectRatio?: string, * outputSpeechVoice?: string, - * customOptions?: array, - * requestOptions?: RequestOptionsArrayShape + * customOptions?: array * } * * @extends AbstractDataTransferObject @@ -76,7 +73,6 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio'; public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice'; public const KEY_CUSTOM_OPTIONS = 'customOptions'; - public const KEY_REQUEST_OPTIONS = 'requestOptions'; /* * Note: This key is not an actual model config key, but specified here for convenience. @@ -190,11 +186,6 @@ class ModelConfig extends AbstractDataTransferObject */ protected array $customOptions = []; - /** - * @var RequestOptions|null HTTP request options. - */ - protected ?RequestOptions $requestOptions = null; - /** * Sets the output modalities. * @@ -745,30 +736,6 @@ public function getCustomOptions(): array return $this->customOptions; } - /** - * Sets the HTTP request options for all transporter calls. - * - * @since n.e.x.t - * - * @param RequestOptions $requestOptions Request options to apply. - */ - public function setRequestOptions(RequestOptions $requestOptions): void - { - $this->requestOptions = $requestOptions; - } - - /** - * Gets the HTTP request options. - * - * @since n.e.x.t - * - * @return RequestOptions|null Request options when configured, null otherwise. - */ - public function getRequestOptions(): ?RequestOptions - { - return $this->requestOptions; - } - /** * {@inheritDoc} * @@ -881,9 +848,6 @@ public static function getJsonSchema(): array 'additionalProperties' => true, 'description' => 'Custom provider-specific options.', ], - self::KEY_REQUEST_OPTIONS => RequestOptions::getJsonSchema() + [ - 'description' => 'HTTP request transport options.', - ], ], 'additionalProperties' => false, ]; @@ -994,13 +958,6 @@ static function (FunctionDeclaration $function_declaration): array { $data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions; } - if ($this->requestOptions !== null) { - $requestOptions = $this->requestOptions->toArray(); - if (!empty($requestOptions)) { - $data[self::KEY_REQUEST_OPTIONS] = $requestOptions; - } - } - return $data; } @@ -1106,10 +1063,6 @@ static function (array $function_declaration_data): FunctionDeclaration { $config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]); } - if (isset($array[self::KEY_REQUEST_OPTIONS])) { - $config->setRequestOptions(RequestOptions::fromArray($array[self::KEY_REQUEST_OPTIONS])); - } - return $config; } } diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index b9b0b55a..9248a9ad 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -58,7 +58,6 @@ protected function getExpectedValues(): array 'OUTPUT_MEDIA_ASPECT_RATIO' => 'outputMediaAspectRatio', 'OUTPUT_SPEECH_VOICE' => 'outputSpeechVoice', 'CUSTOM_OPTIONS' => 'customOptions', - 'REQUEST_OPTIONS' => 'requestOptions', ]; } @@ -113,7 +112,6 @@ public function testDynamicallyLoadedConstants(): void $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaOrientation()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaAspectRatio()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::customOptions()); - $this->assertInstanceOf(OptionEnum::class, OptionEnum::requestOptions()); } /** @@ -137,6 +135,5 @@ public function testGetValuesIncludesDynamicConstants(): void $this->assertContains('outputMediaOrientation', $values); $this->assertContains('outputMediaAspectRatio', $values); $this->assertContains('customOptions', $values); - $this->assertContains('requestOptions', $values); } } From 39a5d8204c11c74834c9defa2664e57c7a313e41 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 15 Oct 2025 17:18:18 -0600 Subject: [PATCH 11/14] refactor: merges request and parameter options --- src/Providers/Http/HttpTransporter.php | 65 ++++++++++++++++++- .../Providers/Http/HttpTransporterTest.php | 51 +++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 1a5b51dd..087d271b 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -73,13 +73,15 @@ public function __construct( public function send(Request $request, ?RequestOptions $options = null): Response { $psr7Request = $this->convertToPsr7Request($request); - $options = $options ?? $request->getOptions(); + + // Merge request options with parameter options, with parameter options taking precedence + $mergedOptions = $this->mergeOptions($request->getOptions(), $options); try { if ($this->client instanceof ClientWithOptionsInterface) { - $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $options); + $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions); } elseif ($this->isGuzzleClient($this->client)) { - $psr7Response = $this->sendWithGuzzle($psr7Request, $options); + $psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions); } else { $psr7Response = $this->client->sendRequest($psr7Request); } @@ -101,6 +103,63 @@ public function send(Request $request, ?RequestOptions $options = null): Respons return $this->convertFromPsr7Response($psr7Response); } + /** + * Merges request options with parameter options taking precedence. + * + * @since n.e.x.t + * + * @param RequestOptions|null $requestOptions Options from the Request object. + * @param RequestOptions|null $parameterOptions Options passed as method parameter. + * @return RequestOptions|null Merged options, or null if both are null. + */ + private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $parameterOptions): ?RequestOptions + { + // If no options at all, return null + if ($requestOptions === null && $parameterOptions === null) { + return null; + } + + // If only one set of options exists, return it + if ($requestOptions === null) { + return $parameterOptions; + } + + if ($parameterOptions === null) { + return $requestOptions; + } + + // Both exist, merge them with parameter options taking precedence + $merged = new RequestOptions(); + + // Start with request options (lower precedence) + if ($requestOptions->getTimeout() !== null) { + $merged->setTimeout($requestOptions->getTimeout()); + } + + if ($requestOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($requestOptions->getConnectTimeout()); + } + + if ($requestOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($requestOptions->getMaxRedirects()); + } + + // Override with parameter options (higher precedence) + if ($parameterOptions->getTimeout() !== null) { + $merged->setTimeout($parameterOptions->getTimeout()); + } + + if ($parameterOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($parameterOptions->getConnectTimeout()); + } + + if ($parameterOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($parameterOptions->getMaxRedirects()); + } + + return $merged; + } + /** * Determines if the underlying client matches the Guzzle client shape. * diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index 19ff2df2..2c30c923 100644 --- a/tests/unit/Providers/Http/HttpTransporterTest.php +++ b/tests/unit/Providers/Http/HttpTransporterTest.php @@ -327,4 +327,55 @@ public function testConstructorWithDiscovery(): void // The transporter should be created successfully $this->assertInstanceOf(HttpTransporter::class, $transporter); } + + /** + * Tests that parameter options override request options when both are provided. + * + * @covers ::send + * @covers ::mergeOptions + * @covers ::buildGuzzleOptions + * + * @return void + */ + public function testSendMergesOptionsWithParameterPrecedence(): void + { + $response = new Psr7Response(200); + $guzzleClient = new GuzzleLikeClient($response); + $transporter = new HttpTransporter( + $guzzleClient, + $this->httpFactory, + $this->httpFactory + ); + + // Request has some options + $requestOptions = new RequestOptions(); + $requestOptions->setTimeout(10.0); + $requestOptions->setConnectTimeout(5.0); + $requestOptions->setMaxRedirects(5); + + $request = new Request( + HttpMethodEnum::GET(), + 'https://api.example.com/test', + [], + null, + $requestOptions + ); + + // Parameter options override some values + $parameterOptions = new RequestOptions(); + $parameterOptions->setTimeout(2.0); // Override timeout + $parameterOptions->setMaxRedirects(0); // Override maxRedirects (disable) + + $result = $transporter->send($request, $parameterOptions); + + $this->assertEquals(200, $result->getStatusCode()); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + + // Verify parameter options took precedence + $this->assertSame(2.0, $lastOptions['timeout']); // From parameter + $this->assertSame(5.0, $lastOptions['connect_timeout']); // From request (not overridden) + $this->assertFalse($lastOptions['allow_redirects']); // From parameter (0 = disabled) + } } From 4143b11b2988a024e559ca3b0d33904168eb120d Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 16 Oct 2025 17:50:37 -0600 Subject: [PATCH 12/14] refactor: simplify options merging --- src/Providers/Http/HttpTransporter.php | 33 ++++---------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 087d271b..e0a4b53e 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -129,35 +129,12 @@ private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $ } // Both exist, merge them with parameter options taking precedence - $merged = new RequestOptions(); - - // Start with request options (lower precedence) - if ($requestOptions->getTimeout() !== null) { - $merged->setTimeout($requestOptions->getTimeout()); - } - - if ($requestOptions->getConnectTimeout() !== null) { - $merged->setConnectTimeout($requestOptions->getConnectTimeout()); - } - - if ($requestOptions->getMaxRedirects() !== null) { - $merged->setMaxRedirects($requestOptions->getMaxRedirects()); - } - - // Override with parameter options (higher precedence) - if ($parameterOptions->getTimeout() !== null) { - $merged->setTimeout($parameterOptions->getTimeout()); - } - - if ($parameterOptions->getConnectTimeout() !== null) { - $merged->setConnectTimeout($parameterOptions->getConnectTimeout()); - } - - if ($parameterOptions->getMaxRedirects() !== null) { - $merged->setMaxRedirects($parameterOptions->getMaxRedirects()); - } + $merged = array_merge( + $requestOptions->toArray(), + $parameterOptions->toArray() + ); - return $merged; + return RequestOptions::fromArray($merged); } /** From 0da66e63842f1dfbfcecf7f6d894e3f6ae0ce885 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 16 Oct 2025 19:33:23 -0600 Subject: [PATCH 13/14] refactor: send request normal way without options --- src/Providers/Http/HttpTransporter.php | 38 +++++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index e0a4b53e..bb16daae 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -78,9 +78,10 @@ public function send(Request $request, ?RequestOptions $options = null): Respons $mergedOptions = $this->mergeOptions($request->getOptions(), $options); try { - if ($this->client instanceof ClientWithOptionsInterface) { + $hasOptions = $mergedOptions !== null; + if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) { $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions); - } elseif ($this->isGuzzleClient($this->client)) { + } elseif ($hasOptions && $this->isGuzzleClient($this->client)) { $psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions); } else { $psr7Response = $this->client->sendRequest($psr7Request); @@ -129,12 +130,35 @@ private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $ } // Both exist, merge them with parameter options taking precedence - $merged = array_merge( - $requestOptions->toArray(), - $parameterOptions->toArray() - ); + $merged = new RequestOptions(); + + // Start with request options (lower precedence) + if ($requestOptions->getTimeout() !== null) { + $merged->setTimeout($requestOptions->getTimeout()); + } + + if ($requestOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($requestOptions->getConnectTimeout()); + } + + if ($requestOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($requestOptions->getMaxRedirects()); + } + + // Override with parameter options (higher precedence) + if ($parameterOptions->getTimeout() !== null) { + $merged->setTimeout($parameterOptions->getTimeout()); + } + + if ($parameterOptions->getConnectTimeout() !== null) { + $merged->setConnectTimeout($parameterOptions->getConnectTimeout()); + } + + if ($parameterOptions->getMaxRedirects() !== null) { + $merged->setMaxRedirects($parameterOptions->getMaxRedirects()); + } - return RequestOptions::fromArray($merged); + return $merged; } /** From b976abd8b93c3a3338d7d269a9796459e3c24826 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 16 Oct 2025 19:35:48 -0600 Subject: [PATCH 14/14] refactor: removes nullable for options parameter --- .../Http/Contracts/ClientWithOptionsInterface.php | 4 ++-- src/Providers/Http/HttpTransporter.php | 12 ++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/Providers/Http/Contracts/ClientWithOptionsInterface.php index 6ec29ef3..3697238d 100644 --- a/src/Providers/Http/Contracts/ClientWithOptionsInterface.php +++ b/src/Providers/Http/Contracts/ClientWithOptionsInterface.php @@ -24,11 +24,11 @@ interface ClientWithOptionsInterface * @since n.e.x.t * * @param RequestInterface $request The PSR-7 request to send. - * @param RequestOptions|null $options The request transport options. + * @param RequestOptions $options The request transport options. Must not be null. * @return ResponseInterface The PSR-7 response received. */ public function sendRequestWithOptions( RequestInterface $request, - ?RequestOptions $options + RequestOptions $options ): ResponseInterface; } diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index bb16daae..0a7f4595 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -218,10 +218,10 @@ private function isGuzzleClient(ClientInterface $client): bool * @since n.e.x.t * * @param RequestInterface $request The PSR-7 request to send. - * @param RequestOptions|null $options The request options. + * @param RequestOptions $options The request options. * @return ResponseInterface The PSR-7 response received. */ - private function sendWithGuzzle(RequestInterface $request, ?RequestOptions $options): ResponseInterface + private function sendWithGuzzle(RequestInterface $request, RequestOptions $options): ResponseInterface { $guzzleOptions = $this->buildGuzzleOptions($options); @@ -239,15 +239,11 @@ private function sendWithGuzzle(RequestInterface $request, ?RequestOptions $opti * * @since n.e.x.t * - * @param RequestOptions|null $options The request options. + * @param RequestOptions $options The request options. * @return array Guzzle-compatible options. */ - private function buildGuzzleOptions(?RequestOptions $options): array + private function buildGuzzleOptions(RequestOptions $options): array { - if ($options === null) { - return []; - } - $guzzleOptions = []; $timeout = $options->getTimeout();