├── .ruby-version ├── .ruby-gemset ├── .rspec ├── lib ├── reso_web_api │ ├── version.rb │ ├── authentication.rb │ ├── resources.rb │ ├── errors.rb │ ├── authentication │ │ ├── access.rb │ │ ├── simple_token_auth.rb │ │ ├── auth_strategy.rb │ │ ├── token_auth.rb │ │ └── middleware.rb │ ├── base_client.rb │ └── client.rb └── reso_web_api.rb ├── spec ├── support │ ├── coverage.rb │ └── logger.rb ├── spec_helper.rb ├── reso_web_api │ ├── authentication │ │ ├── simple_token_auth_spec.rb │ │ ├── token_auth_spec.rb │ │ ├── auth_strategy.rb │ │ ├── access_spec.rb │ │ └── middleware_spec.rb │ ├── base_client_spec.rb │ └── client_spec.rb ├── reso_web_api_spec.rb └── fixtures │ └── files │ └── metadata.xml ├── Rakefile ├── bin ├── setup └── console ├── Gemfile ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── reso_web_api.gemspec └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | reso_web_api 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/reso_web_api/version.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | VERSION = "0.2.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/coverage.rb: -------------------------------------------------------------------------------- 1 | # Produce code coverage reports 2 | require 'simplecov' 3 | SimpleCov.start 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in reso_web_api.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /log/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | 14 | # Gemfile.lock should /not/ be checked in for gems 15 | Gemfile.lock 16 | -------------------------------------------------------------------------------- /spec/support/logger.rb: -------------------------------------------------------------------------------- 1 | # Override standard logger for testing 2 | module ResoWebApi 3 | def self.logger 4 | if @logger.nil? 5 | @logger = Logger.new('log/test.log') 6 | @logger.level = Logger::DEBUG 7 | end 8 | @logger 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication.rb: -------------------------------------------------------------------------------- 1 | require_relative 'authentication/access' 2 | require_relative 'authentication/auth_strategy' 3 | require_relative 'authentication/middleware' 4 | require_relative 'authentication/simple_token_auth' 5 | require_relative 'authentication/token_auth' 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "reso_web_api" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/reso_web_api/resources.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | # Grants access to a service's resources in a convenient manner 3 | module Resources 4 | STANDARD_RESOURCES = { 5 | properties: 'Property', 6 | members: 'Member', 7 | offices: 'Office', 8 | media: 'Media' 9 | } 10 | 11 | STANDARD_RESOURCES.each do |method, resource| 12 | define_method(method) do 13 | resources[resource] 14 | end 15 | end 16 | 17 | def resources 18 | service 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "reso_web_api" 3 | 4 | # Load all files from `spec/support` 5 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f } 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.2.2 4 | 5 | * Switching to frodata gem 6 | * Make headers configurable, remove default accept header 7 | * Don't modify passed-in options hash 8 | 9 | ## 0.2.1 10 | 11 | * Added `SimpleTokenAuth` strategy for using basic non-expiring, non-refreshable tokens 12 | * Share logger with OData service 13 | * Allow passing options to OData service 14 | 15 | ## 0.2.0 16 | 17 | * Refactored code and simplified design 18 | * Middleware-based authentication mechanism 19 | * Added comprehensive tests 20 | 21 | ## 0.1.0 22 | 23 | * Initial release 24 | -------------------------------------------------------------------------------- /lib/reso_web_api/errors.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | class Error < StandardError; end 3 | 4 | class NetworkError < Error 5 | attr_reader :response 6 | 7 | def initialize(options = {}) 8 | # Support the standard initializer for errors 9 | opts = options.is_a?(Hash) ? options : { message: options.to_s } 10 | @response = opts[:response] 11 | super(opts[:message]) 12 | end 13 | 14 | def status 15 | response && response.status 16 | end 17 | end 18 | 19 | module Errors 20 | class AccessDenied < NetworkError; end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/reso_web_api/authentication/simple_token_auth_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Authentication::SimpleTokenAuth do 2 | subject { ResoWebApi::Authentication::SimpleTokenAuth.new(config) } 3 | let(:config) {{ 4 | access_token: 'eyJ0eXAiOiJKV1Qi', 5 | token_type: 'Bearer' 6 | }} 7 | 8 | describe '#authenticate' do 9 | it { expect(subject.authenticate).to be_a(ResoWebApi::Authentication::Access) } 10 | it { expect(subject.authenticate).to be_valid } 11 | it { expect(subject.authenticate.token).to eq(config[:access_token]) } 12 | it { expect(subject.authenticate.token_type).to eq(config[:token_type])} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication/access.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | module Authentication 3 | # Session class for TokenAuth. This stores the access token, the token type 4 | # (usually `Bearer`), and the expiration date of the token. 5 | class Access 6 | attr_accessor :token, :expires, :token_type 7 | 8 | def initialize(options = {}) 9 | @token = options['access_token'] 10 | @expires = Time.now + options['expires_in'] 11 | @token_type = options['token_type'] 12 | end 13 | 14 | def expired? 15 | Time.now > expires 16 | end 17 | 18 | def valid? 19 | !!token && !expired? 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/reso_web_api.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'logger' 3 | require 'frodata' 4 | 5 | require 'reso_web_api/version' 6 | require 'reso_web_api/errors' 7 | require 'reso_web_api/client' 8 | 9 | module ResoWebApi 10 | def self.client(options = {}) 11 | Thread.current[:reso_web_api_client] ||= ResoWebApi::Client.new(options) 12 | end 13 | 14 | def self.reset 15 | reset_configuration 16 | Thread.current[:reso_web_api_client] = nil 17 | end 18 | 19 | def self.logger 20 | if @logger.nil? 21 | @logger = Logger.new(STDOUT) 22 | @logger.level = Logger::INFO 23 | end 24 | @logger 25 | end 26 | 27 | def self.logger=(logger) 28 | @logger = logger 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication/simple_token_auth.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | module Authentication 3 | # A simple auth strategy that uses a static, non-expiring token. 4 | class SimpleTokenAuth < AuthStrategy 5 | # The access token (String) 6 | option :access_token 7 | # The token type (String, defaults to `Bearer`) 8 | option :token_type, default: proc { 'Bearer' } 9 | # This strategy does not require an endpoint 10 | option :endpoint, optional: true 11 | 12 | # Simply returns a static, never expiring access token 13 | # @return [Access] The access token object 14 | def authenticate 15 | Access.new( 16 | 'access_token' => access_token, 17 | 'token_type' => token_type, 18 | 'expires_in' => 1 << (1.size * 8 - 2) - 1 # Max int value 19 | ) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication/auth_strategy.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | module Authentication 3 | # This base class defines the basic interface support by all client authentication implementations. 4 | class AuthStrategy < BaseClient 5 | attr_reader :access 6 | 7 | # @abstract Perform requests to authenticate the client with the API 8 | # @return [Access] The access token object 9 | def authenticate(*) 10 | raise NotImplementedError, 'Implement me!' 11 | end 12 | 13 | # Ensure that a valid access token is present or raise an exception 14 | # @raise [ResoWebApi::Errors::AccessDenied] If authentication fails 15 | def ensure_valid_access! 16 | @access = authenticate unless access && access.valid? 17 | access 18 | end 19 | 20 | # Resets access 21 | def reset 22 | @access = nil 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/reso_web_api_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi do 2 | it 'has a version number' do 3 | expect(subject::VERSION).to match(/\d+\.\d+\.\d+/) 4 | end 5 | 6 | # describe '#client' do 7 | # it 'gives me a client connection' do 8 | # expect(subject.client).to be_a(ResoWebApi::Client) 9 | # end 10 | # end 11 | # 12 | # describe '#reset' do 13 | # it 'resets my connection' do 14 | # client = subject.client 15 | # subject.reset 16 | # expect(subject.client).not_to eq(client) 17 | # end 18 | # 19 | # it 'resets configuration to defaults' do 20 | # subject.api_key = 'test key' 21 | # subject.reset 22 | # expect(subject.api_key).to be_nil 23 | # end 24 | # end 25 | 26 | describe '#logger' do 27 | it 'lets me access and override the logger' do 28 | # Overridden in spec_helper.rb 29 | expect(subject.logger.level).to eq(Logger::DEBUG) 30 | 31 | subject.logger = Logger.new('/dev/null') 32 | subject.logger.level = Logger::WARN 33 | 34 | expect(subject.logger.level).to eq(Logger::WARN) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 W+R Studios 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication/token_auth.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | module Authentication 3 | # This implements a basic token authentication, in which a username/password 4 | # (or API key / secret) combination is sent to a special token endpoint in 5 | # exchange for a HTTP Bearer token with a limited lifetime. 6 | class TokenAuth < AuthStrategy 7 | option :client_id 8 | option :client_secret 9 | option :grant_type, default: proc { 'client_credentials' } 10 | option :scope 11 | 12 | def authenticate 13 | response = connection.post nil, auth_params 14 | body = JSON.parse response.body 15 | 16 | unless response.success? 17 | message = "#{response.reason_phrase}: #{body['error'] || response.body}" 18 | raise Errors::AccessDenied, response: response, message: message 19 | end 20 | 21 | Access.new(body) 22 | end 23 | 24 | def connection 25 | super.basic_auth(client_id, client_secret) && super 26 | end 27 | 28 | private 29 | 30 | def auth_params 31 | { 32 | client_id: client_id, 33 | client_secret: client_secret, 34 | grant_type: grant_type, 35 | scope: scope 36 | } 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/reso_web_api/base_client.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'dry-initializer' 3 | 4 | module ResoWebApi 5 | # Base class for Faraday-based HTTP clients 6 | class BaseClient 7 | extend Dry::Initializer 8 | 9 | option :endpoint 10 | option :adapter, default: proc { Faraday::default_adapter } 11 | option :headers, default: proc { {} } 12 | option :logger, default: proc { ResoWebApi.logger } 13 | option :user_agent, default: proc { USER_AGENT } 14 | 15 | USER_AGENT = "Reso Web API Ruby Gem v#{VERSION}" 16 | 17 | # Return the {Faraday::Connection} object for this client. 18 | # Yields the connection object being constructed (for customzing middleware). 19 | # @return [Faraday::Connection] The connection object 20 | def connection(&block) 21 | @connection ||= Faraday.new(url: endpoint, headers: headers) do |conn| 22 | conn.request :url_encoded 23 | conn.response :logger, logger 24 | yield conn if block_given? 25 | conn.adapter adapter unless conn.builder.send(:adapter_set?) 26 | end 27 | end 28 | 29 | # Return the headers to be sent with every request. 30 | # @return [Hash] The request headers. 31 | def headers 32 | { 33 | :user_agent => user_agent 34 | }.merge(@headers) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /reso_web_api.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "reso_web_api/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "reso_web_api" 8 | spec.version = ResoWebApi::VERSION 9 | spec.authors = ["Christoph Wagner"] 10 | spec.email = ["christoph@wrstudios.com"] 11 | 12 | spec.summary = %q{RESO Web API for Ruby} 13 | spec.description = %q{Allows communication with MLS systems conforming to the RESO Web API standard} 14 | spec.homepage = "https://github.com/wrstudios/reso_web_api" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 18 | f.match(%r{^(test|spec|features)/}) 19 | end 20 | spec.bindir = "exe" 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency 'faraday', '~> 0.15.0' 25 | spec.add_dependency 'frodata', '~> 0.9.2' 26 | spec.add_dependency 'dry-initializer', '~> 1.4.1' 27 | 28 | spec.add_development_dependency "bundler", "~> 1.16" 29 | spec.add_development_dependency "rake", "~> 10.0" 30 | spec.add_development_dependency "rspec", "~> 3.0" 31 | spec.add_development_dependency "simplecov", "~> 0.15" 32 | end 33 | -------------------------------------------------------------------------------- /lib/reso_web_api/authentication/middleware.rb: -------------------------------------------------------------------------------- 1 | module ResoWebApi 2 | module Authentication 3 | # Authentication middleware 4 | # Ensures that each request is made with proper `Authorization` header set 5 | # and raises an exception if a request yields a `401 Access Denied` response. 6 | class Middleware < Faraday::Middleware 7 | AUTH_HEADER = 'Authorization'.freeze 8 | 9 | def initialize(app, auth) 10 | super(app) 11 | @auth = auth 12 | end 13 | 14 | def call(request_env) 15 | retries = 1 16 | 17 | begin 18 | authorize_request(request_env) 19 | 20 | @app.call(request_env).on_complete do |response_env| 21 | raise_if_unauthorized(response_env) 22 | end 23 | rescue Errors::AccessDenied 24 | raise if retries == 0 25 | @auth.reset 26 | retries -= 1 27 | retry 28 | end 29 | end 30 | 31 | private 32 | 33 | def authorize_request(request_env) 34 | @auth.ensure_valid_access! 35 | 36 | request_env[:request_headers].merge!( 37 | AUTH_HEADER => "#{@auth.access.token_type} #{@auth.access.token}" 38 | ) 39 | end 40 | 41 | def raise_if_unauthorized(response_env) 42 | raise Errors::AccessDenied if response_env[:status] == 401 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/reso_web_api/authentication/token_auth_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Authentication::TokenAuth do 2 | subject { ResoWebApi::Authentication::TokenAuth.new(config) } 3 | let(:config) {{ 4 | endpoint: 'https://auth.my-mls.org/connect/token', 5 | client_id: 'deadbeef', 6 | client_secret: 'T0pS3cr3t', 7 | scope: 'odata' 8 | }} 9 | let(:valid_auth) { 10 | { 11 | "access_token" => "eyJ0eXAiOiJKV1Qi", 12 | "expires_in" => 3600, 13 | "token_type" => "Bearer" 14 | }.to_json 15 | } 16 | let(:invalid_auth) { { 'error' => 'invalid_client' }.to_json } 17 | let(:stubs) { Faraday::Adapter::Test::Stubs.new } 18 | 19 | # Use Faraday test adapter and inject request stubs 20 | before { subject.connection { |conn| conn.adapter :test, stubs } } 21 | 22 | describe '#authenticate' do 23 | it 'returns valid access if authentication succeeds' do 24 | stubs.post('/connect/token') { |env| [200, {}, valid_auth] } 25 | access = subject.authenticate 26 | expect(access).to be_a(ResoWebApi::Authentication::Access) 27 | expect(access).to be_valid 28 | end 29 | 30 | it 'raises an exception when authentication fails' do 31 | stubs.post('/connect/token') { |env| [400, {}, invalid_auth] } 32 | expect { subject.authenticate }.to raise_error(ResoWebApi::Errors::AccessDenied) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/reso_web_api/authentication/auth_strategy.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Authentication::AuthStrategy do 2 | subject do 3 | ResoWebApi::Authentication::AuthStrategy.new(endpoint: '') 4 | end 5 | let(:access) { instance_double('ResoWebApi::Authentication::Access') } 6 | 7 | describe '#authenticate' do 8 | it 'should raise an error' do 9 | expect { subject.authenticate }.to raise_error(NotImplementedError) 10 | end 11 | end 12 | 13 | describe '#ensure_valid_access!' do 14 | it 'authenticates and returns access' do 15 | expect(subject).to receive(:authenticate).and_return(access) 16 | expect(subject.ensure_valid_access!).to eq(access) 17 | end 18 | 19 | it 'does not re-authenticate if access is valid' do 20 | allow(subject).to receive(:access).and_return(access) 21 | expect(access).to receive(:valid?).and_return(true) 22 | expect(subject).not_to receive(:authenticate) 23 | expect(subject.ensure_valid_access!).to eq(access) 24 | end 25 | 26 | it 'raises an exception if authentication fails' do 27 | expect(subject).to receive(:authenticate).and_raise(ResoWebApi::Errors::AccessDenied) 28 | expect { subject.ensure_valid_access! }.to raise_error(ResoWebApi::Errors::AccessDenied) 29 | end 30 | end 31 | 32 | describe 'reset' do 33 | it 'resets authentication' do 34 | subject.instance_variable_set(:@access, access) 35 | expect { subject.reset }.to change { subject.access }.to(nil) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/reso_web_api/authentication/access_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Authentication::Access do 2 | subject { ResoWebApi::Authentication::Access.new(token_response) } 3 | let(:token_response) {{ 4 | 'access_token' => '0xdeadbeef', 5 | 'token_type' => 'Bearer', 6 | 'expires_in' => 3600 7 | }} 8 | 9 | describe '#token' do 10 | it 'returns the access token' do 11 | expect(subject.token).to eq('0xdeadbeef') 12 | end 13 | end 14 | 15 | describe '#token_type' do 16 | it 'returns the token type' do 17 | expect(subject.token_type).to eq('Bearer') 18 | end 19 | end 20 | 21 | describe '#expires' do 22 | it 'returns the expiration time' do 23 | expect(subject.expires).to be_a(Time) 24 | end 25 | end 26 | 27 | describe '#expired?' do 28 | it 'returns false if token has not expired' do 29 | expect(subject.expired?).to eq(false) 30 | end 31 | 32 | it 'returns true if token has expired' do 33 | subject.expires = Time.now 34 | expect(subject.expired?).to eq(true) 35 | end 36 | end 37 | 38 | describe '#valid?' do 39 | it 'returns true if token is present and not expired' do 40 | expect(subject.valid?).to eq(true) 41 | end 42 | 43 | it 'returns false if token is not present' do 44 | subject.token = nil 45 | expect(subject.valid?).to eq(false) 46 | end 47 | 48 | it 'returns false if token is expired' do 49 | subject.expires = Time.now 50 | expect(subject.valid?).to be(false) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/reso_web_api/client.rb: -------------------------------------------------------------------------------- 1 | require_relative 'base_client' 2 | require_relative 'authentication' 3 | require_relative 'resources' 4 | 5 | module ResoWebApi 6 | # Main class to run requests against a RESO Web API server. 7 | class Client < BaseClient 8 | include Resources 9 | 10 | # Auth strategy (class instance or Hash) 11 | option :auth 12 | # Options for OData service 13 | option :odata, optional: true 14 | 15 | def initialize(options = {}) 16 | super(options) 17 | ensure_valid_auth_strategy! 18 | end 19 | 20 | # Return the {Faraday::Connection} object for this client. 21 | # Yields the connection object being constructed (for customzing middleware). 22 | # @return [Faraday::Connection] The connection object 23 | def connection(&block) 24 | super do |conn| 25 | conn.use Authentication::Middleware, @auth 26 | yield conn if block_given? 27 | end 28 | end 29 | 30 | # Returns a proxied {FrOData::Service} that attempts to ensure a properly 31 | # authenticated and authorized connection 32 | # @return [FrOData::Service] The service instance. 33 | def service 34 | # puts odata, service_options 35 | @service ||= FrOData::Service.new(connection, service_options) 36 | end 37 | 38 | # Returns the default options used by the by the OData service 39 | # @return [Hash] The options hash 40 | def service_options 41 | @service_options ||= { logger: logger }.merge(odata || {}) 42 | end 43 | 44 | private 45 | 46 | def ensure_valid_auth_strategy! 47 | if auth.is_a?(Hash) 48 | strategy = auth[:strategy] || Authentication::TokenAuth 49 | if strategy.is_a?(Class) && strategy <= Authentication::AuthStrategy 50 | @auth = strategy.new(auth) 51 | else 52 | raise ArgumentError, "#{strategy} is not a valid auth strategy" 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/reso_web_api/authentication/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Authentication::Middleware do 2 | let(:access) { instance_double('ResoWebApi::Authentication::Access') } 3 | let(:auth) { instance_double('ResoWebApi::Authentication::AuthStrategy') } 4 | let(:auth_success) do 5 | # Stub response to echo authorization header 6 | -> (env) { [200, {}, env[:request_headers]['Authorization']] } 7 | end 8 | let(:auth_failure) do 9 | # Stub response to return 401 Access Denied 10 | -> (env) { [401, {}, 'Access Denied'] } 11 | end 12 | let(:conn) do 13 | # @see https://github.com/lostisland/faraday/blob/master/test/authentication_middleware_test.rb 14 | Faraday::Connection.new('http://example.net') do |conn| 15 | conn.use ResoWebApi::Authentication::Middleware, auth 16 | conn.adapter :test do |stub| 17 | stub.get('/auth-echo') { |env| @responses.shift.call(env) } 18 | end 19 | end 20 | end 21 | 22 | before do 23 | # Mock access 24 | allow(access).to receive(:token).and_return('0xdeadbeef') 25 | allow(access).to receive(:token_type).and_return('Bearer') 26 | # Mock auth 27 | allow(auth).to receive(:access).and_return(access) 28 | allow(auth).to receive(:ensure_valid_access!).and_return(access) 29 | end 30 | 31 | it 'sets authorization header' do 32 | @responses = [auth_success] 33 | expect(conn.get('/auth-echo').body).to eq('Bearer 0xdeadbeef') 34 | end 35 | 36 | context 'when service rejects authentication' do 37 | it 'retries the request once' do 38 | @responses = [auth_failure, auth_success] 39 | # Expect middleware to retry authentication and eventually return a value 40 | expect(auth).to receive(:ensure_valid_access!).twice 41 | expect(auth).to receive(:reset) 42 | expect(conn.get('/auth-echo').body).to eq('Bearer 0xdeadbeef') 43 | end 44 | 45 | it 'gives up after that' do 46 | @responses = [auth_failure, auth_failure] 47 | expect(auth).to receive(:reset) 48 | expect { conn.get('/auth-echo') }.to raise_error(ResoWebApi::Errors::AccessDenied) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/reso_web_api/base_client_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::BaseClient do 2 | let(:endpoint) { 'https://service.my-mls.org/' } 3 | 4 | describe '.new' do 5 | it 'requires endpoint option' do 6 | expect { ResoWebApi::BaseClient.new }.to raise_error(ArgumentError, /endpoint/) 7 | end 8 | end 9 | 10 | context 'with default options' do 11 | subject { ResoWebApi::BaseClient.new(endpoint: endpoint) } 12 | 13 | describe '#endpoint' do 14 | it { expect(subject.endpoint).to eq(endpoint) } 15 | end 16 | 17 | describe '#adapter' do 18 | it 'uses default value' do 19 | expect(subject.adapter).to eq(Faraday.default_adapter) 20 | end 21 | end 22 | 23 | describe '#user_agent' do 24 | it 'uses default value' do 25 | expect(subject.user_agent).to eq(ResoWebApi::BaseClient::USER_AGENT) 26 | end 27 | end 28 | 29 | describe '#connection' do 30 | it { expect(subject.connection).to be_a(Faraday::Connection) } 31 | it 'uses the endpoint URL' do 32 | expect(subject.connection.url_prefix.to_s).to eq(endpoint) 33 | end 34 | it 'uses default middleware' do 35 | expect(subject.connection.builder.handlers).to include( 36 | Faraday::Request::UrlEncoded, Faraday::Adapter::NetHttp 37 | ) 38 | end 39 | it 'allows customizing the middleware stack by passing a block' do 40 | subject.connection do |conn| 41 | conn.request :basic_auth, 'aladdin', 'simsalabim' 42 | end 43 | expect(subject.connection.builder.handlers).to include(Faraday::Request::BasicAuthentication) 44 | end 45 | end 46 | 47 | describe '#headers' do 48 | it { expect(subject.headers).to be_a(Hash) } 49 | it { expect(subject.headers).to include(user_agent: ResoWebApi::BaseClient::USER_AGENT) } 50 | end 51 | 52 | describe '#logger' do 53 | it { expect(subject.logger).to be_a(Logger) } 54 | end 55 | end 56 | 57 | context 'when overriding adapter and user agent' do 58 | let(:adapter) { :typhoeus } 59 | let(:user_agent) { 'FooBar/1.2.3'} 60 | subject { ResoWebApi::BaseClient.new(endpoint: endpoint, adapter: adapter, user_agent: user_agent) } 61 | 62 | describe '#adapter' do 63 | it { expect(subject.adapter).to eq(adapter) } 64 | end 65 | 66 | describe '#connection' do 67 | it 'uses the correct adapter' do 68 | expect(subject.connection.builder.handlers).to include(Faraday::Adapter::Typhoeus) 69 | end 70 | end 71 | 72 | describe '#user_agent' do 73 | it 'uses the provided value' do 74 | expect(subject.user_agent).to eq(user_agent) 75 | end 76 | end 77 | 78 | describe '#headers' do 79 | it { expect(subject.headers).to include(user_agent: user_agent) } 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/reso_web_api/client_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ResoWebApi::Client do 2 | subject { ResoWebApi::Client.new(options) } 3 | let(:options) {{ endpoint: endpoint, auth: auth }} 4 | let(:auth) { instance_double('ResoWebApi::Authentication::AuthStrategy') } 5 | let(:access) { instance_double('ResoWebApi::Authentication::Access') } 6 | let(:endpoint) { 'http://services.odata.org/V4/OData/OData.svc' } 7 | let(:metadata) { 'spec/fixtures/files/metadata.xml' } 8 | 9 | describe '.new' do 10 | it 'requires auth option' do 11 | expect { ResoWebApi::Client.new(endpoint: endpoint) }.to raise_error(ArgumentError, /auth/) 12 | end 13 | 14 | it 'instantiates auth strategy if given a hash' do 15 | client = ResoWebApi::Client.new( 16 | endpoint: endpoint, 17 | auth: { 18 | strategy: ResoWebApi::Authentication::AuthStrategy, 19 | endpoint: '' 20 | } 21 | ) 22 | expect(client.auth).to be_a(ResoWebApi::Authentication::AuthStrategy) 23 | end 24 | 25 | it 'defaults to TokenAuth if no strategy was selected' do 26 | client = ResoWebApi::Client.new(endpoint: endpoint, auth: { 27 | endpoint: '', client_id: '', client_secret: '', scope: '' 28 | }) 29 | expect(client.auth).to be_a(ResoWebApi::Authentication::TokenAuth) 30 | end 31 | 32 | it 'ensures that a valid auth strategy is selected' do 33 | expect { 34 | ResoWebApi::Client.new(endpoint: endpoint, auth: { 35 | strategy: Object 36 | }) 37 | }.to raise_error(ArgumentError, /not a valid auth strategy/) 38 | end 39 | end 40 | 41 | describe '#connection' do 42 | it 'uses authentication middleware' do 43 | expect(subject.connection.builder.handlers).to include( 44 | ResoWebApi::Authentication::Middleware 45 | ) 46 | end 47 | end 48 | 49 | describe '#service' do 50 | let(:stub) { Faraday::Adapter::Test::Stubs.new } 51 | 52 | def stub_connection 53 | # Use Faraday test adapter to avoid making real network connections 54 | subject.connection { |conn| conn.adapter :test, stub } 55 | # Stub out connection to OData service 56 | stub.get('/V4/OData/OData.svc/$metadata') do |env| 57 | [ 200, { content_type: 'application/xml' }, File.read(metadata) ] 58 | end 59 | end 60 | 61 | before do 62 | # Stub auth 63 | allow(auth).to receive(:ensure_valid_access!) 64 | allow(auth).to receive(:access).and_return(access) 65 | # Stub access 66 | allow(access).to receive(:token).and_return('0xdeadbeef') 67 | allow(access).to receive(:token_type).and_return('Bearer') 68 | end 69 | 70 | it 'returns a OData service object with an authorized connection' do 71 | stub_connection 72 | expect(access).to receive(:token) 73 | expect(access).to receive(:token_type) 74 | expect(subject.service).to be_a(FrOData::Service) 75 | end 76 | 77 | it 'is aliased to #resources' do 78 | stub_connection 79 | expect(subject.resources).to be_a(FrOData::Service) 80 | end 81 | 82 | it 'uses OData options passed to constructor' do 83 | options[:odata] = { metadata_file: metadata } 84 | expect(subject.service.options).to include(metadata_file: metadata) 85 | end 86 | end 87 | 88 | ResoWebApi::Resources::STANDARD_RESOURCES.each do |method, resource| 89 | describe "##{method}" do 90 | # Stub out service to avoid making network requests 91 | let(:service) { instance_double('FrOData::Service') } 92 | before { subject.instance_variable_set(:@service, service) } 93 | 94 | it "gives me access to the #{resource} resource" do 95 | expect(service).to receive(:[]).with(resource) 96 | subject.send(method) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/fixtures/files/metadata.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ResoWebApi 2 | 3 | A Ruby library to connects to MLS servers conforming to the [RESO Web API][reso-web-api] standard. 4 | 5 | [reso-web-api]: https://www.reso.org/reso-web-api/ 6 | 7 | [![Gem Version](https://badge.fury.io/rb/reso_web_api.svg)](https://badge.fury.io/rb/reso_web_api) 8 | [![Build Status](https://app.codeship.com/projects/c9f88f50-3a07-0136-6878-6eab29180a68/status?branch=master)](https://app.codeship.com/projects/290070) 9 | [![Test Coverage](https://api.codeclimate.com/v1/badges/6e707a367bfdd609fc76/test_coverage)](https://codeclimate.com/github/wrstudios/reso_web_api/test_coverage) 10 | [![Maintainability](https://api.codeclimate.com/v1/badges/6e707a367bfdd609fc76/maintainability)](https://codeclimate.com/github/wrstudios/reso_web_api/maintainability) 11 | [![Documentation](http://inch-ci.org/github/wrstudios/reso_web_api.png?branch=master)](http://www.rubydoc.info/github/wrstudios/reso_web_api/master) 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'reso_web_api' 19 | ``` 20 | 21 | And then execute: 22 | 23 | $ bundle 24 | 25 | Or install it yourself as: 26 | 27 | $ gem install reso_web_api 28 | 29 | ## Usage 30 | 31 | ### Quickstart 32 | 33 | Instantiating an API client requires two things: an endpoint (i.e. service URL) and an authentication strategy. 34 | 35 | ```ruby 36 | require 'reso_web_api' 37 | 38 | client = ResoWebApi::Client.new(endpoint: '', auth: auth) 39 | ``` 40 | 41 | The `:endpoint` option should need no further explanation, for `:auth`, read on below. 42 | 43 | ### Authentication 44 | 45 | You may either instantiate the auth strategy directly and pass it to the client constructor (in the `:auth` parameter), or you may choose to pass a nested hash with options for configuring the strategy instead, as below: 46 | 47 | ```ruby 48 | client = ResoWebApi::Client.new( 49 | endpoint: 'https://api.my-mls.org/RESO/OData/', 50 | auth: { 51 | strategy: ResoWebApi::Authentication::TokenAuth, 52 | endpoint: 'https://oauth.my-mls.org/connect/token', 53 | client_id: 'deadbeef', 54 | client_secret: 'T0pS3cr3t', 55 | scope: 'odata' 56 | } 57 | end 58 | ``` 59 | 60 | Note that if you choose this option, you _may_ specify the strategy implementation by passing its _class_ as the `:strategy` option. 61 | If you omit the `:strategy` parameter, it will default to `ResoWebApi::Authentication::TokenAuth`. 62 | 63 | For a list of available authentication strategies and usage examples, please [see below](#authentication-strategies). 64 | 65 | ### Advanced Configuration 66 | 67 | The client is designed to work out-of-the-box and require as little configuration as possible (only endpoint and auth by default). 68 | However, if you need more control, there are several additional settings that can be configured using the constructor. 69 | 70 | - `:user_agent`: Sets the `User-Agent` header sent to the service (defaults to `Reso Web API Ruby Gem $VERSION`) 71 | - `:adapter`: Sets the Faraday adapter used for the connection (defaults to `Net::HTTP`) 72 | - `:headers`: Allows custom headers to be set on the connection. 73 | - `:logger`: You may pass your own logger to a client instance. By default, each instance will use the global logger defined on the `ResoWebApi` module, which logs to STDOUT. You can also change the logger on the module itself, which will then be used for all new client instances you create. 74 | - `:odata`: If you need to pass any special options to the OData service, you may do so here. 75 | 76 | ### Accessing Data 77 | 78 | #### Standard Resources 79 | 80 | Since most RESO Web API servers will likely adhere to the RESO Data Dictionary, we've created some shortcuts for the standard resources 81 | 82 | ```ruby 83 | # Iterate over all properties -- WARNING! Might take a long time 84 | client.properties.each do |property| 85 | puts "#{property['ListPrice']} #{property['StandardStatus']}" 86 | end 87 | ``` 88 | 89 | The following methods are provided: 90 | 91 | - Property: use `#properties` 92 | - Member: use `#members` 93 | - Office: use `#office` 94 | - Media: use `#media` 95 | 96 | #### Other Resources 97 | 98 | Other resources may be access using the `#resources` method on the client, which may be accessed as a hash like this: 99 | 100 | ```ruby 101 | client.resources['OpenHouse'].first # Access the 'OpenHouse' collection 102 | ``` 103 | 104 | ## Authentication Strategies 105 | 106 | Since the details of authentication may vary from vendor to vendor, this gem attempts to stay flexible by providing a modular authentication system. 107 | 108 | ### Available Strategies 109 | 110 | Currently, we provide the following authentication strategies: 111 | 112 | #### `SimpleTokenAuth` 113 | 114 | A simple strategy that works with a static access token. Often used for development access, where security is not a major concern. 115 | 116 | ##### Configuration 117 | 118 | - `access_token`: The access token value (`String`). 119 | - `token_type`: The token type (`String`, optional). Defaults to `Bearer`. 120 | 121 | ##### Example 122 | 123 | ```ruby 124 | client = ResoWebApi::Client.new( 125 | endpoint: 'https://api.my-mls.org/RESO/OData/', 126 | auth: { 127 | strategy: ResoWebApi::Authentication::SimpleTokenAuth, 128 | access_token: 'abcdefg01234567890' 129 | } 130 | ) 131 | ``` 132 | 133 | #### `TokenAuth` 134 | 135 | A basic OAuth-based token strategy, where a Client ID/Secret pair is sent to a server in exchange for a temporary access token. Frequently used in production systems for its increased security over the static token strategy. 136 | 137 | ##### Configuration 138 | 139 | - `endpoint`: The URL of the token server (`String`). 140 | - `client_id`: The Client ID (`String`). 141 | - `client_secret`: The Client Secret (`String`). 142 | - `scope`: The scope for the token (`String`). 143 | - `grant_type`: The grant type (`String`, optional). Defaults to `client_credentials`. 144 | 145 | ##### Example 146 | 147 | ```ruby 148 | client = ResoWebApi::Client.new( 149 | endpoint: 'https://api.my-mls.org/RESO/OData/', 150 | auth: { 151 | strategy: ResoWebApi::Authentication::TokenAuth, 152 | endpoint: 'https://oauth.my-mls.org/connect/token', 153 | client_id: 'deadbeef', 154 | client_secret: 'T0pS3cr3t', 155 | scope: 'odata' 156 | } 157 | end 158 | ``` 159 | 160 | ## Development 161 | 162 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 163 | 164 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 165 | 166 | ## Contributing 167 | 168 | Bug reports and pull requests are welcome on GitHub at https://github.com/wrstudios/reso_web_api. 169 | 170 | ## License 171 | 172 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 173 | --------------------------------------------------------------------------------