Files
homeproz/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-api-client.php
T
Hanson.xyz Dev b9cddd2f64 Refactor MLS sync to Active/Pending only with on-demand media
Major changes to sync strategy following MLS Grid best practices:

- Initial sync now fetches only Active/Pending properties (~30K vs 1.3M)
- Replication (incremental) fetches all changes, deletes non-Active/Pending
- On-demand media fetching replaces background queue (avoids rate limits)
- Media downloaded and cached when first viewed, not during sync
- Updated CLI commands: wp mls media status/fetch/clear
- Comprehensive documentation with troubleshooting guide

This fixes the "Value out of range" API error caused by high $skip values.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-15 08:25:37 -06:00

497 lines
16 KiB
PHP

<?php
/**
* MLS Grid API Client
*
* Handles all communication with the MLS Grid API including:
* - Authentication (Bearer token)
* - Gzip compression (required)
* - Pagination via @odata.nextLink
* - Rate limit compliance
* - Error handling and retries
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_API_Client {
/**
* Default records per page (max 5000 without expand, 1000 with expand)
*/
const DEFAULT_TOP = 1000;
const MAX_TOP_WITH_EXPAND = 1000;
const MAX_TOP_NO_EXPAND = 5000;
/**
* Request timeout in seconds
*/
const TIMEOUT = 60;
/**
* Max retry attempts
*/
const MAX_RETRIES = 3;
/**
* Options instance
*/
private $options;
/**
* Rate limiter instance
*/
private $rate_limiter;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*
* @param MLS_Options $options Options instance
* @param MLS_Rate_Limiter $rate_limiter Rate limiter instance
* @param MLS_Logger $logger Logger instance
*/
public function __construct(MLS_Options $options, MLS_Rate_Limiter $rate_limiter, MLS_Logger $logger) {
$this->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
* @return array|WP_Error Response data or error
*/
public function request($endpoint, $params = array(), $retry = 0) {
// Check and wait for rate limits
$this->rate_limiter->check_and_wait(true);
$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);
}
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);
}
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 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);
}
}