$opts Options: * - headers: array extra headers. * - json: array to JSON-encode as the body (sets Content-Type). * - form: array to send as a form-encoded body. * - query: array to append to the URL as a query string. * - timeout: request timeout in seconds, default 15. * @return array The decoded JSON response body, or [] when empty. * @throws ApiError On a transport failure or an HTTP status >= 400. */ public function request( string $method, string $url, #[\SensitiveParameter] array $opts = array() ): array { $full_url = $url; if ( ! empty( $opts['query'] ) && is_array( $opts['query'] ) ) { $full_url .= '?' . http_build_query( $opts['query'] ); } $headers = isset( $opts['headers'] ) && is_array( $opts['headers'] ) ? $opts['headers'] : array(); $body = null; if ( array_key_exists( 'json', $opts ) ) { $headers['Content-Type'] = 'application/json'; $body = wp_json_encode( $opts['json'] ); } elseif ( array_key_exists( 'form', $opts ) ) { $body = $opts['form']; } $args = array( 'method' => $method, 'timeout' => $opts['timeout'] ?? 15, 'headers' => $headers, ); if ( null !== $body ) { $args['body'] = $body; } $response = wp_remote_request( $full_url, $args ); $is_relay = $this->is_relay_request( $full_url ); if ( is_wp_error( $response ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, never echoed. throw new ApiError( $is_relay ? 'relay_unavailable' : 'google_unavailable', sanitize_text_field( $response->get_error_message() ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $status = (int) wp_remote_retrieve_response_code( $response ); $raw_body = wp_remote_retrieve_body( $response ); if ( $status >= 400 ) { $body = $this->decode_error_body( $raw_body ); if ( $is_relay ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception args, never echoed. throw $this->relay_error( $status, $body ); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception args, never echoed. throw ApiError::from_response( $status, $body ); } return $this->decode_success_body( $raw_body, $is_relay ); } /** * Maps a >=400 response from the relay host: only a 400 invalid_grant * means reauth is required; every other relay error status is transient. * * @param int $status HTTP status code. * @param array|null $body Decoded JSON response body. */ private function relay_error( int $status, ?array $body ): ApiError { $error = $body['error'] ?? null; if ( 400 === $status && is_string( $error ) && 'invalid_grant' === $error ) { return ApiError::from_response( $status, $body ); } $message = ''; if ( is_array( $body ) ) { $message = (string) ( $body['error_description'] ?? ( is_string( $error ) ? $error : '' ) ); } return new ApiError( 'relay_unavailable', sanitize_text_field( $message ), $status, $body ?? array() ); } /** * Whether a URL's host matches the relay host. * * @param string $url Full request URL. */ private function is_relay_request( string $url ): bool { $relay_host = wp_parse_url( Plugin::relay_url(), PHP_URL_HOST ); $request_host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! is_string( $relay_host ) || ! is_string( $request_host ) || '' === $relay_host ) { return false; } return 0 === strcasecmp( $relay_host, $request_host ); } /** * Decodes an error response body, treating an empty or unparsable body * as null (ApiError::from_response() and relay_error() both cope with a * null body). * * @param mixed $raw_body Raw response body. * @return array|null */ private function decode_error_body( $raw_body ): ?array { if ( ! is_string( $raw_body ) || '' === trim( $raw_body ) ) { return null; } $decoded = json_decode( $raw_body, true ); return is_array( $decoded ) ? $decoded : null; } /** * Decodes a successful (< 400) response body. A body that is empty (or * whitespace-only) means no data. A non-empty body that doesn't decode * to a JSON object or array (HTML, a bare scalar, truncated JSON) is * not usable, so it is treated as a service failure rather than * silently returned as [] or partially parsed. * * @param mixed $raw_body Raw response body. * @param bool $is_relay Whether the request went to the relay host. * @return array * @throws ApiError When a non-empty body isn't a JSON object/array. */ private function decode_success_body( $raw_body, bool $is_relay ): array { $raw_body = is_string( $raw_body ) ? trim( $raw_body ) : ''; if ( '' === $raw_body ) { return array(); } $decoded = json_decode( $raw_body, true ); if ( ! is_array( $decoded ) ) { throw new ApiError( $is_relay ? 'relay_unavailable' : 'google_unavailable', 'Unexpected response from Google.' ); } return $decoded; } }