Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
v0.10.x backward-compat default for legacy tokens (existing behaviour unchanged);
consumers can migrate to the strict variant once every issuer emits `aud`. Additive,
BC-safe.
- **`Arr` async-transport seam** (findings B1/P1, CQ1, F2) — the *arr clients now route
all HTTP I/O through an injectable transport, so the blocking cURL call lives behind a
seam and event-loop (Workerman/Webman) consumers can avoid it entirely, honouring the
package's "zero I/O" charter:
- New `interface Arr\Transport\ArrTransportInterface` with a single
`request(string $method, string $url, array $headers, ?string $body): array{status:int, body:string}`
method. Implementations return the status + raw body (they do NOT throw on non-2xx;
the client maps status codes).
- New default `final Arr\Transport\CurlArrTransport` carrying the **blocking** cURL
behaviour moved out of `AbstractArrClient::request()`. Documented as **CLI/test only**;
event-loop consumers MUST inject an async, non-blocking transport.
- `Arr\AbstractArrClient` gains an **optional, appended** constructor parameter
`?ArrTransportInterface $transport = null`; when null it falls back to
`new CurlArrTransport($timeout)`, so existing direct instantiation keeps working
unchanged. All requests are dispatched through the transport — no `curl_exec()` runs
when a transport is injected.
- `Arr\ArrClientFactory` gains an optional appended `?ArrTransportInterface $transport`
constructor parameter, propagated to every client it creates.
- **`ArrClientInterface` is unchanged** — the transport is a constructor concern only,
not a new interface method, so this is NOT a breaking change.
- `composer.json`: the misleading absolute "zero I/O" claim is reconciled — the
description now states the only bundled network code is the blocking `CurlArrTransport`
(CLI/test only) and event-loop consumers must inject an async transport. `ext-curl`
moved from `require` to `require-dev` + `suggest` (needed only for the default cURL
transport); added a `suggest` for `workerman/http-client` for consumers wiring an
async transport.
- **Consumer follow-up (Wave 1+):** phlix-server will add a `workerman/http-client`-backed
`WorkermanArrTransport` and inject it (via `ArrClientFactory`/direct construction) so
*arr calls stop stalling the worker; phlix-hub audits its `RequestManager` usage the
same way. Additive / BC-safe here.

