Making WordPress.org

Changeset 9100


Ignore:
Timestamp:
08/13/2019 09:08:02 PM (7 years ago)
Author:
coreymckrill
Message:

Official WordPress Events: Refactor meetup fetching for upcoming API changes

The Meetup.com API announced changes that will take effect on 2019-08-15. The
API key method of authentication will no longer work, and the entire v2 of the
API will be deactivated in favor of v3. So:

  • Add an OAuth2 client for authentication (copied from WordCamp)
  • Copy updated version of Meetup_Client from WordCamp and refactor usage of its get_events method to reflect the change to v3 endpoints.
  • Add a generic API_Client class (copied from WordCamp) for making repeated remote requests. This is already used by the Meetup_Client class, but now it'll be used for the requests to the WordCamp REST API endpoint and Google Maps as well.
Location:
sites/trunk/wordpress.org/public_html/wp-content/plugins/official-wordpress-events
Files:
2 added
2 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/official-wordpress-events/meetup/class-meetup-client.php

    r9091 r9100  
    11<?php
    2 
    32namespace WordCamp\Utilities;
     3
    44use WP_Error;
    55
    … …  
    99 * Class Meetup_Client
    1010 *
    11  * TODO Refactor this to use the API_Client base class.
     11 * Important: This class and its dependency classes are used in multiple locations in the WordPress/WordCamp
     12 * ecosystem. Because of complexities around SVN externals and the reliability of GitHub's SVN bridge during deploys,
     13 * it was decided to maintain multiple copies of these files rather than have SVN externals pointing to one canonical
     14 * source.
     15 *
     16 * If you make changes to this file, make sure they are propagated to the other locations:
     17 *
     18 * - wordcamp: wp-content/mu-plugins/utilities
     19 * - wporg: wp-content/plugins/official-wordpress-events/meetup
    1220 */
    13 class Meetup_Client {
     21class Meetup_Client extends API_Client {
    1422        /**
    1523         * @var string The base URL for the API endpoints.
    … …  
    1826
    1927        /**
    20          * @var string The API key.
    21          */
    22         protected $api_key = '';
     28         * @var Meetup_OAuth2_Client|null
     29         */
     30        protected $oauth_client = null;
    2331
    2432        /**
    2533         * @var bool If true, the client will fetch fewer results, for faster debugging.
    2634         */
    27         protected $debug_mode;
    28 
    29         /**
    30          * @var WP_Error|null Container for errors.
    31          */
    32         public $error = null;
     35        protected $debug = false;
    3336
    3437        /**
    3538         * Meetup_Client constructor.
    36          */
    37         public function __construct() {
    38                 $this->error = new WP_Error();
    39 
    40                 if ( defined( 'MEETUP_API_KEY' ) ) {
    41                         $this->api_key = MEETUP_API_KEY;
    42                 } else {
    43                         $this->error->add(
    44                                 'api_key_undefined',
    45                                 'The Meetup.com API Key is undefined.'
    46                         );
    47                 }
    48 
    49                 $this->debug_mode = apply_filters( 'wcmc_debug_mode', false );
     39         *
     40         * @param array $settings {
     41         *     Optional. Settings for the client.
     42         *
     43         *     @type bool $debug If true, the client will fetch fewer results, for faster debugging.
     44         * }
     45         */
     46        public function __construct( array $settings = [] ) {
     47                parent::__construct( array(
     48                        /*
     49                         * Response codes that should break the request loop.
     50                         *
     51                         * See https://www.meetup.com/meetup_api/docs/#errors.
     52                         *
     53                         * `200` (ok) is not in the list, because it needs to be handled conditionally.
     54                         *  See API_Client::tenacious_remote_request.
     55                         *
     56                         * `400` (bad request) is not in the list, even though it seems like it _should_ indicate an unrecoverable
     57                         * error. In practice we've observed that it's common for a seemingly valid request to be rejected with
     58                         * a `400` response, but then get a `200` response if that exact same request is retried.
     59                         */
     60                        'breaking_response_codes' => array(
     61                                401, // Unauthorized (invalid key).
     62                                429, // Too many requests (rate-limited).
     63                                404, // Unable to find group
     64                        ),
     65                        'throttle_callback'       => array( __CLASS__, 'throttle' ),
     66                ) );
     67
     68                $settings = wp_parse_args(
     69                        $settings,
     70                        array(
     71                                'debug' => false,
     72                        )
     73                );
     74
     75                $this->oauth_client = new Meetup_OAuth2_Client;
     76                $this->debug        = $settings['debug'];
     77
     78                if ( $this->debug ) {
     79                        self::cli_message( "Meetup Client debug is ON. Results will be truncated." );
     80                }
    5081        }
    5182
    … …  
    6899
    69100                while ( $request_url ) {
    70                         $request_url = $this->sign_request_url( $request_url );
    71 
    72                         $response = $this->tenacious_remote_get( $request_url );
     101                        $response = $this->tenacious_remote_get( $request_url, $this->get_request_args() );
    73102
    74103                        if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
    … …  
    98127                        }
    99128
    100                         if ( $this->debug_mode ) {
     129                        if ( $this->debug ) {
    101130                                break;
    102131                        }
    … …  
    125154                ), $request_url );
    126155
    127                 $request_url = $this->sign_request_url( $request_url );
    128 
    129                 $response = $this->tenacious_remote_get( $request_url );
     156                $response = $this->tenacious_remote_get( $request_url, $this->get_request_args() );
    130157
    131158                if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
    … …  
    152179
    153180        /**
    154          * Wrapper for `wp_remote_get` to retry requests that fail temporarily for various reasons.
    155          *
    156          * One common example of a reason a request would fail, but later succeed, is when the first request times out.
    157          *
    158          * Based on `wcorg_redundant_remote_get`.
    159          *
    160          * @param string $url
    161          * @param array  $args
    162          *
    163          * @return array|WP_Error
    164          */
    165         protected function tenacious_remote_get( $url, $args = array() ) {
    166                 $attempt_count = 0;
    167                 $max_attempts  = 3;
    168 
    169                 /*
    170                  * Response codes that should break the loop.
    171                  *
    172                  * See https://www.meetup.com/meetup_api/docs/#errors.
    173                  *
    174                  * `200` (ok) is not in the list, because it needs to be handled conditionally. See below.
    175                  *
    176                  * `400` (bad request) is not in the list, even though it seems like it _should_ indicate an unrecoverable
    177                  * error. In practice we've observed that it's common for a seemingly valid request to be rejected with
    178                  * a `400` response, but then get a `200` response if that exact same request is retried.
    179                  */
    180                 $breaking_codes = array(
    181                         401, // Unauthorized (invalid key).
    182                         429, // Too many requests (rate-limited).
    183                         404, // Unable to find group
     181         * Generate headers to use in a request.
     182         *
     183         * @return array
     184         */
     185        protected function get_request_args() {
     186                $oauth_token = $this->oauth_client->get_oauth_token();
     187
     188                return array(
     189                        'headers' => array(
     190                                'Accept'        => 'application/json',
     191                                'Authorization' => "Bearer $oauth_token",
     192                        ),
    184193                );
    185 
    186                 // The default of 5 seconds often results in frequent timeouts.
    187                 if ( empty( $args['timeout'] ) ) {
    188                         $args['timeout'] = 15;
    189                 }
    190 
    191                 while ( $attempt_count < $max_attempts ) {
    192                         $response      = wp_remote_get( $url, $args );
    193                         $response_code = wp_remote_retrieve_response_code( $response );
    194 
    195                         $this->maybe_throttle( wp_remote_retrieve_headers( $response ) );
    196 
    197                         /*
    198                          * Sometimes their API inexplicably returns a success code with an empty body, but will return a valid
    199                          * response if the exact request is retried.
    200                          */
    201                         if ( 200 === $response_code && ! empty( wp_remote_retrieve_body( $response ) ) ) {
    202                                 break;
    203                         }
    204 
    205                         if ( in_array( $response_code, $breaking_codes, true ) ) {
    206                                 break;
    207                         }
    208 
    209                         $attempt_count++;
    210 
    211                         /**
    212                          * Action: Fires when tenacious_remote_get fails a request attempt.
    213                          *
    214                          * Note that the request parameter includes the request URL that contains a query string for the API key.
    215                          * This should be redacted before outputting anywhere public.
    216                          *
    217                          * @param array $response
    218                          * @param array $request
    219                          * @param int   $attempt_count
    220                          * @param int   $max_attempts
    221                          */
    222                         do_action( 'meetup_client_tenacious_remote_get_attempt', $response, compact( 'url', 'args' ), $attempt_count, $max_attempts );
    223 
    224                         if ( $attempt_count < $max_attempts ) {
    225                                 $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ) ?: 5;
    226                                 $wait        = min( $retry_after * $attempt_count, 30 );
    227 
    228                                 if ( 'cli' === php_sapi_name() ) {
    229                                         echo "\nRequest failed $attempt_count times. Pausing for $wait seconds before retrying.";
    230                                 }
    231 
    232                                 sleep( $wait );
    233                         }
    234                 }
    235 
    236                 if ( $attempt_count === $max_attempts && 'cli' === php_sapi_name() ) {
    237                         if ( 200 !== $response_code || is_wp_error( $response ) ) {
    238                                 echo "\nRequest failed $attempt_count times. Giving up.";
    239                         }
    240                 }
    241 
    242                 return $response;
    243         }
    244 
    245         /**
    246          * Sign a request URL with our API key.
    247          *
    248          * @param string $request_url
    249          *
    250          * @return string
    251          */
    252         protected function sign_request_url( $request_url ) {
    253                 return add_query_arg( array(
    254                         'sign' => true,
    255                         'key'  => $this->api_key,
    256                 ), $request_url );
    257194        }
    258195
    … …  
    298235         *
    299236         * @param array $headers
    300          */
    301         protected function maybe_throttle( $headers ) {
     237         *
     238         * @return void
     239         */
     240        protected static function throttle( $response ) {
     241                $headers = wp_remote_retrieve_headers( $response );
     242
    302243                if ( ! isset( $headers['x-ratelimit-remaining'], $headers['x-ratelimit-reset'] ) ) {
    303244                        return;
    … …  
    305246
    306247                $remaining = absint( $headers['x-ratelimit-remaining'] );
    307                 $period    = absint( $headers['x-ratelimit-reset'    ] );
    308 
    309                 // Pause more frequently than we need to, and for longer, just to be safe.
    310                 if ( $remaining > 2 ) {
     248                $period    = absint( $headers['x-ratelimit-reset'] );
     249
     250                /**
     251                 * Don't throttle if we have sufficient requests remaining.
     252                 *
     253                 * We don't let this number get to 0, though, because there are scenarios where multiple processes are using
     254                 * the API at the same time, and there's no way for them to be aware of each other.
     255                 */
     256                if ( $remaining > 3 ) {
    311257                        return;
    312258                }
    313259
     260                // Pause for longer than we need to, just to be safe.
    314261                if ( $period < 2 ) {
    315262                        $period = 2;
    316263                }
    317264
    318                 if ( 'cli' === php_sapi_name() ) {
    319                         echo "\nPausing for $period seconds to avoid rate-limiting.";
    320                 }
     265                self::cli_message( "\nPausing for $period seconds to avoid rate-limiting." );
    321266
    322267                sleep( $period );
    … …  
    326271         * Extract error information from an API response and add it to our error handler.
    327272         *
     273         * Make sure you don't include the full $response in the error as data, as that could expose sensitive information
     274         * from the request payload.
     275         *
    328276         * @param array|WP_Error $response
    329277         *
    330278         * @return void
    331279         */
    332         protected function handle_error_response( $response ) {
    333                 if ( is_wp_error( $response ) ) {
    334                         $codes = $response->get_error_codes();
    335 
    336                         foreach ( $codes as $code ) {
    337                                 $messages = $response->get_error_messages( $code );
    338 
    339                                 foreach ( $messages as $message ) {
    340                                         $this->error->add( $code, $message );
    341                                 }
    342                         }
    343 
     280        public function handle_error_response( $response ) {
     281                if ( parent::handle_error_response( $response ) ) {
    344282                        return;
    345283                }
    … …  
    350288                if ( isset( $data['errors'] ) ) {
    351289                        foreach ( $data['errors'] as $error ) {
    352                                 $this->error->add( $error['code'], $error['message'] );
     290                                $this->error->add(
     291                                        $error['code'],
     292                                        $error['message']
     293                                );
    353294                        }
    354295                } elseif ( isset( $data['code'] ) && isset( $data['details'] ) ) {
    355                         $this->error->add( $data['code'], $data['details'] );
     296                        $this->error->add(
     297                                $data['code'],
     298                                $data['details']
     299                        );
    356300                } elseif ( $response_code ) {
    357301                        $this->error->add(
    … …  
    360304                        );
    361305                } else {
    362                         $this->error->add( 'unknown_error', 'There was an unknown error.' );
     306                        $this->error->add(
     307                                'unknown_error',
     308                                'There was an unknown error.'
     309                        );
    363310                }
    364311        }
    … …  
    385332         * Retrieve data about events associated with a set of groups.
    386333         *
    387          * This automatically breaks up requests into chunks of 50 groups to avoid overloading the API.
    388          *
    389          * @param array $group_ids The IDs of the groups to get events for.
    390          * @param array $args      Optional. Additional request parameters.
    391          *                         See https://www.meetup.com/meetup_api/docs/2/events/.
     334         * Because of the way that the Meetup API v3 endpoints are structured, we unfortunately have to make one request
     335         * (or more, if there's pagination) for each group that we want events for. When there are hundreds of groups, and
     336         * we are throttling to make sure we don't get rate-limited, this process can literally take several minutes.
     337         *
     338         * So, when building the array for the $group_slugs parameter, it's important to filter out groups that you know
     339         * will not provide relevant results. For example, if you want all events during a date range in the past, you can
     340         * filter out groups that didn't join the chapter program until after your date range.
     341         *
     342         * Note that when using date/time related parameters in the $args array, unlike other endpoints and fields in the
     343         * Meetup API which use an epoch timestamp in milliseconds, this one requires a date/time string formatted in
     344         * ISO 8601, without the timezone part. Because consistency is overrated.
     345         *
     346         * @param array $group_slugs The URL slugs of each group to retrieve events for. Also known as `urlname`.
     347         * @param array $args        Optional. Additional request parameters.
     348         *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/#list
    392349         *
    393350         * @return array|WP_Error
    394351         */
    395         public function get_events( array $group_ids, array $args = array() ) {
    396                 $url_base     = $this->api_base . '2/events';
    397                 $group_chunks = array_chunk( $group_ids, 50, true ); // Meetup API sometimes throws an error with chunk size larger than 50.
    398                 $events       = array();
    399 
    400                 foreach ( $group_chunks as $chunk ) {
    401                         $query_args = array_merge( array(
    402                                 'group_id' => implode( ',', $chunk ),
    403                         ), $args );
    404 
    405                         $request_url = add_query_arg( $query_args, $url_base );
    406 
    407                         $data = $this->send_paginated_request( $request_url );
    408 
    409                         if ( is_wp_error( $data ) ) {
    410                                 return $data;
    411                         }
    412 
    413                         $events = array_merge( $events, $data );
     352        public function get_events( array $group_slugs, array $args = array() ) {
     353                $events = array();
     354
     355                if ( $this->debug ) {
     356                        $chunked     = array_chunk( $group_slugs, 10 );
     357                        $group_slugs = $chunked[0];
     358                }
     359
     360                foreach ( $group_slugs as $group_slug ) {
     361                        $response = $this->get_group_events( $group_slug, $args );
     362
     363                        if ( is_wp_error( $response ) ) {
     364                                return $response;
     365                        }
     366
     367                        $events = array_merge( $events, $response );
    414368                }
    415369
    … …  
    418372
    419373        /**
    420          * Retrieve data about the group. Calls https://www.meetup.com/meetup_api/docs/:urlname/#get
     374         * Retrieve details about a group.
    421375         *
    422376         * @param string $group_slug The slug/urlname of a group.
    423          * @param array $args Optional. Additional request parameters.
     377         * @param array  $args       Optional. Additional request parameters.
     378         *                           See https://www.meetup.com/meetup_api/docs/:urlname/#get
    424379         *
    425380         * @return array|WP_Error
    426381         */
    427         public function get_group_details ( $group_slug, $args = array() ) {
    428                 $request_url = $this->api_base . "$group_slug";
     382        public function get_group_details( $group_slug, $args = array() ) {
     383                $request_url = $this->api_base . sanitize_key( $group_slug );
    429384
    430385                if ( ! empty( $args ) ) {
    … …  
    436391
    437392        /**
    438          * Retrieve group members. Calls https://www.meetup.com/meetup_api/docs/:urlname/members/#list
     393         * Retrieve details about group members.
    439394         *
    440395         * @param string $group_slug The slug/urlname of a group.
    441          * @param array $args Optional. Additional request parameters.
     396         * @param array  $args       Optional. Additional request parameters.
     397         *                           See https://www.meetup.com/meetup_api/docs/:urlname/members/#list
    442398         *
    443399         * @return array|WP_Error
    444400         */
    445         public function get_group_members ( $group_slug, $args = array() ) {
    446                 $request_url = $this->api_base . "$group_slug/members";
     401        public function get_group_members( $group_slug, $args = array() ) {
     402                $request_url = $this->api_base . sanitize_key( $group_slug ) . '/members';
    447403
    448404                if ( ! empty( $args ) ) {
    … …  
    458414         * @param string $group_slug The slug/urlname of a group.
    459415         * @param array  $args       Optional. Additional request parameters.
    460          *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/.
     416         *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/#list
    461417         *
    462418         * @return array|WP_Error
    463419         */
    464420        public function get_group_events( $group_slug, array $args = array() ) {
    465                 $request_url = $this->api_base . "$group_slug/events";
     421                $request_url = $this->api_base . sanitize_key( $group_slug ) . '/events';
    466422
    467423                if ( ! empty( $args ) ) {
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/official-wordpress-events/official-wordpress-events.php

    r9091 r9100  
    88*/
    99
     10use WordCamp\Utilities\API_Client;
    1011use WordCamp\Utilities\Meetup_Client;
    1112
    … …  
    3839         */
    3940        public function __construct() {
    40                 add_action( 'wp_enqueue_scripts',           array( $this, 'enqueue_scripts'    ) );
    41                 add_action( 'owpe_prime_events_cache',      array( $this, 'prime_events_cache' ) );
    42                 add_action( 'owpe_mark_deleted_meetups',    array( $this, 'mark_deleted_meetups' ) );
     41                add_action( 'wp_enqueue_scripts',               array( $this, 'enqueue_scripts'       ) );
     42                add_action( 'owpe_prime_events_cache',          array( $this, 'prime_events_cache'    ) );
     43                add_action( 'owpe_mark_deleted_meetups',        array( $this, 'mark_deleted_meetups'  ) );
     44                add_action( 'api_client_handle_error_response', array( $this, 'handle_error_response' ), 10, 3 );
     45
    4346                add_shortcode( 'official_wordpress_events', array( $this, 'render_events'      ) );
    4447
    … …  
    5053                        wp_schedule_event( time(), 'hourly', 'owpe_mark_deleted_meetups' );
    5154                }
     55        }
     56
     57        /**
     58         * Get an instance of the generic API Client, loading files first as necessary.
     59         *
     60         * @return API_Client
     61         */
     62        protected function get_api_client() {
     63                if ( ! class_exists( '\WordCamp\Utilities\API_Client' ) ) {
     64                        $files = array(
     65                                'class-api-client.php',
     66                        );
     67
     68                        foreach ( $files as $file ) {
     69                                require_once trailingslashit( __DIR__ ) . "meetup/$file";
     70                        }
     71                }
     72
     73                return new API_Client();
    5274        }
    5375
    … …  
    6082                if ( ! class_exists( '\WordCamp\Utilities\Meetup_Client' ) ) {
    6183                        $files = array(
     84                                'class-api-client.php',
     85                                'class-meetup-oauth2-client.php',
    6286                                'class-meetup-client.php',
    6387                        );
    … …  
    209233         */
    210234        protected function fetch_upcoming_events() {
    211                 $events = array_merge( $this->get_wordcamp_events(), $this->get_meetup_events() );
     235                $wordcamp_events = $this->get_wordcamp_events();
     236                $meetup_events   = $this->get_meetup_events();
     237
     238                $events = array_merge( $wordcamp_events, $meetup_events );
    212239
    213240                return $events;
    … …  
    239266         */
    240267        protected function get_wordcamp_events() {
    241                 $request_params = array(
     268                $api_client = $this->get_api_client();
     269
     270                // Note: With the number of WordCamps per year growing fast, we may need to batch requests in the near future, like we do for meetups.
     271                $request_url = add_query_arg( array(
    242272                        'status'   => array( 'wcpt-scheduled', 'wcpt-cancelled' ),
    243273                        'per_page' => 100,
    244                         // Note: With the number of WordCamps per year growing fast, we may need to batch requests in the near future, like we do for meetups
    245                 );
    246 
    247                 $endpoint = add_query_arg( $request_params, self::WORDCAMP_API_BASE_URL . 'wp/v2/wordcamps' );
    248                 $response = $this->remote_get( esc_url_raw( $endpoint ) );
    249                 $events   = $this->parse_wordcamp_events( $response );
     274                ), self::WORDCAMP_API_BASE_URL . 'wp/v2/wordcamps' );
     275
     276                $response = $api_client->tenacious_remote_get( $request_url );
     277
     278                $api_client->handle_error_response( $response, $request_url );
     279
     280                $events = $this->parse_wordcamp_events( $response );
    250281
    251282                $this->log( sprintf( 'returning %d events', count( $events ) ) );
    … …  
    339370                $events = array();
    340371
    341                 $client = $this->get_meetup_client();
    342                 if ( ! empty( $client->error->errors ) ) {
    343                         $this->log( 'Failed to instantiate meetup client: ' . wp_json_encode( $client->error ), true );
     372                // Fetching events for a large number of groups from the Meetup API is currently a very inefficient process.
     373                ini_set( 'memory_limit', '900M' );
     374                ini_set( 'max_execution_time', 500 );
     375
     376                $meetup_client = $this->get_meetup_client();
     377                if ( ! empty( $meetup_client->error->errors ) ) {
     378                        $this->log( 'Failed to instantiate meetup client: ' . wp_json_encode( $meetup_client->error ), true );
    344379                        return $events;
    345380                }
    346381
    347                 $groups = $client->get_groups();
    348                 if ( ! empty( $client->error->errors ) ) {
    349                         $this->log( 'Failed to fetch groups: ' . wp_json_encode( $client->error ), true );
     382                $groups = $meetup_client->get_groups();
     383                if ( ! empty( $meetup_client->error->errors ) ) {
     384                        $this->log( 'Failed to fetch groups: ' . wp_json_encode( $meetup_client->error ), true );
    350385                        return $events;
    351386                }
    352387
    353                 $meetups = $client->get_events( wp_list_pluck( $groups, 'id' ) );
    354                 if ( ! empty( $client->error->errors ) ) {
    355                         $this->log( 'Failed to fetch meetups: ' . wp_json_encode( $client->error ), true );
     388                $yesterday    = date( 'c', strtotime( '-1 day' ) );
     389                $one_year_out = date( 'c', strtotime( '+1 year' ) );
     390                $meetups      = $meetup_client->get_events(
     391                        wp_list_pluck( $groups, 'urlname' ),
     392                        array(
     393                                // We want cancelled events too so they will be updated in our database table.
     394                                'status'          => 'upcoming,cancelled',
     395                                // We don't want cancelled events in the past, but need some leeway here for timezones.
     396                                'no_earlier_than' => substr( $yesterday, 0, strpos( $yesterday, '+' ) ),
     397                                // We don't need to cache events happening more than a year from now.
     398                                'no_later_than'   => substr( $one_year_out, 0, strpos( $one_year_out, '+' ) ),
     399                        )
     400                );
     401                if ( ! empty( $meetup_client->error->errors ) ) {
     402                        $this->log( 'Failed to fetch meetups: ' . wp_json_encode( $meetup_client->error ), true );
    356403                        return $events;
    357404                }
    358405
    359                                         foreach ( $meetups as $meetup ) {
    360                                                 if ( empty( $meetup['id'] ) || empty( $meetup['name'] ) ) {
    361                                                         $this->log( 'Malformed meetup: ' . wp_json_encode( $meetup ) );
    362                                                         continue;
    363                                                 }
    364 
    365                                                 $start_timestamp = ( $meetup['time'] / 1000 ) + ( $meetup['utc_offset'] / 1000 );    // convert to seconds
    366 
    367                                                 if ( isset( $meetup['venue'] ) ) {
    368                                                         $location = $this->format_meetup_venue_location( $meetup['venue'] );
    369                                                 } else {
    370                                                         $geocoded_location = $this->reverse_geocode( $meetup['group']['group_lat'], $meetup['group']['group_lon'] );
    371                                                         $location_parts    = $this->parse_reverse_geocode_address( $geocoded_location );
    372                                                         $location          = sprintf(
    373                                                                 '%s%s%s',
    374                                                                 $location_parts['city'] ?? '',
    375                                                                 empty( $location_parts['state'] )        ? '' : ', ' . $location_parts['state'],
    376                                                                 empty( $location_parts['country_name'] ) ? '' : ', ' . $location_parts['country_name']
    377                                                         );
    378                                                         $location         = trim( $location, ", \t\n\r\0\x0B" );
    379                                                 }
    380 
    381                                                 if ( ! empty( $meetup['venue']['country'] ) ) {
    382                                                         $country_code = $meetup['venue']['country'];
    383                                                 } elseif ( ! empty( $location_parts['country_code'] ) ) {
    384                                                         $country_code = $location_parts['country_code'];
    385                                                 } else {
    386                                                         $country_code = '';
    387                                                 }
    388 
    389                                                 $events[] = new Official_WordPress_Event( array(
    390                                                         'type'            => 'meetup',
    391                                                         'source_id'       => $meetup['id'],
    392                                                         'status'          => 'upcoming' === $meetup['status'] ? 'scheduled' : 'cancelled',
    393                                                         'title'           => $meetup['name'],
    394                                                         'url'             => $meetup['event_url'],
    395                                                         'meetup_name'     => $meetup['group']['name'],
    396                                                         'meetup_url'      => sprintf( 'https://www.meetup.com/%s/', $meetup['group']['urlname'] ),
    397                                                         'description'     => $meetup['description'] ?? '',
    398                                                         'num_attendees'   => $meetup['yes_rsvp_count'],
    399                                                         'start_timestamp' => $start_timestamp,
    400                                                         'end_timestamp'   => ( empty ( $meetup['duration'] ) ? $start_timestamp : $start_timestamp + ( $meetup['duration'] / 1000 ) ), // convert to seconds
    401                                                         'location'        => $location,
    402                                                         'country_code'    => $country_code,
    403                                                         'latitude'        => empty( $meetup['venue']['lat'] ) ? $meetup['group']['group_lat'] : $meetup['venue']['lat'],
    404                                                         'longitude'       => empty( $meetup['venue']['lon'] ) ? $meetup['group']['group_lon'] : $meetup['venue']['lon'],
    405                                                 ) );
    406                                         }
     406                $events = $this->parse_meetup_events( $meetups );
    407407
    408408                $this->log( sprintf( 'returning %d events', count( $events ) ) );
     409
     410                return $events;
     411        }
     412
     413        /**
     414         * Parse meetup events out of a response from the Meetup API.
     415         *
     416         * @param array $meetups
     417         *
     418         * @return array
     419         */
     420        protected function parse_meetup_events( $meetups ) {
     421                $events = array();
     422
     423                foreach ( $meetups as $meetup ) {
     424                        if ( empty( $meetup['id'] ) || empty( $meetup['name'] ) ) {
     425                                $this->log( 'Malformed meetup: ' . wp_json_encode( $meetup ) );
     426                                continue;
     427                        }
     428
     429                        $start_timestamp = ( $meetup['time'] / 1000 ) + ( $meetup['utc_offset'] / 1000 ); // convert to seconds
     430                        $latitude        = ! empty( $meetup['venue']['lat'] ) ? $meetup['venue']['lat'] : $meetup['group']['lat'];
     431                        $longitude       = ! empty( $meetup['venue']['lon'] ) ? $meetup['venue']['lon'] : $meetup['group']['lon'];
     432
     433                        if ( isset( $meetup['venue'] ) ) {
     434                                $location = $this->format_meetup_venue_location( $meetup['venue'] );
     435                        } else {
     436                                $geocoded_location = $this->reverse_geocode( $latitude, $longitude );
     437                                $location_parts    = $this->parse_reverse_geocode_address( $geocoded_location );
     438                                $location          = sprintf(
     439                                        '%s%s%s',
     440                                        $location_parts['city'] ?? '',
     441                                        empty( $location_parts['state'] )        ? '' : ', ' . $location_parts['state'],
     442                                        empty( $location_parts['country_name'] ) ? '' : ', ' . $location_parts['country_name']
     443                                );
     444                                $location          = trim( $location, ", \t\n\r\0\x0B" );
     445                        }
     446
     447                        if ( ! empty( $meetup['venue']['country'] ) ) {
     448                                $country_code = $meetup['venue']['country'];
     449                        } elseif ( ! empty( $location_parts['country_code'] ) ) {
     450                                $country_code = $location_parts['country_code'];
     451                        } else {
     452                                $country_code = '';
     453                        }
     454
     455                        $events[] = new Official_WordPress_Event( array(
     456                                'type'            => 'meetup',
     457                                'source_id'       => $meetup['id'],
     458                                'status'          => 'upcoming' === $meetup['status'] ? 'scheduled' : 'cancelled',
     459                                'title'           => $meetup['name'],
     460                                'url'             => $meetup['link'],
     461                                'meetup_name'     => $meetup['group']['name'],
     462                                'meetup_url'      => sprintf( 'https://www.meetup.com/%s/', $meetup['group']['urlname'] ),
     463                                'description'     => $meetup['description'] ?? '',
     464                                'num_attendees'   => $meetup['yes_rsvp_count'],
     465                                'start_timestamp' => $start_timestamp,
     466                                'end_timestamp'   => ( empty ( $meetup['duration'] ) ? $start_timestamp : $start_timestamp + ( $meetup['duration'] / 1000 ) ), // convert to seconds
     467                                'location'        => $location,
     468                                'country_code'    => $country_code,
     469                                'latitude'        => $latitude,
     470                                'longitude'       => $longitude,
     471                        ) );
     472                }
    409473
    410474                return $events;
    … …  
    431495                usleep( 75000 );
    432496
    433                 $response = $this->remote_get( sprintf(
     497                $api_client  = $this->get_api_client();
     498                $request_url = sprintf(
    434499                        'https://maps.googleapis.com/maps/api/geocode/json?latlng=%s,%s&sensor=false&key=%s',
    435500                        $latitude,
    436501                        $longitude,
    437502                        OFFICIAL_WP_EVENTS_GOOGLE_MAPS_API_KEY
    438                 ) );
    439                 $body = json_decode( wp_remote_retrieve_body( $response ) );
     503                );
     504
     505                $response = $api_client->tenacious_remote_get( $request_url );
     506                $body     = json_decode( wp_remote_retrieve_body( $response ) );
    440507
    441508                if ( ! is_wp_error( $response ) && isset( $body->results ) && empty( $body->error_message ) ) {
    … …  
    448515                }
    449516                else {
     517                        $api_client->handle_error_response( $response, $request_url );
    450518                        $this->log( 'geocode failed: ' . wp_json_encode( $response ) );
    451519                }
    … …  
    571639                                $wpdb->update( self::EVENTS_TABLE, array( 'status' => 'deleted' ), array( 'id' => $db_event->id ) );
    572640
    573                                 if ( 'cli' === php_sapi_name() ) {
    574                                         echo "\nMarked {$db_event->source_id} as deleted.";
    575                                 }
    576                         }
    577                 }
    578         }
    579 
    580         /**
    581          * Wrapper for wp_remote_get()
    582          *
    583          * This adds error logging/notification.
    584          *
    585          * @param string $url
    586          * @param array  $args
    587          *
    588          * @return false|array|WP_Error False if a valid $url was not passed; otherwise the results from wp_remote_get()
    589          */
    590         protected function remote_get( $url, $args = array() ) {
    591                 $response = $error = false;
    592 
    593                 if ( $url ) {
    594                         $args['timeout'] = 30;
    595                         $response        = wp_remote_get( $url, $args );
    596 
    597                         $this->maybe_pause( wp_remote_retrieve_headers( $response ) );
    598 
    599                         $response_code    = wp_remote_retrieve_response_code( $response );
    600                         $response_message = wp_remote_retrieve_response_message( $response );
    601                         $response_body    = wp_remote_retrieve_body( $response );
    602 
    603                         if ( is_wp_error( $response ) ) {
    604                                 $error_messages = implode( ', ', $response->get_error_messages() );
    605 
    606                                 if ( false === strpos( $error_messages, 'Operation timed out' ) ) {
    607                                         $error = sprintf(
    608                                                 'Received WP_Error message: %s; Request was to %s; Arguments were: %s',
    609                                                 $error_messages,
    610                                                 $url,
    611                                                 print_r( $args, true )
    612                                         );
    613                                 }
    614                         } elseif ( 200 != $response_code ) {
    615                                 // trigger_error() has a message limit of 1024 bytes, so we truncate $response['body'] to make sure that $body doesn't get truncated.
     641                                $this->log( "Marked {$db_event->source_id} as deleted." );
     642                        }
     643                }
     644        }
     645
     646        /**
     647         * Error logging and notification.
     648         *
     649         * Hooked to `api_client_handle_error_response`.
     650         *
     651         * @param array|WP_Error $response
     652         *
     653         * @return void
     654         */
     655        protected function handle_error_response( $response, $request_url, $request_args ) {
     656                $error = null;
     657
     658                $response_code    = wp_remote_retrieve_response_code( $response );
     659                $response_message = wp_remote_retrieve_response_message( $response );
     660                $response_body    = wp_remote_retrieve_body( $response );
     661
     662                if ( is_wp_error( $response ) ) {
     663                        $error_messages = implode( ', ', $response->get_error_messages() );
     664
     665                        if ( false === strpos( $error_messages, 'Operation timed out' ) ) {
    616666                                $error = sprintf(
    617                                         "HTTP Code: %d\nMessage: %s\nBody: %s\nRequest URL: %s\nArgs: %s",
    618                                         $response_code,
    619                                         sanitize_text_field( $response_message ),
    620                                         substr( sanitize_text_field( $response_body ), 0, 500 ),
    621                                         $url,
    622                                         print_r( $args, true )
     667                                        'Received WP_Error message: %s; Request was to %s; Arguments were: %s',
     668                                        $error_messages,
     669                                        $request_url,
     670                                        print_r( $request_args, true )
    623671                                );
    624 
    625                                 $response = new WP_Error( 'owe_invalid_http_response', 'Invalid HTTP response code', $response );
    626                         }
    627 
    628                         if ( $error ) {
    629                                 $error = preg_replace( '/&key=[a-z0-9]+/i', '&key=[redacted]', $error );
    630                                 trigger_error( sprintf(
    631                                         '%s error for %s: %s',
    632                                         __METHOD__,
    633                                         parse_url( site_url(), PHP_URL_HOST ),
     672                        }
     673                } elseif ( 200 !== $response_code ) {
     674                        // trigger_error() has a message limit of 1024 bytes, so we truncate $response['body'] to make sure that $body doesn't get truncated.
     675                        $error = sprintf(
     676                                "HTTP Code: %d\nMessage: %s\nBody: %s\nRequest URL: %s\nArgs: %s",
     677                                $response_code,
     678                                sanitize_text_field( $response_message ),
     679                                substr( sanitize_text_field( $response_body ), 0, 500 ),
     680                                $request_url,
     681                                print_r( $request_args, true )
     682                        );
     683                }
     684
     685                if ( $error ) {
     686                        $error = preg_replace( '/&key=[a-z0-9]+/i', '&key=[redacted]', $error );
     687
     688                        $this->log( sanitize_text_field( $error ), true );
     689
     690                        trigger_error( sprintf(
     691                                '%s error for %s: %s',
     692                                __METHOD__,
     693                                parse_url( site_url(), PHP_URL_HOST ),
     694                                sanitize_text_field( $error )
     695                        ), E_USER_WARNING );
     696
     697                        $to = apply_filters( 'owe_error_email_addresses', array() );
     698
     699                        if ( $to && ( ! defined( 'WPORG_SANDBOXED_REQUEST' ) || ! WPORG_SANDBOXED_REQUEST ) ) {
     700                                wp_mail(
     701                                        $to,
     702                                        sprintf(
     703                                                '%s error for %s',
     704                                                __METHOD__,
     705                                                parse_url( site_url(), PHP_URL_HOST )
     706                                        ),
    634707                                        sanitize_text_field( $error )
    635                                 ), E_USER_WARNING );
    636 
    637                                 $to = apply_filters( 'owe_error_email_addresses', array() );
    638                                 if ( $to && ( ! defined( 'WPORG_SANDBOXED_REQUEST' ) || ! WPORG_SANDBOXED_REQUEST ) ) {
    639                                         wp_mail(
    640                                                 $to,
    641                                                 sprintf(
    642                                                         '%s error for %s',
    643                                                         __METHOD__,
    644                                                         parse_url( site_url(), PHP_URL_HOST )
    645                                                 ),
    646                                                 sanitize_text_field( $error )
    647                                         );
    648                                 }
    649                         }
    650                 }
    651 
    652                 return $response;
    653         }
    654 
    655         /**
    656          * Maybe pause the script to avoid rate limiting
    657          *
    658          * @param array $headers
    659          */
    660         protected function maybe_pause( $headers ) {
    661                 if ( ! isset( $headers['x-ratelimit-remaining'], $headers['x-ratelimit-reset'] ) ) {
    662                         return;
    663                 }
    664 
    665                 $remaining = absint( $headers['x-ratelimit-remaining'] );
    666                 $period    = absint( $headers['x-ratelimit-reset'] );
    667 
    668                 // Pause more frequently than we need to, and for longer, just to be safe
    669                 if ( $remaining > 2 ) {
    670                         return;
    671                 }
    672 
    673                 if ( $period < 2 ) {
    674                         $period = 2;
    675                 }
    676 
    677                 if ( 'cli' == php_sapi_name() ) {
    678                         echo "\nPausing for $period seconds to avoid rate-limiting.";
    679                 }
    680 
    681                 $this->log( 'sleeping to avoid api rate limit' );
    682                 sleep( $period );
     708                                );
     709                        }
     710                }
    683711        }
    684712
    … …  
    697725                $limit = 500;
    698726                $api_keys = array( MEETUP_API_KEY, OFFICIAL_WP_EVENTS_GOOGLE_MAPS_API_KEY );
     727
     728                if ( 'cli' === php_sapi_name() ) {
     729                        echo $message;
     730                }
    699731
    700732                if ( $write_to_disk ) {
Note: See TracChangeset for help on using the changeset viewer.