Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,27 +794,43 @@ 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

```mermaid
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

Expand Down Expand Up @@ -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
Expand All @@ -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 >$
}

Expand Down
34 changes: 34 additions & 0 deletions src/Providers/Http/Contracts/ClientWithOptionsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace WordPress\AiClient\Providers\Http\Contracts;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;

/**
* Interface for HTTP clients that support per-request transport options.
*
* Extends the capabilities of PSR-18 clients by allowing custom transport
* configuration such as timeouts and redirect handling on each request.
*
* @since n.e.x.t
*/
interface ClientWithOptionsInterface
{
/**
* Sends an HTTP request with the given transport options.
*
* @since n.e.x.t
*
* @param RequestInterface $request The PSR-7 request to send.
* @param RequestOptions|null $options The request transport options.
* @return ResponseInterface The PSR-7 response received.
*/
public function sendRequestWithOptions(
RequestInterface $request,
?RequestOptions $options
): ResponseInterface;
}
4 changes: 3 additions & 1 deletion src/Providers/Http/Contracts/HttpTransporterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace WordPress\AiClient\Providers\Http\Contracts;

use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\DTO\Response;

/**
Expand All @@ -23,7 +24,8 @@ interface HttpTransporterInterface
* @since 0.1.0
*
* @param Request $request The request to send.
* @param RequestOptions|null $options Optional transport options for the request.
* @return Response The response received.
*/
public function send(Request $request): Response;
public function send(Request $request, ?RequestOptions $options = null): Response;
}
133 changes: 129 additions & 4 deletions src/Providers/Http/DTO/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
*
* @since 0.1.0
*
* @phpstan-import-type RequestOptionsArrayShape from RequestOptions
* @phpstan-type RequestArrayShape array{
* method: string,
* uri: string,
* headers: array<string, list<string>>,
* body?: string|null
* body?: string|null,
* options?: RequestOptionsArrayShape
* }
*
* @extends AbstractDataTransferObject<RequestArrayShape>
Expand All @@ -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.
Expand All @@ -60,6 +63,11 @@ class Request extends AbstractDataTransferObject
*/
protected ?string $body = null;

/**
* @var RequestOptions|null Request transport options.
*/
protected ?RequestOptions $options = null;

/**
* Constructor.
*
Expand All @@ -69,11 +77,17 @@ class Request extends AbstractDataTransferObject
* @param string $uri The request URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|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.');
}
Expand All @@ -88,6 +102,8 @@ public function __construct(HttpMethodEnum $method, string $uri, array $headers
} elseif (is_array($data)) {
$this->data = $data;
}

$this->options = $options;
}

/**
Expand Down Expand Up @@ -281,6 +297,104 @@ 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();

$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.
*
* @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}
*
Expand Down Expand Up @@ -311,6 +425,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],
];
Expand All @@ -337,6 +452,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;
}

Expand All @@ -353,7 +475,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
);
}

Expand Down
Loading