### Changed
- **`Arr\AbstractArrClient` extraction** (findings CQ2/CQ5) — the four near-identical
Expand Down
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
{
"name": "detain/phlix-shared",
"description": "Shared interfaces, DTOs, event names, and protocol types used by both phlix-server and phlix-hub. Composer-installable, PHP 8.3+, zero I/O.",
"description": "Shared interfaces, DTOs, event names, and protocol types used by both phlix-server and phlix-hub. Composer-installable, PHP 8.3+, zero I/O by charter — the only bundled network code is the blocking CurlArrTransport (CLI/test only); event-loop consumers MUST inject an async ArrTransportInterface.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.3",
"ext-curl": "*",
"psr/container": "^2.0",
"psr/event-dispatcher": "^1.0",
"psr/log": "^3.0"
},
"require-dev": {
"ext-curl": "*",
"phpunit/phpunit": "^10.0",
"phpstan/phpstan": "^2.0",
"squizlabs/php_codesniffer": "^3.10",
"vimeo/psalm": "^5.0"
},
"suggest": {
"ext-curl": "Required only for the bundled blocking Arr\\Transport\\CurlArrTransport (CLI/test use). Event-loop (Workerman/Webman) consumers should inject an async ArrTransportInterface instead and do not need ext-curl.",
"workerman/http-client": "Event-loop (Workerman/Webman) consumers should inject a workerman/http-client-backed Arr\\Transport\\ArrTransportInterface so *arr API calls do not block the worker; the bundled CurlArrTransport is blocking and for CLI/test only."
},
"autoload": {
"psr-4": {
"Phlix\\Shared\\": "src/"
Expand Down
63 changes: 25 additions & 38 deletions src/Arr/AbstractArrClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,25 @@

namespace Phlix\Shared\Arr;

use Phlix\Shared\Arr\Transport\ArrTransportInterface;
use Phlix\Shared\Arr\Transport\CurlArrTransport;
use Psr\Log\LoggerInterface;
use RuntimeException;

/**
* Shared base for the *arr HTTP API clients (Radarr/Sonarr/Prowlarr/Bazarr).
*
* Centralises the constructor, header building, the GET/POST/PUT/DELETE cURL
* Centralises the constructor, header building, the GET/POST/PUT/DELETE request
* methods, and the per-status-code error mapping that were previously
* duplicated across each client. Subclasses provide only their
* endpoint-specific methods plus {@see AbstractArrClient::vendorName()} used in
* error messages (e.g. "Radarr API error: HTTP 500").
*
* NOTE: This is intentionally still blocking cURL. A later step (F2b) swaps the
* transport behind an injected seam; this class only removes the duplication so
* that swap happens in one place.
* All HTTP I/O is delegated to an injected {@see ArrTransportInterface}. When none
* is supplied the class falls back to the bundled, **blocking** {@see CurlArrTransport}
* so direct instantiation in CLI scripts/tests keeps working unchanged. Event-loop
* consumers (Workerman/Webman) MUST inject an async, non-blocking transport so a slow
* *arr instance never stalls the worker — see {@see ArrTransportInterface}.
*
* @package Phlix\Shared\Arr
* @since 0.4.0
Expand All @@ -29,6 +33,7 @@ abstract class AbstractArrClient
protected string $apiKey;
protected ?LoggerInterface $logger;
protected int $timeout;
protected ArrTransportInterface $transport;

/**
* Creates a new *arr client.
Expand All @@ -37,17 +42,22 @@ abstract class AbstractArrClient
* @param string $apiKey API key for authentication.
* @param LoggerInterface|null $logger Optional logger instance.
* @param int $timeout Request timeout in seconds (default 30).
* @param ArrTransportInterface|null $transport Optional HTTP transport. When null,
* a blocking {@see CurlArrTransport} (CLI/test only) is used. Event-loop
* consumers MUST inject an async, non-blocking transport.
*/
public function __construct(
string $baseUrl,
string $apiKey,
?LoggerInterface $logger = null,
int $timeout = 30
int $timeout = 30,
?ArrTransportInterface $transport = null
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->apiKey = $apiKey;
$this->logger = $logger;
$this->timeout = $timeout;
$this->transport = $transport ?? new CurlArrTransport($timeout);
}

/**
Expand Down Expand Up @@ -108,6 +118,10 @@ protected function delete(string $path): array
/**
* Executes an HTTP request against the *arr instance and decodes the JSON body.
*
* The wire I/O is delegated to the injected {@see ArrTransportInterface}; this
* method only builds the request, maps status codes to exceptions, and decodes
* the JSON body. No cURL call happens here when a non-default transport is injected.
*
* @param string $method One of GET/POST/PUT/DELETE.
* @param string $path Request path.
* @param array<string, mixed>|null $body JSON-serializable body for POST/PUT; null otherwise.
Expand All @@ -117,42 +131,15 @@ protected function delete(string $path): array
private function request(string $method, string $path, ?array $body): array
{
$url = $this->baseUrl . $path;
assert($url !== '');

$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => $this->buildHeaders(),
];

if ($method === 'POST') {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = $this->encodeBody($body ?? []);
} elseif ($method === 'PUT') {
$options[CURLOPT_CUSTOMREQUEST] = 'PUT';
$options[CURLOPT_POSTFIELDS] = $this->encodeBody($body ?? []);
} elseif ($method === 'DELETE') {
$options[CURLOPT_CUSTOMREQUEST] = 'DELETE';
}

$ch = curl_init();
if ($ch === false) {
throw new RuntimeException('curl_init() failed');
$encodedBody = null;
if ($method === 'POST' || $method === 'PUT') {
$encodedBody = $this->encodeBody($body ?? []);
}

curl_setopt_array($ch, $options);

/** @var string|false */
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);

if ($responseBody === false || $curlErrno !== 0) {
throw new RuntimeException('cURL error: ' . $curlError, $curlErrno);
}
$response = $this->transport->request($method, $url, $this->buildHeaders(), $encodedBody);
$httpCode = $response['status'];
$responseBody = $response['body'];

$vendor = $this->vendorName();

Expand Down
15 changes: 12 additions & 3 deletions src/Arr/ArrClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@

namespace Phlix\Shared\Arr;

use Phlix\Shared\Arr\Transport\ArrTransportInterface;
use Psr\Log\LoggerInterface;

/**
* Factory for creating Sonarr/Radarr API clients from config.
*
* An optional {@see ArrTransportInterface} may be supplied; when present it is
* propagated to every created client so event-loop consumers can wire a single
* async, non-blocking transport once. When omitted, clients fall back to the
* bundled blocking {@see \Phlix\Shared\Arr\Transport\CurlArrTransport} (CLI/test only).
*
* @package Phlix\Shared\Arr
* @since 0.4.0
*/
Expand All @@ -19,9 +25,12 @@ class ArrClientFactory
* sonarr?: array{url?: string, api_key?: string, enabled?: bool},
* radarr?: array{url?: string, api_key?: string, enabled?: bool}
* } $config Configuration array with sonarr/radarr sections.
* @param ArrTransportInterface|null $transport Optional HTTP transport propagated to
* every created client. When null, clients use the default blocking cURL transport.
*/
public function __construct(
private readonly array $config
private readonly array $config,
private readonly ?ArrTransportInterface $transport = null
) {
}

