diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c18ec4e..6f360233 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,24 @@ direction LR +getHeaders() array< string, string[] > +getBody() ?string +getData() ?array< string, mixed > + +getOptions() ?RequestOptions + +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 { + +setTimeout(?float $timeout) void + +setConnectTimeout(?float $timeout) void + +setMaxRedirects(?int $maxRedirects) void + +getTimeout() ?float + +getConnectTimeout() ?float + +allowsRedirects() ?bool + +getMaxRedirects() ?int + +toArray() array< string, mixed > +getJsonSchema() array< string, mixed >$ } diff --git a/src/Providers/Http/Contracts/ClientWithOptionsInterface.php b/src/Providers/Http/Contracts/ClientWithOptionsInterface.php new file mode 100644 index 00000000..3697238d --- /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,33 @@ 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; + } + + /** + * 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 +354,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 +381,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 +404,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..b4f06720 --- /dev/null +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -0,0 +1,254 @@ + + */ +class RequestOptions extends AbstractDataTransferObject +{ + public const KEY_TIMEOUT = 'timeout'; + public const KEY_CONNECT_TIMEOUT = 'connectTimeout'; + public const KEY_MAX_REDIRECTS = 'maxRedirects'; + + /** + * @var float|null Maximum duration in seconds to wait for the full response. + */ + protected ?float $timeout = null; + + /** + * @var float|null Maximum duration in seconds to wait for the initial connection. + */ + protected ?float $connectTimeout = null; + + /** + * @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified. + */ + protected ?int $maxRedirects = null; + + /** + * Sets the request timeout in seconds. + * + * @since n.e.x.t + * + * @param float|null $timeout Timeout in seconds. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. + */ + public function setTimeout(?float $timeout): void + { + $this->validateTimeout($timeout, self::KEY_TIMEOUT); + $this->timeout = $timeout; + } + + /** + * Sets the connection timeout in seconds. + * + * @since n.e.x.t + * + * @param float|null $timeout Connection timeout in seconds. + * @return void + * + * @throws InvalidArgumentException When timeout is negative. + */ + public function setConnectTimeout(?float $timeout): void + { + $this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT); + $this->connectTimeout = $timeout; + } + + /** + * 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, or 0 to disable, or null for unspecified. + * @return void + * + * @throws InvalidArgumentException When redirect count is negative. + */ + public function setMaxRedirects(?int $maxRedirects): void + { + if ($maxRedirects !== null && $maxRedirects < 0) { + throw new InvalidArgumentException( + 'Request option "maxRedirects" must be greater than or equal to 0.' + ); + } + + $this->maxRedirects = $maxRedirects; + } + + /** + * 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 (maxRedirects > 0), + * false when disabled (maxRedirects = 0), + * null when unspecified (maxRedirects = null). + */ + public function allowsRedirects(): ?bool + { + if ($this->maxRedirects === null) { + return null; + } + + return $this->maxRedirects > 0; + } + + /** + * 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->maxRedirects !== null) { + $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects; + } + + return $data; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + $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_MAX_REDIRECTS])) { + $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]); + } + + return $instance; + } + + /** + * {@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_MAX_REDIRECTS => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.', + ], + ], + '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) + ); + } + } +} diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 3942364f..0a7f4595 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,24 @@ 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); + // Merge request options with parameter options, with parameter options taking precedence + $mergedOptions = $this->mergeOptions($request->getOptions(), $options); + try { - $psr7Response = $this->client->sendRequest($psr7Request); + $hasOptions = $mergedOptions !== null; + if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) { + $psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions); + } elseif ($hasOptions && $this->isGuzzleClient($this->client)) { + $psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions); + } else { + $psr7Response = $this->client->sendRequest($psr7Request); + } } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\Psr\Http\Client\ClientExceptionInterface $e) { @@ -91,6 +104,175 @@ public function send(Request $request): Response 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. + * + * @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 $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 $options The request options. + * @return array Guzzle-compatible options. + */ + private function buildGuzzleOptions(RequestOptions $options): array + { + $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/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..12f77446 --- /dev/null +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -0,0 +1,97 @@ +assertNull($options->getTimeout()); + $this->assertNull($options->getConnectTimeout()); + $this->assertNull($options->allowsRedirects()); + $this->assertNull($options->getMaxRedirects()); + $this->assertSame([], $options->toArray()); + } + + /** + * Tests mutable setters modify the same instance. + * + * @return void + */ + public function testSetTimeoutModifiesInstance(): void + { + $options = new RequestOptions(); + $options->setTimeout(5.0); + + $this->assertSame(5.0, $options->getTimeout()); + } + + /** + * Tests enabling redirects with a limit. + * + * @return void + */ + public function testSetMaxRedirectsEnablesRedirects(): void + { + $options = new RequestOptions(); + $options->setMaxRedirects(3); + + $this->assertTrue($options->allowsRedirects()); + $this->assertSame(3, $options->getMaxRedirects()); + } + + /** + * Tests disabling redirects by setting maxRedirects to 0. + * + * @return void + */ + public function testSetMaxRedirectsToZeroDisablesRedirects(): void + { + $options = new RequestOptions(); + $options->setMaxRedirects(0); + + $this->assertFalse($options->allowsRedirects()); + $this->assertSame(0, $options->getMaxRedirects()); + } + + /** + * Tests validation when attempting to set a negative redirect limit. + * + * @return void + */ + public function testSetMaxRedirectsThrowsWhenNegative(): void + { + $options = new RequestOptions(); + + $this->expectException(InvalidArgumentException::class); + $options->setMaxRedirects(-1); + } + + /** + * Tests that the JSON schema reflects nullable maxRedirects. + * + * @return void + */ + public function testGetJsonSchemaDefinesNullableMaxRedirects(): void + { + $schema = RequestOptions::getJsonSchema(); + + $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..d004678c --- /dev/null +++ b/tests/unit/Providers/Http/DTO/RequestTest.php @@ -0,0 +1,280 @@ +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(); + $options->setTimeout(1.5); + + $updated = $request->withOptions($options); + + $this->assertNotSame($request, $updated); + $this->assertSame($options, $updated->getOptions()); + $this->assertNull($request->getOptions()); + } + + /** + * 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(); + $options->setTimeout(1.0); + $options->setMaxRedirects(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, '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_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(), ''); + } +} diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index 2cc4399e..2c30c923 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(); + $options->setTimeout(5.0); + $options->setConnectTimeout(1.0); + $options->setMaxRedirects(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. * @@ -281,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) + } }