options = $options; $this->rate_limiter = $rate_limiter; $this->logger = $logger; } /** * Test API connection * * @return array Result with success status and message */ public function test_connection() { $start_time = microtime(true); $response = $this->request(''); $elapsed = round((microtime(true) - $start_time) * 1000); if (is_wp_error($response)) { return array( 'success' => false, 'error' => $response->get_error_message(), 'response_time' => $elapsed, ); } return array( 'success' => true, 'message' => 'Connection successful', 'response_time' => $elapsed, 'endpoints' => isset($response['value']) ? array_column($response['value'], 'name') : array(), ); } /** * Test API authentication * * @return array Result with success status */ public function test_auth() { // Try to fetch a single property to verify auth $response = $this->get_properties(null, null, 1); if (is_wp_error($response)) { $error_code = $response->get_error_code(); $error_message = $response->get_error_message(); if (strpos($error_message, '401') !== false || strpos($error_message, 'Unauthorized') !== false) { return array( 'success' => false, 'error' => 'Authentication failed. Please check your API token.', ); } return array( 'success' => false, 'error' => $error_message, ); } return array( 'success' => true, 'message' => 'Authentication successful', 'originating_system' => $this->options->get_originating_system(), ); } /** * Make an API request * * @param string $endpoint API endpoint (relative to base URL) * @param array $params Query parameters * @param int $retry Current retry attempt * @param string $channel Rate limit channel ('general' or 'image') * @return array|WP_Error Response data or error */ public function request($endpoint, $params = array(), $retry = 0, $channel = 'general') { // Check and wait for rate limits (uses global advisory lock coordination) $this->rate_limiter->check_and_wait(true, $channel); $url = $this->build_url($endpoint, $params); $this->logger->debug('API Request', array( 'url' => $url, 'retry' => $retry, )); $args = array( 'method' => 'GET', 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $this->options->get_api_token(), 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip', 'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'), ), ); $response = wp_remote_get($url, $args); // Track the request $bytes = 0; if (!is_wp_error($response)) { $body = wp_remote_retrieve_body($response); $bytes = strlen($body); } $this->rate_limiter->record_request($bytes); // Handle errors if (is_wp_error($response)) { $this->logger->error('API Request Failed', array( 'error' => $response->get_error_message(), 'url' => $url, )); // Retry on transient errors if ($retry < self::MAX_RETRIES) { sleep(pow(2, $retry)); // Exponential backoff return $this->request($endpoint, $params, $retry + 1, $channel); } return $response; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); // Handle HTTP errors if ($status_code >= 400) { $error_message = $this->parse_error_response($body, $status_code); $this->logger->error('API HTTP Error', array( 'status' => $status_code, 'error' => $error_message, 'url' => $url, )); // Retry on 429 (rate limit) or 5xx errors if (($status_code === 429 || $status_code >= 500) && $retry < self::MAX_RETRIES) { $wait = $status_code === 429 ? 60 : pow(2, $retry); sleep($wait); return $this->request($endpoint, $params, $retry + 1, $channel); } return new WP_Error('api_error', $error_message, array('status' => $status_code)); } // Parse JSON response $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { $this->logger->error('API JSON Parse Error', array( 'error' => json_last_error_msg(), 'body_preview' => substr($body, 0, 500), )); return new WP_Error('json_error', 'Failed to parse API response: ' . json_last_error_msg()); } $this->logger->debug('API Response', array( 'status' => $status_code, 'record_count' => isset($data['value']) ? count($data['value']) : 0, 'has_next' => isset($data['@odata.nextLink']), )); return $data; } /** * Build full URL with parameters * * @param string $endpoint Endpoint * @param array $params Parameters * @return string Full URL */ private function build_url($endpoint, $params = array()) { $base_url = rtrim($this->options->get_api_url(), '/'); if (!empty($endpoint)) { $url = $base_url . '/' . ltrim($endpoint, '/'); } else { $url = $base_url . '/'; } if (!empty($params)) { $query_string = http_build_query($params, '', '&', PHP_QUERY_RFC3986); // OData uses $ prefix which gets encoded, decode it $query_string = str_replace('%24', '$', $query_string); $url .= '?' . $query_string; } return $url; } /** * Parse error response * * @param string $body Response body * @param int $status_code HTTP status code * @return string Error message */ private function parse_error_response($body, $status_code) { $data = json_decode($body, true); if (isset($data['error']['message'])) { return $data['error']['message']; } if (isset($data['message'])) { return $data['message']; } return "HTTP Error {$status_code}"; } /** * Get properties from API * * @param string|null $filter OData filter * @param string|null $expand Expand parameter (Media, Rooms, UnitTypes) * @param int|null $top Number of records to fetch * @return array|WP_Error Response data or error */ public function get_properties($filter = null, $expand = null, $top = null) { $params = array(); // Build filter - always include originating system $system = $this->options->get_originating_system(); $base_filter = "OriginatingSystemName eq '{$system}'"; if ($filter) { $params['$filter'] = $base_filter . ' and ' . $filter; } else { $params['$filter'] = $base_filter . ' and MlgCanView eq true'; } // Expand for media if ($expand) { $params['$expand'] = $expand; } // Records per page if ($top) { $params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND); } else { $params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP; } return $this->request('Property', $params); } /** * Get properties modified since timestamp * * @param string $timestamp ISO 8601 timestamp * @param string|null $expand Expand parameter * @param int|null $top Number of records * @return array|WP_Error Response data or error */ public function get_properties_since($timestamp, $expand = null, $top = null) { $filter = "ModificationTimestamp gt {$timestamp}"; return $this->get_properties($filter, $expand, $top); } /** * Get properties for initial sync (Active/Pending only) * * @param string|null $expand Expand parameter * @param int|null $top Number of records * @return array|WP_Error Response data or error */ public function get_properties_for_initial_sync($expand = null, $top = null) { $params = array(); $system = $this->options->get_originating_system(); // Initial sync: only Active/Pending with MlgCanView=true $params['$filter'] = "OriginatingSystemName eq '{$system}' and MlgCanView eq true and (StandardStatus eq 'Active' or StandardStatus eq 'Pending')"; if ($expand) { $params['$expand'] = $expand; } if ($top) { $params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND); } else { $params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP; } return $this->request('Property', $params); } /** * Get properties modified since timestamp (for replication) * * Does NOT filter by MlgCanView or StandardStatus so we can detect: * - Records that became unavailable (MlgCanView=false) * - Records that changed status (Active -> Sold) * * @param string $timestamp ISO 8601 modification timestamp * @param string|null $expand Expand parameter * @param int|null $top Number of records * @return array|WP_Error Response data or error */ public function get_properties_for_replication($timestamp, $expand = null, $top = null) { $params = array(); $system = $this->options->get_originating_system(); // Replication: get ALL changes since timestamp (no MlgCanView or Status filter) $params['$filter'] = "OriginatingSystemName eq '{$system}' and ModificationTimestamp gt {$timestamp}"; if ($expand) { $params['$expand'] = $expand; } if ($top) { $params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND); } else { $params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP; } return $this->request('Property', $params); } /** * Get a single property by listing ID with media * * Used to refresh media URLs for a specific listing without * fetching the entire dataset. Uses the 'image' rate limit channel * with a 2-second interval for on-demand image requests. * * Note: MLS Grid only allows filtering by ListingId (not ListingKey) * for the Property resource. The caller must provide the listing_id. * * @param string $listing_id The MLS listing ID (not listing_key) * @return array|WP_Error Property data with Media or error */ public function get_property_media($listing_id) { $params = array(); $system = $this->options->get_originating_system(); // Filter by ListingId (MLS Grid only allows certain fields for filtering) $params['$filter'] = "OriginatingSystemName eq '{$system}' and ListingId eq '{$listing_id}'"; $params['$expand'] = 'Media'; $params['$top'] = 1; // Use 'image' channel with 2-second rate limiting for on-demand media fetches $response = $this->request('Property', $params, 0, 'image'); if (is_wp_error($response)) { return $response; } // Return first result or null if (isset($response['value']) && !empty($response['value'])) { return $response['value'][0]; } return null; } /** * Get multiple properties by listing IDs with media (batched) * * Fetches up to 25 properties in a single API request using OData 'in' filter. * Used for efficient media URL refresh without making individual API calls. * Uses the 'image' rate limit channel with 2-second interval. * * @param array $listing_ids Array of MLS listing IDs (max 25) * @return array|WP_Error Array of property data with Media, or error */ public function get_properties_by_ids($listing_ids) { if (empty($listing_ids)) { return array('value' => array()); } // Limit to 25 (MLS Grid's max with $expand) $listing_ids = array_slice($listing_ids, 0, 25); $params = array(); $system = $this->options->get_originating_system(); // Build 'in' filter: ListingId in ('ID1', 'ID2', 'ID3') $escaped_ids = array_map(function($id) { return "'" . addslashes($id) . "'"; }, $listing_ids); $in_list = implode(',', $escaped_ids); $params['$filter'] = "OriginatingSystemName eq '{$system}' and ListingId in ({$in_list})"; $params['$expand'] = 'Media'; $params['$top'] = 25; // Use 'image' channel with 2-second rate limiting for media fetches return $this->request('Property', $params, 0, 'image'); } /** * Get next page of results * * @param string $next_link The @odata.nextLink URL * @return array|WP_Error Response data or error */ public function get_next_page($next_link) { // Check and wait for rate limits $this->rate_limiter->check_and_wait(true); $this->logger->debug('API Next Page Request', array( 'url' => $next_link, )); $args = array( 'method' => 'GET', 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $this->options->get_api_token(), 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip', 'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'), ), ); $response = wp_remote_get($next_link, $args); // Track the request $bytes = 0; if (!is_wp_error($response)) { $body = wp_remote_retrieve_body($response); $bytes = strlen($body); } $this->rate_limiter->record_request($bytes); if (is_wp_error($response)) { $this->logger->error('API Next Page Failed', array( 'error' => $response->get_error_message(), )); return $response; } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status_code >= 400) { $error_message = $this->parse_error_response($body, $status_code); return new WP_Error('api_error', $error_message, array('status' => $status_code)); } $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { return new WP_Error('json_error', 'Failed to parse API response'); } return $data; } /** * Get members (agents) from API * * @param string|null $filter OData filter * @return array|WP_Error Response data or error */ public function get_members($filter = null) { $params = array(); $system = $this->options->get_originating_system(); $base_filter = "OriginatingSystemName eq '{$system}'"; if ($filter) { $params['$filter'] = $base_filter . ' and ' . $filter; } else { $params['$filter'] = $base_filter; } return $this->request('Member', $params); } /** * Get offices from API * * @param string|null $filter OData filter * @return array|WP_Error Response data or error */ public function get_offices($filter = null) { $params = array(); $system = $this->options->get_originating_system(); $base_filter = "OriginatingSystemName eq '{$system}'"; if ($filter) { $params['$filter'] = $base_filter . ' and ' . $filter; } else { $params['$filter'] = $base_filter; } return $this->request('Office', $params); } /** * Get lookup values (field definitions) * Note: Should not be called more than once per day * * @return array|WP_Error Response data or error */ public function get_lookups() { $system = $this->options->get_originating_system(); $params = array( '$filter' => "OriginatingSystemName eq '{$system}'", ); return $this->request('Lookup', $params); } }