*/ private array $live = array(); public function __construct( Razorbooking_Credentials $credentials ) { $this->credentials = $credentials; } public static function base_url(): string { return untrailingslashit( RAZORBOOKING_APP_URL ) . self::BASE_PATH; } /** * A GET, uncached. * * @return array|WP_Error */ public function get( Razorbooking_Endpoint $endpoint ) { return $this->request( 'GET', $endpoint ); } /** * A GET that is never stored: for responses with customers' personal * data, such as bookings. Reused within the current request only. * * @return array|WP_Error */ public function get_live( Razorbooking_Endpoint $endpoint ) { $key = $endpoint->copy_key(); if ( ! isset( $this->live[ $key ] ) ) { $response = $this->get( $endpoint ); if ( is_wp_error( $response ) ) { return $response; } $this->live[ $key ] = $response; } return $this->live[ $key ]; } /** @return array|WP_Error */ public function post( Razorbooking_Endpoint $endpoint, Razorbooking_Request_Body $body ) { return $this->request( 'POST', $endpoint, $body ); } /** @return array|WP_Error */ public function put( Razorbooking_Endpoint $endpoint, Razorbooking_Request_Body $body ) { return $this->request( 'PUT', $endpoint, $body ); } /** @return array|WP_Error */ public function patch( Razorbooking_Endpoint $endpoint, ?Razorbooking_Request_Body $body = null ) { return $this->request( 'PATCH', $endpoint, $body ); } /** @return array|WP_Error */ public function delete( Razorbooking_Endpoint $endpoint ) { return $this->request( 'DELETE', $endpoint ); } /** * Who a key belongs to (GET /me), for a key that isn't stored: a pasted * one, checked before it replaces anything. Changes nothing on this site; * a refused key is that key's problem, not the connection's. * * @return array|WP_Error */ public function identify( string $key ) { return $this->request( 'GET', Razorbooking_Endpoint::me(), null, $key ); } /** * One request to RazorBooking. * * @param string $method GET, POST, PUT, PATCH or DELETE (the methods above). * @param string|null $key A key to try instead of the stored one (identify()). * @return array|WP_Error */ private function request( string $method, Razorbooking_Endpoint $endpoint, ?Razorbooking_Request_Body $body = null, ?string $key = null ) { $trying = null !== $key; $key = $trying ? $key : $this->credentials->key(); if ( '' === $key ) { return new WP_Error( Razorbooking_Error_Code::NOT_CONNECTED, __( 'This site isn’t connected to RazorBooking.', 'razorbooking' ), array( 'status' => 401 ) ); } $args = array( 'method' => $method, 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', 'Content-Type' => 'application/json', // Lets support find this install without asking for the key. 'X-RB-Client' => (string) get_option( Razorbooking_Option::SITE_UUID, '' ), ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $endpoint->url( self::base_url() ), $args ); if ( is_wp_error( $response ) ) { return new WP_Error( Razorbooking_Error_Code::UNREACHABLE, __( 'Couldn’t reach RazorBooking. Try again in a moment.', 'razorbooking' ), array( 'status' => 503, 'detail' => $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $parsed = json_decode( wp_remote_retrieve_body( $response ), true ); // Only 401 means the key is gone. A 403 means this key may not do this one thing. if ( 401 === $status ) { // The stored key, not one being tried. if ( ! $trying ) { do_action( Razorbooking_Hook::KEY_REFUSED ); } return new WP_Error( Razorbooking_Error_Code::REVOKED, __( 'This site isn’t connected to RazorBooking anymore.', 'razorbooking' ), array( 'status' => $status ) ); } if ( 429 === $status ) { return self::rate_limited( (int) wp_remote_retrieve_header( $response, 'retry-after' ) ); } if ( $status < 200 || $status >= 300 ) { return self::refusal( $status, Razorbooking_Raw_Data::of( $parsed ) ); } return is_array( $parsed ) ? $parsed : array(); } /** * Too many requests: RazorBooking's limit is per key, and `Retry-After` * says how many seconds until the next one is accepted. */ private static function rate_limited( int $retry_after ): WP_Error { $retry_after = max( 1, $retry_after ); return new WP_Error( Razorbooking_Error_Code::RATE_LIMITED, sprintf( /* translators: %d: seconds to wait */ _n( 'RazorBooking is busy. Try again in %d second.', 'RazorBooking is busy. Try again in %d seconds.', $retry_after, 'razorbooking' ), $retry_after ), array( 'status' => 429, 'retry_after' => $retry_after ) ); } /** * A refused request as an error, with RazorBooking's code and message * kept only when they look like one: the admin screens choose what to * show by the code. */ private static function refusal( int $status, Razorbooking_Raw_Data $body ): WP_Error { $error = $body->child( 'error' ); $message = __( 'RazorBooking didn’t accept that request.', 'razorbooking' ); $code = Razorbooking_Error_Code::API_ERROR; if ( '' !== $error->string( 'message' ) ) { $message = self::upstream_text( $error->string( 'message' ), $message ); $code = self::upstream_code( $error->string( 'code' ), $code ); } elseif ( '' !== $body->string( 'message' ) ) { // Laravel validation errors. $message = self::upstream_text( $body->string( 'message' ), $message ); } return new WP_Error( $code, $message, array( 'status' => $status, 'errors' => $body->value( 'errors' ) ) ); } /** An error code from RazorBooking: lowercase letters, digits and `_`, up to 64; else the fallback. */ private static function upstream_code( string $code, string $fallback ): string { return preg_match( '/^[a-z0-9_]{1,64}$/', $code ) ? $code : $fallback; } /** An error message from RazorBooking as plain text, up to 300 characters; else the fallback. */ private static function upstream_text( string $text, string $fallback ): string { $text = trim( wp_strip_all_tags( $text ) ); return '' === $text ? $fallback : mb_substr( $text, 0, 300 ); } }