Class: RESO::API::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/reso_api/app/models/reso/api/client.rb

Constant Summary collapse

RESOURCE_KEYS =
{
  media: "MediaKey",
  members: "MemberKey",
  offices: "OfficeKey",
  open_houses: "OpenHouseKey",
  properties: "ListingKey"
}
DETAIL_ENDPOINTS =
{
  medium: "/Media",
  member: "/Member",
  office: "/Office",
  open_house: "/OpenHouse",
  property: "/Property"
}
FILTERABLE_ENDPOINTS =
{
  media: "/Media",
  members: "/Member",
  offices: "/Office",
  open_houses: "/OpenHouse",
  properties: "/Property"
}
PASSTHROUGH_ENDPOINTS =
{
  metadata: "/$metadata"
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(**opts) ⇒ Client

Returns a new instance of Client.



14
15
16
17
# File 'lib/reso_api/app/models/reso/api/client.rb', line 14

def initialize(**opts)
  @access_token, @client_id, @client_secret, @auth_url, @base_url, @scope, @osn = opts.values_at(:access_token, :client_id, :client_secret, :auth_url, :base_url, :scope, :osn)
  validate!
end

Instance Attribute Details

#access_tokenObject

Returns the value of attribute access_token.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def access_token
  @access_token
end

#auth_urlObject

Returns the value of attribute auth_url.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def auth_url
  @auth_url
end

#base_urlObject

Returns the value of attribute base_url.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def base_url
  @base_url
end

#client_idObject

Returns the value of attribute client_id.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def client_id
  @client_id
end

#client_secretObject

Returns the value of attribute client_secret.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def client_secret
  @client_secret
end

#last_request_urlObject (readonly)

Returns the value of attribute last_request_url.



12
13
14
# File 'lib/reso_api/app/models/reso/api/client.rb', line 12

def last_request_url
  @last_request_url
end

#osnObject

Returns the value of attribute osn.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def osn
  @osn
end

#scopeObject

Returns the value of attribute scope.



11
12
13
# File 'lib/reso_api/app/models/reso/api/client.rb', line 11

def scope
  @scope
end

Instance Method Details

#auth_tokenObject



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/reso_api/app/models/reso/api/client.rb', line 113

def auth_token
  # If access_token is provided, check if it's expired
  if access_token.present?
    # Try to decode as JWT to check expiration
    begin
      token = JWT.decode(access_token, nil, false)
      exp_timestamp = Hash(token.try(:first))["exp"].to_s
      expiration = DateTime.strptime(exp_timestamp, '%s').utc rescue nil

      # If token is expired and we have OAuth credentials, get a fresh token
      if expiration && expiration <= DateTime.now.utc && can_use_oauth?
        return oauth2_token
      end
    rescue JWT::DecodeError
      # Not a JWT token, just use it as-is
    end

    return access_token
  end

  # No access_token provided, use OAuth flow
  oauth2_token
end

#can_use_oauth?Boolean

Returns:



137
138
139
# File 'lib/reso_api/app/models/reso/api/client.rb', line 137

def can_use_oauth?
  client_id.present? && client_secret.present? && auth_url.present?
end

#entity_namesObject



299
300
301
302
303
# File 'lib/reso_api/app/models/reso/api/client.rb', line 299

def entity_names
  doc = Nokogiri::XML()
  namespace = { 'edm' => 'http://docs.oasis-open.org/odata/ns/edm' }
  doc.xpath('//edm:EntityType', namespace).map { |node| node['Name'] }
end

#fresh_oauth2_payloadObject



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/reso_api/app/models/reso/api/client.rb', line 164

def fresh_oauth2_payload
  begin
    @oauth2_payload = oauth2_client.client_credentials.get_token('client_id' => client_id, 'client_secret' => client_secret, 'scope' => scope.presence)
    File.write(oauth2_token_path, @oauth2_payload.to_hash.to_json)
    return @oauth2_payload
  rescue OAuth2::Error => e
    # Provide detailed error message for OAuth failures
    error_details = "OAuth token refresh failed for #{base_url}"
    error_details += "\n  Scope attempted: #{scope.inspect}"
    error_details += "\n  OAuth error: #{e.message}"
    raise StandardError, error_details
  rescue => e
    raise StandardError, "Failed to refresh OAuth token: #{e.message}"
  end
end

#get_oauth2_payloadObject



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/reso_api/app/models/reso/api/client.rb', line 188

def get_oauth2_payload
  if File.exist?(oauth2_token_path)
    begin
      persisted = File.read(oauth2_token_path)
      parsed = JSON.parse(persisted)

      # Check if the persisted data is a valid token (has access_token or token field)
      if parsed['access_token'].present? || parsed['token'].present?
        payload = OAuth2::AccessToken.from_hash(oauth2_client, parsed)

        # Verify the payload actually has a token
        if payload.token.present?
          return payload
        end
      end

      # If we get here, the cached token is invalid - delete it and get fresh
      File.delete(oauth2_token_path)
    rescue JSON::ParserError, StandardError => e
      # If there's any error reading/parsing the cached token, delete it
      File.delete(oauth2_token_path) if File.exist?(oauth2_token_path)
    end
  end

  # Get fresh token
  begin
    payload = oauth2_client.client_credentials.get_token('client_id' => client_id, 'client_secret' => client_secret, 'scope' => scope.presence)
    File.write(oauth2_token_path, payload.to_hash.to_json)
    return payload
  rescue OAuth2::Error => e
    # Clean up any bad cached token
    File.delete(oauth2_token_path) if File.exist?(oauth2_token_path)

    # Provide detailed error message
    error_details = "OAuth token request failed for #{base_url}"
    error_details += "\n  Scope attempted: #{scope.inspect}"
    error_details += "\n  OAuth error: #{e.message}"
    raise StandardError, error_details
  end
end

#oauth2_clientObject



141
142
143
144
145
146
147
148
149
# File 'lib/reso_api/app/models/reso/api/client.rb', line 141

def oauth2_client
  OAuth2::Client.new(
    client_id,
    client_secret,
    token_url: auth_url,
    scope: scope.presence,
    grant_type: "client_credentials"
  )
end

#oauth2_payloadObject



184
185
186
# File 'lib/reso_api/app/models/reso/api/client.rb', line 184

def oauth2_payload
  @oauth2_payload ||= get_oauth2_payload
end

#oauth2_tokenObject



151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/reso_api/app/models/reso/api/client.rb', line 151

def oauth2_token
  payload = oauth2_payload
  token = JWT.decode(payload.token, nil, false)
  exp_timestamp = Hash(token.try(:first))["exp"].to_s
  expiration = DateTime.strptime(exp_timestamp, '%s').utc rescue DateTime.now.utc
  if expiration > DateTime.now.utc
    return payload.token
  else
    @oauth2_payload = fresh_oauth2_payload
    return @oauth2_payload.token
  end
end

#oauth2_token_pathObject



180
181
182
# File 'lib/reso_api/app/models/reso/api/client.rb', line 180

def oauth2_token_path
  File.join(Dir.tmpdir, [base_url.parameterize, client_id, "oauth-token.json"].join("-"))
end

#perform_call(endpoint, params, max_retries = 5, debug = false) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/reso_api/app/models/reso/api/client.rb', line 233

def perform_call(endpoint, params, max_retries = 5, debug = false)
  uri = uri_for_endpoint(endpoint)
  retries = 0

  if params.present?
    query = params.present? ? URI.encode_www_form(params).gsub("+", " ") : ""
    uri.query && uri.query.length > 0 ? uri.query += '&' + query : uri.query = query
    return URI::decode(uri.request_uri) if params.dig(:$debug).present?
  end

  # Store the full request URL for debugging
  @last_request_url = uri.to_s

  begin
    req = Net::HTTP::Get.new(uri.request_uri)
    req['Authorization'] = "Bearer #{auth_token}"

    # Configure HTTP object before starting connection
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')

    if http.use_ssl?
      # Configure certificate store to avoid CRL checking issues with OpenSSL 3.5+
      # This maintains certificate chain verification while preventing CRL-related failures
      http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      store = OpenSSL::X509::Store.new
      store.set_default_paths
      http.cert_store = store
    end

    res = http.request(req)
    response = JSON(res.body) rescue res.body
    if response.is_a?(String) && response.include?('Bad Gateway')
      puts "Error: Bad Gateway." if debug
      raise StandardError
    elsif response.is_a?(String) && response.include?('Unauthorized')
      puts "Error: Unauthorized." if debug
      fresh_oauth2_payload
      raise StandardError
    elsif response.is_a?(Hash) && response.has_key?("error")
      error_msg = response.inspect
      puts "Error: #{error_msg}" if debug
      raise StandardError, error_msg
    elsif response.is_a?(Hash) && response.has_key?("retry-after")
      puts "Error: Retrying in #{response["retry-after"].to_i}} seconds." if debug
      sleep response["retry-after"].to_i
      raise StandardError
    end
  rescue Net::ReadTimeout, StandardError
    if (retries += 1) <= max_retries
      sleep 5
      retry
    else
      raise
    end
  end

  # Add metadata to response hash (if response is a hash)
  if response.is_a?(Hash)
    response['@reso_request_url'] = @last_request_url
    response['@reso_auth_scope'] = scope
  end

  return response
end

#supported_expandablesObject



305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/reso_api/app/models/reso/api/client.rb', line 305

def supported_expandables
  expandables_arr = []
  omit_arr = %w[HistoryTransactional]

  entity_names.each do |entity_name|
    success = try_expand(entity_name)
    expandables_arr << entity_name if success
  end

  expandables_arr -= omit_arr
  expandables_arr.join(',').presence
end

#try_expand(entity_name) ⇒ Object



318
319
320
321
322
323
324
325
326
# File 'lib/reso_api/app/models/reso/api/client.rb', line 318

def try_expand(entity_name)
  endpoint = '/Property'
  params = { '$expand' => entity_name }

  response = perform_call(endpoint, params, max_retries = 0)
  (!response.is_a?(Hash) || !response.key?('error')) && response['statusCode'].blank? && response['status'].blank?
rescue StandardError
  false
end

#uri_for_endpoint(endpoint) ⇒ Object



229
230
231
# File 'lib/reso_api/app/models/reso/api/client.rb', line 229

def uri_for_endpoint endpoint
  return URI(endpoint).host ? URI(endpoint) : URI([base_url, endpoint].join)
end

#validate!Object



19
20
21
22
23
24
25
26
27
28
# File 'lib/reso_api/app/models/reso/api/client.rb', line 19

def validate!
  if access_token.nil?
    raise 'Missing Client ID `client_id`' if client_id.nil?
    raise 'Missing Client Secret `client_secret`' if client_secret.nil?
    raise 'Missing Authentication URL `auth_url`' if auth_url.nil?
    raise 'Missing API Base URL `base_url`' if base_url.nil?
  else
    raise 'Missing API Base URL `base_url`' if base_url.nil?
  end
end