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.



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

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

#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



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

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:

  • (Boolean)


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

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

#entity_namesObject



288
289
290
291
292
# File 'lib/reso_api/app/models/reso/api/client.rb', line 288

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



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

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



187
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
# File 'lib/reso_api/app/models/reso/api/client.rb', line 187

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



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

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

#oauth2_payloadObject



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

def oauth2_payload
  @oauth2_payload ||= get_oauth2_payload
end

#oauth2_tokenObject



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

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



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

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



232
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
# File 'lib/reso_api/app/models/reso/api/client.rb', line 232

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

  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
  return response
end

#supported_expandablesObject



294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/reso_api/app/models/reso/api/client.rb', line 294

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



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

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



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

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

#validate!Object



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

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