credentials = $credentials; $this->cache = $cache; $this->group = $group; } /** * Sends a GET request. * * @param string $url Full URL including any query string. * @param int $ttl Cache lifetime in seconds; 0 disables caching. * @return array Decoded JSON. * @throws ApiException On failure. */ public function get( $url, $ttl = 0 ) { return $this->send( 'GET', $url, null, $ttl ); } /** * Sends a JSON POST request. * * @param string $url URL. * @param array $body Request body. * @param int $ttl Cache lifetime in seconds; 0 disables caching. * @return array Decoded JSON. * @throws ApiException On failure. */ public function post( $url, array $body, $ttl = 0 ) { return $this->send( 'POST', $url, $body, $ttl ); } /** * Sends a request, using the cache when allowed. * * @param string $method HTTP method. * @param string $url URL. * @param array|null $body Body for POST. * @param int $ttl Cache lifetime. * @return array * @throws ApiException On failure. */ private function send( $method, $url, $body, $ttl ) { $key = $this->credentials->id() . '|' . $method . '|' . $url . '|' . ( null === $body ? '' : wp_json_encode( $body ) ); return $this->cache->remember( $this->group, $key, (int) $ttl, function () use ( $method, $url, $body ) { return $this->dispatch( $method, $url, $body, false ); } ); } /** * Performs the HTTP call. Retries once with a fresh token after a 401. * * @param string $method HTTP method. * @param string $url URL. * @param array|null $body Body for POST. * @param bool $retried Whether this is the retry. * @return array * @throws ApiException On failure. */ private function dispatch( $method, $url, $body, $retried ) { $args = array( 'method' => $method, 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $this->credentials->access_token( $retried ), 'Accept' => 'application/json', ), ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { /* translators: %s: network error message. */ throw new ApiException( sprintf( __( 'Couldn\'t reach Google: %s', 'turtlereport' ), $response->get_error_message() ), ApiException::NETWORK ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); $valid = JSON_ERROR_NONE === json_last_error() && is_array( $data ); if ( 401 === $status && ! $retried ) { if ( $this->credentials instanceof AbstractCredentials ) { $this->credentials->forget_token(); } return $this->dispatch( $method, $url, $body, true ); } if ( $status >= 400 ) { throw ApiException::from_api( $status, $valid ? $data : array() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( ! $valid || $status < 200 || $status >= 300 ) { throw new ApiException( __( 'Google returned an unreadable response. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE, $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( isset( $data['error'] ) ) { throw ApiException::from_api( $status, $data ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } self::validate_response( $data ); return $data; } /** * Rejects malformed collections and values. Absent collections are allowed: no data is not an error. * * @param array $data Decoded Google response. * @return void * @throws ApiException When a known collection or value has an invalid shape. */ private static function validate_response( array $data ) { $collections = array( 'rows', 'siteEntry', 'accounts', 'locations', 'accountSummaries', 'propertySummaries', 'keyEvents', 'conversionEvents', 'multiDailyMetricTimeSeries', 'dailyMetricTimeSeries', 'datedValues', 'dimensionValues', 'metricValues' ); foreach ( $data as $key => $value ) { if ( in_array( $key, $collections, true ) ) { if ( ! is_array( $value ) || ( $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) { throw new ApiException( __( 'Google returned an unreadable report collection. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } foreach ( $value as $row ) { if ( ! is_array( $row ) ) { throw new ApiException( __( 'Google returned an unreadable report collection. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } } } if ( in_array( $key, array( 'nextPageToken', 'dailyMetric' ), true ) && ! is_string( $value ) ) { throw new ApiException( __( 'Google returned an unreadable report value. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( 'keys' === $key ) { if ( ! is_array( $value ) || count( array_filter( $value, 'is_string' ) ) !== count( $value ) ) { throw new ApiException( __( 'Google returned unreadable report dimensions. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } } if ( in_array( $key, array( 'value', 'clicks', 'impressions', 'ctr', 'position', 'year', 'month', 'day' ), true ) && ! is_scalar( $value ) ) { throw new ApiException( __( 'Google returned an unreadable report value. Try the report again later.', 'turtlereport' ), ApiException::GOOGLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain domain exception, not HTML output; preserve message bytes and escape at the response boundary. } if ( is_array( $value ) ) { self::validate_response( $value ); } } } }