Expand All @@ -47,7 +56,7 @@ public function createSonarrClient(?LoggerInterface $logger = null): ?SonarrClie
return null;
}

return new SonarrClient($url, $apiKey, $logger);
return new SonarrClient($url, $apiKey, $logger, 30, $this->transport);
}

/**
Expand All @@ -72,6 +81,6 @@ public function createRadarrClient(?LoggerInterface $logger = null): ?RadarrClie
return null;
}

return new RadarrClient($url, $apiKey, $logger);
return new RadarrClient($url, $apiKey, $logger, 30, $this->transport);
}
}
39 changes: 39 additions & 0 deletions src/Arr/Transport/ArrTransportInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Phlix\Shared\Arr\Transport;

/**
* Transport seam for the *arr HTTP API clients.
*
* {@see \Phlix\Shared\Arr\AbstractArrClient} performs all of its HTTP I/O through
* this interface, so the actual wire mechanism is a pluggable, injectable concern
* rather than a hard-coded blocking call. This keeps the package honest about its
* "zero I/O" charter: the only bundled implementation that touches the network is
* {@see CurlArrTransport}, which is intended for CLI/test usage only.
*
* **Event-loop consumers (Workerman/Webman) MUST inject an async, non-blocking
* transport** (e.g. a `workerman/http-client`-backed implementation living in the
* consumer) so that a slow *arr instance never stalls the worker's coroutines.
*
* Implementations MUST NOT throw on a non-2xx HTTP status — they return the status
* and raw body and let the caller map it. They MAY throw on a genuine transport
* failure (DNS, connection refused, timeout).
*
* @package Phlix\Shared\Arr\Transport
* @since 0.11.0
*/
interface ArrTransportInterface
{
/**
* Executes a single HTTP request and returns the status code and raw body.
*
* @param string $method One of GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS (upper-cased).
* @param string $url Absolute request URL.
* @param array<string> $headers Headers as raw `Name: value` strings.
* @param string|null $body Raw request body for write methods; null for none.
* @return array{status:int, body:string} HTTP status code and raw response body.
*/
public function request(string $method, string $url, array $headers, ?string $body): array;
}
94 changes: 94 additions & 0 deletions src/Arr/Transport/CurlArrTransport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Phlix\Shared\Arr\Transport;

use RuntimeException;

/**
* Default, **blocking** cURL transport for the *arr API clients.
*
* This is the implementation {@see \Phlix\Shared\Arr\AbstractArrClient} falls back
* to when no transport is injected, preserving the original synchronous behaviour
* for direct instantiation in CLI scripts and tests.
*
* WARNING — this transport calls `curl_exec()` synchronously and therefore BLOCKS.
* It is **for CLI/test use only**. Inside an event loop (Workerman/Webman) a slow
* *arr instance would stall every coroutine on the worker; such consumers MUST
* inject an async, non-blocking {@see ArrTransportInterface} implementation instead
* (e.g. a `workerman/http-client`-backed transport in the consumer). Keeping this
* blocking call behind the injected seam is what lets the shared library honour its
* "zero I/O" charter.
*
* @package Phlix\Shared\Arr\Transport
* @since 0.11.0
*/
final class CurlArrTransport implements ArrTransportInterface
{
/**
* @param int $timeout Overall request timeout in seconds.
*/
public function __construct(private readonly int $timeout = 30)
{
}

/**
* {@inheritDoc}
*
* @param array<string> $headers
* @return array{status:int, body:string}
* @throws RuntimeException On a transport-level cURL failure.
*/
public function request(string $method, string $url, array $headers, ?string $body): array
{
if ($url === '') {
throw new RuntimeException('CurlArrTransport: empty request URL');
}
if ($method === '') {
throw new RuntimeException('CurlArrTransport: empty request method');
}

$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => $headers,
];

if ($method === 'POST') {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = (string) $body;
} elseif ($method === 'PUT') {
$options[CURLOPT_CUSTOMREQUEST] = 'PUT';
$options[CURLOPT_POSTFIELDS] = (string) $body;
} elseif ($method === 'DELETE') {
$options[CURLOPT_CUSTOMREQUEST] = 'DELETE';
} elseif ($method !== 'GET') {
$options[CURLOPT_CUSTOMREQUEST] = $method;
if ($body !== null && $body !== '') {
$options[CURLOPT_POSTFIELDS] = $body;
}
}

$ch = curl_init();
if ($ch === false) {
throw new RuntimeException('curl_init() failed');
}

curl_setopt_array($ch, $options);

/** @var string|false $responseBody */
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);

if ($responseBody === false || $curlErrno !== 0) {
throw new RuntimeException('cURL error: ' . $curlError, $curlErrno);
}

return ['status' => $httpCode, 'body' => $responseBody];
}
}
Loading
Loading