├── .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 | [](https://badge.fury.io/rb/reso_web_api)
8 | [](https://app.codeship.com/projects/290070)
9 | [](https://codeclimate.com/github/wrstudios/reso_web_api/test_coverage)
10 | [](https://codeclimate.com/github/wrstudios/reso_web_api/maintainability)
11 | [](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 |
--------------------------------------------------------------------------------