settings = $settings; $this->logger = $logger; } /** * Run one report function. * * @param string $function Report function name. * @param array $body Request body. * @return Response */ public function report( string $function, array $body = array() ): Response { $settings = $this->settings->load(); if ( ! $settings->can_report() ) { return Response::failed( __( 'Reporting is not connected: set the property ID and a read-only API token.', 'adpix-analytics' ) ); } // Both parts are taken from settings rather than from the caller, so a report function name can // never widen the request beyond this one property. $url = $settings->api_base_url() . self::BASE_PATH . rawurlencode( $settings->str( 'property_id' ) ) . '/reports/' . rawurlencode( $function ); $encoded = wp_json_encode( $body ); if ( ! is_string( $encoded ) ) { return Response::failed( __( 'The report request could not be encoded as JSON.', 'adpix-analytics' ) ); } return $this->send( 'POST', $url, $encoded, $function ); } /** * List the properties the token can read. * * @return Response */ public function properties(): Response { $settings = $this->settings->load(); if ( '' === $settings->api_base_url() || '' === $settings->str( 'api_token' ) ) { return Response::failed( __( 'Reporting is not connected: set a read-only API token.', 'adpix-analytics' ) ); } return $this->send( 'GET', $settings->api_base_url() . '/v1/properties', null, 'properties' ); } /** * Make one call with the read-only token. * * @param string $method HTTP method. * @param string $url Full address. * @param string|null $body JSON body, or null for none. * @param string $function What is being asked, for the timeout filter and the log. * @return Response */ private function send( string $method, string $url, ?string $body, string $function ): Response { $settings = $this->settings->load(); if ( ! wp_http_validate_url( $url ) ) { return Response::failed( __( 'The configured AdPix API address is not valid.', 'adpix-analytics' ) ); } $headers = array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $settings->str( 'api_token' ), ); if ( null !== $body ) { $headers['Content-Type'] = 'application/json; charset=utf-8'; } $response = wp_remote_request( $url, array( 'method' => $method, 'headers' => $headers, 'body' => $body, 'timeout' => (int) apply_filters( 'adpix_analytics_report_timeout', self::TIMEOUT, $function ), 'redirection' => 0, 'httpversion' => '1.1', 'user-agent' => 'AdPixAnalyticsWP/' . ADPIX_ANALYTICS_VERSION . '; ' . home_url( '/' ), 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { $this->logger->error( 'Reporting API unreachable', array( 'function' => $function, 'code' => $response->get_error_code(), 'message' => $response->get_error_message(), ) ); return Response::failed( $response->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $result = new Response( $status, is_array( $decoded ) ? $decoded : array(), $this->message_from( $decoded ) ); if ( ! $result->is_ok() ) { $this->logger->error( 'The reporting API rejected a call', array( 'function' => $function, 'status' => $status, 'message' => $result->error(), ) ); } return $result; } /** * Pull the human part out of the reporting API's error envelope. * * That API nests its message under `error`, where Response looks for it at the top level. Lifting * it here means the screen shows "token expired" instead of "HTTP 401", which is the difference * between a site owner fixing it themselves and opening a support ticket. * * The API's own message is English. For the codes it documents, the plugin says the same thing in * the site's language instead, and keeps the code in brackets so support can still match it. * * @param mixed $decoded Decoded response body. * @return string Empty when the body carries no error. */ private function message_from( $decoded ): string { if ( ! is_array( $decoded ) || ! isset( $decoded['error'] ) || ! is_array( $decoded['error'] ) ) { return ''; } $error = $decoded['error']; $message = isset( $error['message'] ) && is_scalar( $error['message'] ) ? (string) $error['message'] : ''; $code = isset( $error['code'] ) && is_scalar( $error['code'] ) ? (string) $error['code'] : ''; $known = self::messages(); if ( isset( $known[ $code ] ) ) { $message = $known[ $code ]; } if ( '' === $message ) { return $code; } return '' !== $code ? $message . ' (' . $code . ')' : $message; } /** * The reporting API's documented error codes, in words a site owner can act on. * * @return array */ private static function messages(): array { return array( 'UNAUTHENTICATED' => __( 'The API token is not valid. Copy it again in full, or create a new one.', 'adpix-analytics' ), 'TOKEN_EXPIRED' => __( 'The API token has expired. Create a new one in your AdPix dashboard.', 'adpix-analytics' ), 'TOKEN_REVOKED' => __( 'The API token has been revoked. Create a new one in your AdPix dashboard.', 'adpix-analytics' ), 'INSUFFICIENT_SCOPE' => __( 'The API token cannot read reports. Create a new one with Reports ticked.', 'adpix-analytics' ), 'IP_NOT_ALLOWED' => __( 'The API token does not accept requests from this server\'s IP address.', 'adpix-analytics' ), 'NOT_FOUND' => __( 'The property was not found, or this token cannot read it.', 'adpix-analytics' ), 'UPGRADE_REQUIRED' => __( 'The plan of this property does not include this report.', 'adpix-analytics' ), 'METRIC_NOT_PERMITTED' => __( 'This token is not allowed to read one of the figures asked for.', 'adpix-analytics' ), 'INVALID_ARGUMENT' => __( 'AdPix did not accept the report request.', 'adpix-analytics' ), 'RATE_LIMITED' => __( 'Too many requests in the last minute. Try again shortly.', 'adpix-analytics' ), 'QUOTA_EXCEEDED' => __( 'Today\'s report budget for this property is used up. It resets tomorrow.', 'adpix-analytics' ), 'INTERNAL' => __( 'AdPix had a problem answering. Try again in a few minutes.', 'adpix-analytics' ), ); } }