$body * @return array The decoded JSON. * @throws ApiException When Canada Post cannot be reached, refuses, or answers with an error. */ public function postJson( string $path, array $body ): array { $response = $this->call( 'POST', $path, $body, 'application/json' ); return $this->decode( $path, $response['status'], $response['body'] ); } /** * @return array The decoded JSON. * @throws ApiException When Canada Post cannot be reached, refuses, or answers with an error. */ public function getJson( string $path ): array { $response = $this->call( 'GET', $path, null, 'application/json' ); return $this->decode( $path, $response['status'], $response['body'] ); } /** * A file as it comes, such as a label PDF. Errors still come back as JSON. * * @throws ApiException When Canada Post cannot be reached, refuses, or answers with an error. */ public function getBytes( string $path, string $accept = 'application/pdf' ): string { $response = $this->call( 'GET', $path, null, $accept ); if ( $response['status'] >= 200 && $response['status'] < 300 && ! self::isRateLimited( $response['status'], $response['body'] ) ) { return $response['body']; } $this->fail( $path, $response['status'], $response['body'] ); } /** * The full URL for a path, or a full URL that is Canada Post's own. * * @throws ApiException For a full URL anywhere else. */ public static function url( string $path ): string { if ( str_starts_with( $path, 'https://' ) ) { if ( ! str_starts_with( $path, self::BASE . '/' ) ) { throw new ApiException( __( 'Canada Post sent a link to an address it does not own, so it was not followed.', 'maple-parcel' ) ); } return $path; } return self::BASE . $path; } /** True for Canada Post's "Rejected by SLM Monitor" answer, or a plain 429. */ public static function isRateLimited( int $status, string $body ): bool { return 429 === $status || false !== stripos( $body, 'SLM Monitor' ); } public static function startBackOff(): void { set_transient( self::BACKOFF_TRANSIENT, 1, self::BACKOFF_SECONDS ); } /** * Canada Post's error text and codes from either error shape: the usual * {title, detail, errors[{errorCode, message}]}, or the gateway's 401 * list [{httpCode, httpMessage, moreInformation}]. * * @return array{0: string, 1: list} Message, codes. */ public static function describeErrors( mixed $data ): array { if ( ! is_array( $data ) ) { return array( '', array() ); } if ( array_is_list( $data ) && isset( $data[0] ) && is_array( $data[0] ) ) { $data = $data[0]; } $codes = array(); $messages = array(); $errors = isset( $data['errors'] ) && is_array( $data['errors'] ) ? $data['errors'] : array(); foreach ( $errors as $error ) { if ( ! is_array( $error ) ) { continue; } $code = is_scalar( $error['errorCode'] ?? null ) ? (string) $error['errorCode'] : ''; $message = is_scalar( $error['message'] ?? null ) ? (string) $error['message'] : ''; if ( '' !== $code ) { $codes[] = $code; } if ( '' !== $message ) { $messages[] = '' !== $code ? "[{$code}] {$message}" : $message; } } if ( array() === $messages ) { foreach ( array( 'detail', 'moreInformation', 'errorDescription', 'error_description', 'errorMessage', 'title', 'httpMessage', 'error' ) as $key ) { if ( isset( $data[ $key ] ) && is_string( $data[ $key ] ) && '' !== $data[ $key ] ) { $messages[] = $data[ $key ]; break; } } } return array( implode( ' ', $messages ), $codes ); } /** * The body as JSON, with every float in its shortest exact form whatever the * host's serialize_precision. Some hosts (Local among them) set it to 17, * which sends 2.4 kg as 2.3999999999999999, and Canada Post refuses that * with a 400 "Schema validation Error". * * @param array $body */ public static function encode( array $body ): string { $precision = ini_get( 'serialize_precision' ); ini_set( 'serialize_precision', '-1' ); // phpcs:ignore WordPress.PHP.IniSet.Risky, Squiz.PHP.DiscouragedFunctions.Discouraged -- put back straight after; see above. try { return (string) wp_json_encode( $body ); } finally { ini_set( 'serialize_precision', false === $precision ? '-1' : $precision ); // phpcs:ignore WordPress.PHP.IniSet.Risky, Squiz.PHP.DiscouragedFunctions.Discouraged -- the host's value, back as it was. } } /** * Sends with the cached token, and once more with a fresh one after a 401. * * @param array|null $body * @return array{status: int, body: string} * @throws ApiException During the back-off, when signing in fails, or when the request gets no answer. */ private function call( string $method, string $path, ?array $body, string $accept ): array { $url = self::url( $path ); if ( false !== get_transient( self::BACKOFF_TRANSIENT ) ) { throw new RateLimitedException( self::isRating( $path ) ? __( 'Canada Post asked this store to slow down. Rates will be tried again in a minute.', 'maple-parcel' ) : __( 'Canada Post asked this store to slow down. Wait a minute and try again.', 'maple-parcel' ) ); } $response = $this->send( $method, $url, $body, $accept, $this->tokens->token() ); if ( 401 === $response['status'] ) { $this->logger->debug( 'HTTP 401 with the cached token. Fetching a new token and retrying once.' ); $this->tokens->forget(); $response = $this->send( $method, $url, $body, $accept, $this->tokens->token() ); if ( 401 === $response['status'] ) { throw new AuthException( __( 'Canada Post refused a fresh sign-in token (HTTP 401). Check the key, secret and Environment in the Maple Parcel settings.', 'maple-parcel' ), 401 ); } } return $response; } /** * @param array|null $body * @return array{status: int, body: string} * @throws TransportException When the request does not get an answer. */ private function send( string $method, string $url, ?array $body, string $accept, string $token ): array { $this->space(); $path = substr( $url, strlen( self::BASE ) ); $args = array( 'method' => $method, 'timeout' => $this->timeout, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Accept' => $accept, 'Accept-Language' => $this->language, ), ); if ( null === $body ) { $this->logger->debug( "{$method} {$path}" ); } else { $json = self::encode( $body ); $this->logger->debug( "{$method} {$path} {$json}" ); $args['headers']['Content-Type'] = 'application/json'; $args['body'] = $json; $args['data_format'] = 'body'; } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { throw new TransportException( /* translators: %s: the HTTP error, e.g. a timeout. */ sprintf( __( 'Could not reach Canada Post: %s', 'maple-parcel' ), $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); // A label is a file, not something to read in a log. if ( 'application/json' !== $accept && $status >= 200 && $status < 300 ) { $this->logger->debug( "HTTP {$status} (" . strlen( $raw ) . ' bytes)' ); } else { $this->logger->debug( "HTTP {$status} " . ( strlen( $raw ) > 4000 ? substr( $raw, 0, 4000 ) . '...' : $raw ) ); } return array( 'status' => $status, 'body' => $raw, ); } /** * @return array * @throws TransportException For a 2xx that is not JSON. * @throws ApiException For any other answer that is not a 2xx. */ private function decode( string $path, int $status, string $raw ): array { if ( $status >= 200 && $status < 300 && ! self::isRateLimited( $status, $raw ) ) { $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { throw new TransportException( __( 'Canada Post sent an answer that was not the JSON expected.', 'maple-parcel' ), $status ); } return $data; } $this->fail( $path, $status, $raw ); } /** * Turns an answer that is not a success into the right exception. * * @throws ApiException Always. */ private function fail( string $path, int $status, string $raw ): never { $rating = self::isRating( $path ); if ( self::isRateLimited( $status, $raw ) ) { self::startBackOff(); throw new RateLimitedException( $rating ? __( 'Canada Post is limiting requests from this store. Rates will be tried again in a minute.', 'maple-parcel' ) : __( 'Canada Post is limiting requests from this store. Wait a minute and try again.', 'maple-parcel' ), $status ); } if ( 403 === $status ) { throw new AuthException( sprintf( /* translators: %s: the API's name, e.g. Rating API. */ __( 'Canada Post refused this request (HTTP 403). In the Developer Portal, check that your app is subscribed to the %s.', 'maple-parcel' ), $rating ? __( 'Rating API', 'maple-parcel' ) : __( 'Shipping API', 'maple-parcel' ) ), 403 ); } if ( $status >= 500 || $status < 400 ) { throw new TransportException( $rating /* translators: %d: HTTP status code. */ ? sprintf( __( 'Canada Post is not answering normally (HTTP %d). Rates will be tried again shortly.', 'maple-parcel' ), $status ) /* translators: %d: HTTP status code. */ : sprintf( __( 'Canada Post is not answering normally (HTTP %d). Try again shortly.', 'maple-parcel' ), $status ), $status ); } [ $message, $codes ] = self::describeErrors( json_decode( $raw, true ) ); if ( '' === $message ) { /* translators: %d: HTTP status code. */ $message = sprintf( __( 'Canada Post returned HTTP %d.', 'maple-parcel' ), $status ); } throw new ApiException( $message, $status, $codes ); } /** Rating calls happen at checkout and are retried on their own; others are a merchant's button. */ private static function isRating( string $path ): bool { return str_contains( $path, '/rating/' ); } /** Waits out whatever is left of the gap since the last call in this request. */ private function space(): void { $gap = (int) apply_filters( 'bbmmp_request_spacing_ms', self::SPACING_MS ); $wait = self::$lastCall + $gap / 1000 - microtime( true ); if ( $wait > 0 ) { usleep( (int) ( $wait * 1000000 ) ); } self::$lastCall = microtime( true ); } }