auth_service = $auth_service; $this->http_client = $http_client; $this->rate_limiter = $rate_limiter; $this->logger = $logger; $this->config = $config; } public static function create(?ContaiConfig $config = null): self { if ($config === null) { $config = ContaiConfig::getInstance(); } $auth_service = ContaiOnePlatformAuthService::create($config); $http_client = ContaiHTTPClientService::create($config); $rate_limiter = new ContaiRateLimiter( self::RATE_LIMITER_KEY, $config->getRateLimitRequests(), $config->getRateLimitWindow() ); $logger = ContaiRequestLogger::create($config); return new self($auth_service, $http_client, $rate_limiter, $logger, $config); } public function get(string $endpoint, array $query_params = []): ContaiOnePlatformResponse { $url = $this->buildUrl($endpoint, $query_params); return $this->request('GET', $url); } public function post(string $endpoint, array $data = []): ContaiOnePlatformResponse { $url = $this->buildUrl($endpoint); return $this->request('POST', $url, $data); } public function put(string $endpoint, array $data = []): ContaiOnePlatformResponse { $url = $this->buildUrl($endpoint); return $this->request('PUT', $url, $data); } public function delete(string $endpoint): ContaiOnePlatformResponse { $url = $this->buildUrl($endpoint); return $this->request('DELETE', $url); } public function patch(string $endpoint, array $data = []): ContaiOnePlatformResponse { $url = $this->buildUrl($endpoint); return $this->request('PATCH', $url, $data); } private function request(string $method, string $url, ?array $data = null, int $retry_count = 0): ContaiOnePlatformResponse { if (!$this->isWithinRateLimit()) { return $this->createRateLimitResponse(); } $auth_headers = $this->obtainAuthHeaders(); if ($auth_headers === null) { if ($retry_count < $this->config->getMaxRetries()) { contai_log('Content AI: Auth token generation failed, force-refreshing and retrying'); $this->auth_service->forceRefreshAllTokens(); return $this->request($method, $url, $data, $retry_count + 1); } return $this->createAuthFailedResponse(); } $headers = array_merge($auth_headers, $this->custom_headers); // Advertise the plugin version on every outbound request. The 1Platform API // routes to the Authorize+Capture billing path when this header is >= 2.36.0 // (and falls back to the legacy charge-first path otherwise). if (defined('CONTAI_VERSION')) { $headers['X-Plugin-Version'] = CONTAI_VERSION; } // Identify the originating site so the API can match heartbeats to the // Website document and only ping sites with active PENDING jobs. if (function_exists('home_url')) { $headers['X-Site-URL'] = home_url(); } $start_time = microtime(true); $response = $this->executeHttpRequest($method, $url, $data, $headers); $duration = microtime(true) - $start_time; $this->logRequest($method, $url, $data, $response, $duration); if ($this->shouldRetryRequest($response, $retry_count)) { $this->clearTokensForRetry($response); return $this->request($method, $url, $data, $retry_count + 1); } $platform_response = $this->createResponse($response); if (!$platform_response->isSuccess() && class_exists('ContaiClientLogReporter') && !$this->isLogEndpoint($url)) { ContaiClientLogReporter::report([ 'timestamp' => gmdate('c'), 'method' => $method, 'endpoint' => $url, 'action' => 'api_request', 'source_module' => 'api_client', 'response_status' => $platform_response->getStatusCode(), 'response_message' => substr($platform_response->getMessage() ?? self::ERROR_REQUEST_FAILED, 0, 2000), 'success' => false, 'error_type' => 'provider_error', 'trace_id' => $platform_response->getTraceId(), ]); } return $platform_response; } private function isWithinRateLimit(): bool { return $this->rate_limiter->allow(); } private function createRateLimitResponse(): ContaiOnePlatformResponse { return new ContaiOnePlatformResponse( false, null, self::ERROR_RATE_LIMIT, 429 ); } private function obtainAuthHeaders(): ?array { if ($this->app_only_auth) { return $this->auth_service->getAppOnlyAuthHeaders(); } return $this->auth_service->getAuthHeaders(); } public function setAppOnlyAuth(): self { $this->app_only_auth = true; return $this; } private function createAuthFailedResponse(): ContaiOnePlatformResponse { return new ContaiOnePlatformResponse( false, null, self::ERROR_AUTH_FAILED, 401 ); } private function executeHttpRequest(string $method, string $url, ?array $data, array $headers): ContaiHTTPResponse { return match($method) { 'GET' => $this->http_client->get($url, $headers), 'POST' => $this->http_client->post($url, $data, $headers), 'PUT' => $this->http_client->put($url, $data, $headers), 'PATCH' => $this->http_client->patch($url, $data, $headers), 'DELETE' => $this->http_client->delete($url, $headers), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped default => throw new InvalidArgumentException(sprintf(self::ERROR_INVALID_METHOD, $method)), }; } private function logRequest(string $method, string $url, ?array $data, ContaiHTTPResponse $response, float $duration): void { $this->logger->log($method, $url, $data, $response, $duration); } /** * Determine if the request should be retried after an auth error. * * Retries on 401/403 up to max_retries (1), but never retries * permanent errors like "Invalid API key". */ private function shouldRetryRequest(ContaiHTTPResponse $response, int $retry_count): bool { if ($retry_count >= $this->config->getMaxRetries()) { return false; } if (!$response->isForbidden() && !$response->isUnauthorized()) { return false; } if ($this->isPermanentAuthError($response)) { contai_log('Content AI: Permanent auth error detected, skipping retry'); return false; } return true; } /** * Check if the API response indicates a non-retryable auth error. * * "Invalid API key" means the key itself is wrong — refreshing tokens * with the same key will never succeed. */ private function isPermanentAuthError(ContaiHTTPResponse $response): bool { $json = $response->getJson(); if (!$json) { return false; } $message = strtolower($json['msg'] ?? $json['message'] ?? ''); foreach (self::PERMANENT_AUTH_ERRORS as $error_pattern) { if (strpos($message, $error_pattern) !== false) { return true; } } return false; } /** * Clear both app and user tokens before retry. * * Always clears both tokens because a 401 could be caused by either * an expired app token or user token. Clearing both ensures the retry * generates fresh tokens for the entire auth chain. */ private function clearTokensForRetry(ContaiHTTPResponse $response): void { $this->auth_service->clearToken(); } private function createResponse(ContaiHTTPResponse $http_response): ContaiOnePlatformResponse { $json = $http_response->getJson(); $trace_id = $http_response->getHeader('x-trace-id'); $headers = $http_response->getHeaders(); if ($http_response->isSuccess()) { return $this->createSuccessResponse($json, $http_response->getStatusCode(), $trace_id, $headers); } return $this->createErrorResponse($json, $http_response, $trace_id, $headers); } private function createSuccessResponse(array $json, int $status_code, ?string $trace_id = null, array $headers = []): ContaiOnePlatformResponse { return new ContaiOnePlatformResponse( $json['success'] ?? true, $json['data'] ?? $json, $json['msg'] ?? null, $status_code, $trace_id, $headers ); } private function createErrorResponse(?array $json, ContaiHTTPResponse $http_response, ?string $trace_id = null, array $headers = []): ContaiOnePlatformResponse { $status_code = $http_response->getStatusCode(); // When neither the API nor wp_remote_request provide a diagnostic message // (e.g. upstream gateway returns a non-JSON body like an HTML 502 page), // include the HTTP status in the fallback so users can distinguish a // timeout from a provider outage instead of seeing only "Request failed". $fallback_message = $status_code > 0 ? sprintf('%s (HTTP %d)', self::ERROR_REQUEST_FAILED, $status_code) : self::ERROR_REQUEST_FAILED; $error_message = $json['msg'] ?? $json['detail'] ?? $http_response->getError() ?? $fallback_message; return new ContaiOnePlatformResponse( false, null, $error_message, $status_code, $trace_id, $headers ); } private function isLogEndpoint(string $url): bool { return strpos($url, '/logs/client') !== false; } private function buildUrl(string $endpoint, array $query_params = []): string { $base_url = rtrim($this->config->getApiBaseUrl(), '/'); $url = $base_url . '/' . ltrim($endpoint, '/'); if (!empty($query_params)) { $url .= '?' . http_build_query($query_params); } return $url; } public function getRateLimitInfo(): array { return [ 'remaining' => $this->rate_limiter->getRemainingRequests(), 'reset_at' => $this->rate_limiter->getResetTime(), ]; } public function getAuthInfo(): array { return $this->auth_service->getTokenInfo(); } public function clearCache(): void { $this->auth_service->clearToken(); } public function getConfig(): ContaiConfig { return $this->config; } public function getEnvironment(): string { return $this->config->getEnvironment(); } public function setCustomHeaders(array $headers): void { $this->custom_headers = $headers; } public function getCustomHeaders(): array { return $this->custom_headers; } public function addCustomHeader(string $name, string $value): void { $this->custom_headers[$name] = $value; } public function removeCustomHeader(string $name): void { unset($this->custom_headers[$name]); } } class ContaiOnePlatformResponse { private bool $success; private $data; private ?string $message; private int $status_code; private ?string $trace_id; private array $headers; public function __construct(bool $success, $data, ?string $message = null, int $status_code = 200, ?string $trace_id = null, array $headers = []) { $this->success = $success; $this->data = $data; $this->message = $message; $this->status_code = $status_code; $this->trace_id = $trace_id; $this->headers = $headers; } public function isSuccess(): bool { return $this->success; } public function getData() { return $this->data; } public function getMessage(): ?string { return $this->message; } public function getStatusCode(): int { return $this->status_code; } public function getTraceId(): ?string { return $this->trace_id; } public function getHeaders(): array { return $this->headers; } /** * Case-insensitive header lookup. * * HTTP headers are case-insensitive (RFC 7230 §3.2). wp_remote_request * normalizes them to lowercase via the Requests library, but callers * commonly pass the canonical mixed-case spelling (e.g. "X-Hold-Id"), * so we search both spellings transparently. */ public function getHeader(string $name): ?string { if (isset($this->headers[$name])) { return is_array($this->headers[$name]) ? (string) reset($this->headers[$name]) : (string) $this->headers[$name]; } $lower = strtolower($name); foreach ($this->headers as $key => $value) { if (strtolower((string) $key) === $lower) { return is_array($value) ? (string) reset($value) : (string) $value; } } return null; } public function isPaymentRequired(): bool { return $this->status_code === 402; } public function toArray(): array { return [ 'success' => $this->success, 'data' => $this->data, 'message' => $this->message, 'status_code' => $this->status_code, 'trace_id' => $this->trace_id, 'headers' => $this->headers, ]; } }