72b932b25e
MLS Grid media URLs expire after ~24 hours. Instead of running scheduled syncs, this adds on-demand refresh when images are requested: - Add is_url_expired() to parse expires timestamp from media URLs - Add refresh_media_urls() to fetch fresh URLs from API for one listing - Add get_property_media() API method using ListingId filter - Image endpoint checks URL expiration before fetching - If expired, refreshes URLs from API then proceeds with fetch This is more efficient than scheduled full syncs because: - Only refreshes URLs for listings actually being viewed - Zero overhead for unviewed listings - Scales naturally with traffic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
533 lines
17 KiB
PHP
533 lines
17 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 a single property by listing ID with media
|
|
*
|
|
* Used to refresh media URLs for a specific listing without
|
|
* fetching the entire dataset.
|
|
*
|
|
* 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;
|
|
|
|
$response = $this->request('Property', $params);
|
|
|
|
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 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);
|
|
}
|
|
}
|