array( 404, 'mapsemble_not_found' ), 'not_allowed' => array( 403, 'mapsemble_not_allowed' ), 'quota' => array( 429, 'mapsemble_quota' ), 'rate_limited' => array( 429, 'mapsemble_rate_limited' ), 'too_long' => array( 400, 'mapsemble_too_long' ), 'unavailable' => array( 503, 'mapsemble_unavailable' ), ); /** * API client. * * @var Client */ private Client $api; /** * Constructor. * * @param Client $api API client. */ public function __construct( Client $api ) { $this->api = $api; } /** * Registers the REST route. */ public function register_routes(): void { register_rest_route( 'mapsemble/v1', '/geocode', array( 'methods' => 'GET', 'callback' => array( $this, 'handle' ), 'permission_callback' => static fn (): bool => current_user_can( 'edit_posts' ), 'args' => array( 'q' => array( 'type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => self::MAX_LENGTH, // Named explicitly: a custom sanitizer would otherwise // switch the schema checks above off. 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Looks the address up. * * @param WP_REST_Request $request The request. */ public function handle( WP_REST_Request $request ): WP_REST_Response { $query = (string) $request->get_param( 'q' ); if ( '' === $query ) { // Only whitespace or markup, emptied by the sanitizer. return rest_convert_error_to_response( new WP_Error( 'rest_invalid_param', __( 'Enter an address to search for.', 'mapsemble' ), array( 'status' => 400 ) ) ); } // Before the cache: a disconnected site has no business answering, // not even from what it learned while it was connected. if ( ! $this->api->has_credentials() ) { return $this->error( 'unavailable' ); } $key = self::CACHE_PREFIX . md5( strtolower( trim( $query ) ) ); $cached = get_transient( $key ); if ( is_array( $cached ) && isset( $cached['lat'], $cached['lng'] ) ) { return new WP_REST_Response( $cached ); } $result = $this->api->geocode( $query ); if ( ! isset( $result['error'] ) ) { set_transient( $key, $result, DAY_IN_SECONDS ); return new WP_REST_Response( $result ); } return $this->error( $result['error'], $result['retry_after'] ?? null ); } /** * The error response for a client error code. * * A rate limit carries its wait twice: as Retry-After, and in the error * data, where the script reads it to choose between "in a minute" and * "later". * * @param string $error Client error code. * @param int|null $retry_after Seconds to wait, for a rate limit. */ private function error( string $error, ?int $retry_after = null ): WP_REST_Response { list( $status, $code ) = self::ERRORS[ $error ] ?? self::ERRORS['unavailable']; $messages = self::messages(); $message = $messages[ $code ]; $data = array( 'status' => $status ); if ( null !== $retry_after ) { $data['retry_after'] = $retry_after; if ( $retry_after > self::RETRY_SOON ) { $message = $messages['mapsemble_rate_limited_long']; } } $response = rest_convert_error_to_response( new WP_Error( $code, $message, $data ) ); if ( null !== $retry_after ) { $response->header( 'Retry-After', (string) $retry_after ); } return $response; } /** * What went wrong, in words, by REST error code. * * The one source of these strings: the endpoint answers with them, and * the location box's script receives the same table, so a message * reads the same whichever side words it. `mapsemble_rate_limited_long` * is no code of its own — it is the rate limit's wording when the wait * is past RETRY_SOON, which only the daily cap produces. * * @return array */ public static function messages(): array { return array( 'mapsemble_not_found' => __( 'No match — try adding the city.', 'mapsemble' ), 'mapsemble_not_allowed' => __( 'Address search is not included in your Mapsemble plan.', 'mapsemble' ), 'mapsemble_quota' => __( 'This month\'s address searches are used up.', 'mapsemble' ), 'mapsemble_rate_limited' => __( 'Too many address searches just now — try again in a minute.', 'mapsemble' ), 'mapsemble_rate_limited_long' => __( 'Too many address searches today — try again later.', 'mapsemble' ), 'mapsemble_too_long' => __( 'That address is too long.', 'mapsemble' ), 'mapsemble_unavailable' => __( 'Mapsemble could not be reached.', 'mapsemble' ), ); } }