int, 'body' => ?array, 'error' => ?string]; * status 0 is a transport failure. A 409 or 429 is an answer, not an error โ€” * callers branch on the status, never on is_wp_error alone. */ final class Readerr_Client { private const TIMEOUT_S = 15; private string $base_url; private string $api_key; // No constructor promotion: the plugin runs on PHP 7.4 (readerr.php header). public function __construct( string $base_url, string $api_key ) { $this->base_url = $base_url; $this->api_key = $api_key; } public static function from_settings(): ?self { $url = (string) Readerr_Settings::get( 'service_url' ); $key = Readerr_Settings::api_key(); return ( '' === $key || ! self::is_valid_url( $url ) ) ? null : new self( $url, $key ); } /** * service_url is production-fixed https:// (Readerr_Settings::INFRA) and * only a wp-config.php constant can move it, dev included (docs/todo.md, * "Adresy infrastruktury nieedytowalne na produkcji") โ€” so the constant is * the only way a malformed value reaches here. A broken override must not * become "send the key nowhere safe"; it becomes "no client", same as no * key at all. */ private static function is_valid_url( string $url ): bool { $parts = wp_parse_url( $url ); return is_array( $parts ) && ! empty( $parts['host'] ) && in_array( $parts['scheme'] ?? '', array( 'http', 'https' ), true ); } public function ingest( array $payload ): array { return $this->request( 'POST', '/v1/ingest', $payload ); } public function article( string $external_id ): array { return $this->request( 'GET', '/v1/articles/' . rawurlencode( $external_id ) ); } public function delete_article( string $external_id ): array { return $this->request( 'DELETE', '/v1/articles/' . rawurlencode( $external_id ) ); } public function quota(): array { return $this->request( 'GET', '/v1/quota' ); } /** The editor waits on this one, so its caller picks a shorter timeout. */ public function voices( int $timeout = self::TIMEOUT_S ): array { return $this->request( 'GET', '/v1/voices', null, $timeout ); } private function request( string $method, string $path, ?array $json = null, int $timeout = self::TIMEOUT_S ): array { $args = array( 'method' => $method, 'timeout' => $timeout, // Which plugin a site runs, for support: the service keeps it. 'user-agent' => 'Readerr-WordPress/' . READERR_VERSION . ' WordPress/' . get_bloginfo( 'version' ) . ' PHP/' . PHP_VERSION, 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, 'Accept' => 'application/json', ), ); if ( null !== $json ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $json ); } $response = wp_remote_request( rtrim( $this->base_url, '/' ) . $path, $args ); if ( is_wp_error( $response ) ) { return array( 'status' => 0, 'body' => null, 'error' => $response->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $status ) { // Customer-panel spec ยง6.1: any call the plugin makes, not just // the quota poll, learns fast that the key is dead โ€” Readerr_Access // listens for this and lapses the account. do_action( 'readerr_unauthorized' ); } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return array( 'status' => $status, 'body' => is_array( $body ) ? $body : null, 'error' => null, ); } }