├── .rspec ├── Rakefile ├── lib ├── api_client │ ├── version.rb │ ├── utils.rb │ ├── mixins │ │ ├── inheritance.rb │ │ ├── connection_hooks.rb │ │ ├── delegation.rb │ │ ├── configuration.rb │ │ ├── instantiation.rb │ │ └── scoping.rb │ ├── connection │ │ ├── oauth.rb │ │ ├── json.rb │ │ ├── middlewares │ │ │ └── request │ │ │ │ ├── oauth.rb │ │ │ │ ├── json.rb │ │ │ │ └── logger.rb │ │ ├── abstract.rb │ │ └── basic.rb │ ├── resource │ │ ├── name_resolver.rb │ │ ├── base.rb │ │ └── scope.rb │ ├── errors.rb │ ├── base.rb │ └── scope.rb └── api_client.rb ├── .gitignore ├── .github ├── CODEOWNERS └── workflows │ ├── tests.yml │ └── gempush.yml ├── spec ├── support │ └── matchers.rb ├── spec_helper.rb └── api_client │ ├── base │ ├── delegation_spec.rb │ ├── connection_hook_spec.rb │ ├── marshalling_spec.rb │ ├── parsing_spec.rb │ ├── inheritance_spec.rb │ ├── instantiation_spec.rb │ └── scoping_spec.rb │ ├── resource │ ├── name_spec.rb │ ├── base_spec.rb │ └── scope_spec.rb │ ├── connection │ ├── request │ │ ├── logger_spec.rb │ │ ├── oauth_spec.rb │ │ └── json_spec.rb │ ├── abstract_spec.rb │ ├── oauth_spec.rb │ └── basic_spec.rb │ ├── utils_spec.rb │ ├── base_spec.rb │ └── scope_spec.rb ├── Gemfile ├── examples ├── digg.rb ├── twitter.rb ├── flickr.rb ├── twitter_oauth.rb ├── highrise.rb └── github.rb ├── api_client.gemspec ├── CHANGELOG.md ├── README.md └── LICENSE /.rspec: -------------------------------------------------------------------------------- 1 | --color -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /lib/api_client/version.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | VERSION = '0.6.0' 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | bin 6 | coverage 7 | examples/config.rb 8 | *.sw[a-z] 9 | .idea/* 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS file 2 | # This file defines who should review code changes in this repository. 3 | 4 | * @zendesk/sell-composite 5 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :inherit_from do |parent| 2 | match do |klass| 3 | klass.ancestors.include?(parent) 4 | end 5 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in fs-api.gemspec 4 | gemspec 5 | 6 | # Testing 7 | gem "simplecov" 8 | 9 | # Soft dependencies 10 | gem "simple_oauth", '0.2.0' 11 | gem 'multi_xml' 12 | gem "libxml-ruby" 13 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | $:.push File.expand_path("../../lib", __FILE__) 4 | require "rspec" 5 | 6 | require 'simplecov' 7 | SimpleCov.start do 8 | add_filter '/spec' 9 | end 10 | 11 | require "api_client" 12 | 13 | Dir.glob("#{File.dirname(__FILE__)}/support/*.rb").each { |f| require f } 14 | -------------------------------------------------------------------------------- /lib/api_client/utils.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Utils 4 | 5 | def self.deep_merge(hash, other_hash) 6 | other_hash.each_pair do |key,v| 7 | if hash[key].is_a?(::Hash) and v.is_a?(::Hash) 8 | deep_merge hash[key], v 9 | else 10 | hash[key] = v 11 | end 12 | end 13 | hash 14 | end 15 | 16 | end 17 | 18 | end -------------------------------------------------------------------------------- /lib/api_client/mixins/inheritance.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Mixins 4 | 5 | module Inheritance 6 | 7 | def inherited(subclass) 8 | subclass.default_scopes = self.default_scopes.dup 9 | subclass.connection_hooks = self.connection_hooks.dup 10 | 11 | subclass.namespace self.namespace 12 | subclass.format self.format 13 | end 14 | 15 | end 16 | 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /spec/api_client/base/delegation_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | it "delegates methods to scope" do 6 | scope = double 7 | ApiClient::Base.stub(:scope).and_return(scope) 8 | [:fetch, :get, :put, :post, :patch, :delete, :headers, :endpoint, :options, :adapter, :params, :raw].each do |method| 9 | scope.should_receive(method) 10 | ApiClient::Base.send(method) 11 | end 12 | 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/api_client/mixins/connection_hooks.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Mixins 4 | 5 | module ConnectionHooks 6 | 7 | attr_accessor :connection_hooks 8 | 9 | def connection(&block) 10 | @connection_hooks ||= [] 11 | @connection_hooks.push(block) if block 12 | @connection_hooks 13 | end 14 | 15 | def connection_hooks 16 | @connection_hooks || [] 17 | end 18 | 19 | end 20 | 21 | end 22 | 23 | end 24 | 25 | -------------------------------------------------------------------------------- /lib/api_client/mixins/delegation.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Mixins 4 | 5 | module Delegation 6 | 7 | def delegate(*methods) 8 | hash = methods.pop 9 | to = hash[:to] 10 | methods.each do |method| 11 | class_eval <<-STR 12 | def #{method}(*args, &block) 13 | #{to}.#{method}(*args, &block) 14 | end 15 | STR 16 | end 17 | end 18 | 19 | end 20 | 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /lib/api_client/connection/oauth.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Connection 4 | 5 | class Oauth < Basic 6 | 7 | def finalize_handler 8 | @handler.use Middlewares::Request::Logger, ApiClient.logger if ApiClient.logger 9 | @handler.use Middlewares::Request::OAuth, @options[:oauth] 10 | @handler.use Faraday::Request::UrlEncoded 11 | @handler.adapter Faraday.default_adapter 12 | end 13 | 14 | end 15 | 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/api_client/connection/json.rb: -------------------------------------------------------------------------------- 1 | # Exactly like Basic, but uses JSON encoding for request body 2 | # if applicable 3 | module ApiClient 4 | module Connection 5 | class Json < Basic 6 | def finalize_handler 7 | @handler.use Middlewares::Request::Logger, ApiClient.logger if ApiClient.logger 8 | @handler.use Middlewares::Request::Json 9 | @handler.use Faraday::Request::UrlEncoded 10 | @handler.adapter Faraday.default_adapter 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/api_client/base/connection_hook_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | describe '.connection' do 6 | 7 | it "registers a new connection_hook" do 8 | ConnectionHookTestProc = lambda {} 9 | class ConnectionHookTest < ApiClient::Base 10 | connection &ConnectionHookTestProc 11 | end 12 | expect(ConnectionHookTest.connection_hooks.size).to eq(1) 13 | expect(ConnectionHookTest.connection_hooks).to eq([ConnectionHookTestProc]) 14 | end 15 | 16 | end 17 | 18 | end -------------------------------------------------------------------------------- /examples/digg.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "api_client" 4 | 5 | module Digg 6 | 7 | class Base < ApiClient::Base 8 | 9 | always do 10 | endpoint "http://services.digg.com" 11 | params :type => 'json' 12 | end 13 | 14 | end 15 | 16 | class Collection < Base 17 | 18 | def self.diggs 19 | Digg.build get('/2.0/digg.getAll')['diggs'] 20 | end 21 | 22 | end 23 | 24 | class Digg < Base 25 | end 26 | 27 | end 28 | 29 | Digg::Collection.diggs.each do |digg| 30 | puts "#{digg.user.name}: #{digg.item.title}" 31 | end 32 | -------------------------------------------------------------------------------- /lib/api_client/mixins/configuration.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Mixins 4 | 5 | module Configuration 6 | 7 | def dsl_accessor(*names) 8 | options = names.last.is_a?(Hash) ? names.pop : {} 9 | names.each do |name| 10 | returns = options[:return_self] ? "self" : "@#{name}" 11 | class_eval <<-STR 12 | def #{name}(value = nil) 13 | value.nil? ? @#{name} : @#{name} = value 14 | #{returns} 15 | end 16 | STR 17 | end 18 | end 19 | 20 | end 21 | 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/api_client/resource/name_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ApiClient::Resource::NameResolver do 4 | describe '.resolve' do 5 | subject { described_class } 6 | 7 | it 'changes My::Namespace::MyResouce to my_resource' do 8 | subject.resolve('My::Namespace::MyResource').should == 'my_resource' 9 | end 10 | 11 | it 'changes Resource to resource' do 12 | subject.resolve('Resource').should == 'resource' 13 | end 14 | 15 | it 'changes My::Resource to resoure' do 16 | subject.resolve('My::Resource').should == 'resource' 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/api_client/connection/request/logger_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Middlewares::Request::Logger do 4 | it "adds a oauth header to the request" do 5 | app = double 6 | io = StringIO.new 7 | logger = Logger.new(io) 8 | instance = ApiClient::Connection::Middlewares::Request::Logger.new(app, logger) 9 | env = { 10 | :url => "http://api.twitter.com", 11 | :request_headers => {}, 12 | :method => 'get' 13 | } 14 | app.should_receive(:call).with(env) 15 | instance.call(env) 16 | io.string.should match("GET http://api.twitter.com") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/api_client/connection/abstract_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Abstract do 4 | 5 | class ConnectionSubclass < ApiClient::Connection::Abstract 6 | end 7 | 8 | it "does not raise an error when instantiating a subclass" do 9 | lambda { 10 | ConnectionSubclass.new("http://google.com") 11 | }.should_not raise_error() 12 | end 13 | 14 | it "raises an error when instantiating directly and not as a subclass" do 15 | lambda { 16 | ApiClient::Connection::Abstract.new("http://google.com") 17 | }.should raise_error("Cannot instantiate abstract class") 18 | end 19 | 20 | end 21 | 22 | -------------------------------------------------------------------------------- /examples/twitter.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "api_client" 4 | 5 | module Twitter 6 | 7 | class Base < ApiClient::Base 8 | always do 9 | endpoint "http://api.twitter.com/" 10 | end 11 | end 12 | 13 | class Tweet < Base 14 | end 15 | 16 | class User < Base 17 | 18 | def self.find_by_username(name) 19 | params(:screen_name => name).fetch("/1/users/show.json") 20 | end 21 | 22 | def tweets 23 | Tweet.params(:screen_name => self.screen_name).fetch("/1/statuses/user_timeline.json") 24 | end 25 | 26 | end 27 | 28 | end 29 | 30 | user = Twitter::User.find_by_username("marcinbunsch") 31 | puts user.name 32 | user.tweets.each do |tweet| 33 | puts " #{tweet.text}" 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby-version: 16 | - 2.3.8 17 | - 2.4.6 18 | - 2.5.5 19 | - 2.6.3 20 | - 2.7.7 21 | - 3.0.5 22 | - 3.1.3 23 | - 3.2.0 24 | 25 | steps: 26 | - uses: zendesk/checkout@v3 27 | - name: Set up Ruby 28 | uses: zendesk/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby-version }} 31 | - name: Install dependencies 32 | run: bundle install 33 | - name: Run tests 34 | run: bundle exec rspec 35 | -------------------------------------------------------------------------------- /lib/api_client/mixins/instantiation.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | module Mixins 3 | module Instantiation 4 | def self.extended(base) 5 | base.instance_eval do 6 | attr_accessor :original_scope 7 | end 8 | end 9 | 10 | def build_one(hash) 11 | instance = self.new self.namespace ? hash[namespace] : hash 12 | instance.original_scope = self.scope.clone_only_headers 13 | instance 14 | end 15 | 16 | def build_many(array) 17 | array.collect { |one| build_one(one) } 18 | end 19 | 20 | def build(result_or_array) 21 | if result_or_array.is_a?(Array) 22 | build_many result_or_array 23 | else 24 | build_one result_or_array 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/api_client/connection/middlewares/request/oauth.rb: -------------------------------------------------------------------------------- 1 | # Borrowed from https://github.com/pengwynn/faraday_middleware/blob/master/lib/faraday/request/oauth.rb 2 | class ApiClient::Connection::Middlewares::Request::OAuth < Faraday::Middleware 3 | 4 | dependency 'simple_oauth' 5 | 6 | def call(env) 7 | params = env[:body] || {} 8 | signature_params = params.reject{ |k,v| v.respond_to?(:content_type) } 9 | 10 | header = SimpleOAuth::Header.new(env[:method], env[:url], signature_params, @options || {}) 11 | 12 | env[:request_headers]['Authorization'] = header.to_s 13 | env[:request_headers]['User-Agent'] = "ApiClient gem v#{ApiClient::VERSION}" 14 | 15 | @app.call(env) 16 | end 17 | 18 | def initialize(app, options = {}) 19 | @app, @options = app, options 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /spec/api_client/connection/request/oauth_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Middlewares::Request::OAuth do 4 | 5 | it "adds a oauth header to the request" do 6 | app = double 7 | options = { 8 | :token => 'TOKEN', 9 | :token_secret => 'SECRET', 10 | :consumer_key => 'CONSUMER_KEY', 11 | :consumer_secret => 'CONSUMER_SECRET' 12 | } 13 | instance = ApiClient::Connection::Middlewares::Request::OAuth.new(app, options) 14 | env = { 15 | :url => "http://api.twitter.com", 16 | :request_headers => {} 17 | } 18 | app.should_receive(:call).with(env) 19 | 20 | instance.call(env) 21 | env[:request_headers]['Authorization'].should match("OAuth") 22 | env[:request_headers]['User-Agent'].should match("ApiClient gem") 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /spec/api_client/connection/request/json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Middlewares::Request::Json do 4 | let(:app) { double } 5 | let(:body) { {:some => :data} } 6 | let(:env) do 7 | { 8 | :url => "http://api.twitter.com", 9 | :request_headers => {}, 10 | :method => "post", 11 | :body => body 12 | } 13 | end 14 | 15 | subject { ApiClient::Connection::Middlewares::Request::Json.new(app) } 16 | 17 | it "sets content type to json" do 18 | app.should_receive(:call). 19 | with(hash_including(:request_headers => {"Content-Type" => "application/json"})) 20 | 21 | subject.call(env) 22 | end 23 | 24 | it "JSON encodes body" do 25 | app.should_receive(:call). 26 | with(hash_including(:body => MultiJson.dump(body))) 27 | 28 | subject.call(env) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /examples/flickr.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "./examples/config" if File.exist?('examples/config.rb') 4 | require "api_client" 5 | 6 | module Flickr 7 | 8 | class Base < ApiClient::Base 9 | always do 10 | endpoint "http://api.flickr.com" 11 | params :api_key => FLICKR_API_KEY, 12 | :format => 'json', 13 | :nojsoncallback => 1 14 | end 15 | end 16 | 17 | class Collection < Base 18 | 19 | def self.interesting 20 | build params(:method => 'flickr.interestingness.getList'). 21 | get("/services/rest") 22 | end 23 | 24 | def photos 25 | Photo.build self['photos']['photo'] 26 | end 27 | 28 | end 29 | 30 | class Photo < Base 31 | end 32 | 33 | end 34 | 35 | Flickr::Collection.interesting.photos.each do |photo| 36 | puts photo.title 37 | end 38 | -------------------------------------------------------------------------------- /lib/api_client/resource/name_resolver.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | module Resource 3 | class NameResolver 4 | def self.resolve(ruby_path) 5 | new(ruby_path).resolve 6 | end 7 | 8 | attr_reader :name 9 | 10 | def initialize(name) 11 | @name = name 12 | end 13 | 14 | def resolve 15 | select_last_item 16 | underscorize 17 | lowercase 18 | name 19 | end 20 | 21 | private 22 | def select_last_item 23 | @name = @name.split('::').last 24 | end 25 | 26 | #Inspired by ActiveSupport::Inflector#underscore 27 | def underscorize 28 | @name.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2') 29 | @name.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 30 | end 31 | 32 | def lowercase 33 | @name.downcase! 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/gempush.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem 2 | 3 | on: 4 | push: 5 | tags: v* 6 | 7 | jobs: 8 | build: 9 | name: Build + Publish 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | ruby-version: ["head", "jruby"] 15 | 16 | steps: 17 | - uses: zendesk/checkout@v2 18 | - name: Set up Ruby 19 | uses: zendesk/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby-version }} 22 | 23 | - name: Publish to RubyGems 24 | run: | 25 | mkdir -p $HOME/.gem 26 | touch $HOME/.gem/credentials 27 | chmod 0600 $HOME/.gem/credentials 28 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 29 | gem build *.gemspec 30 | gem push *.gem 31 | env: 32 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 33 | -------------------------------------------------------------------------------- /examples/twitter_oauth.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "./examples/config" if File.exist?('examples/config.rb') 4 | require "api_client" 5 | 6 | module TwitterOauth 7 | 8 | class Base < ApiClient::Base 9 | 10 | always do 11 | endpoint "https://api.twitter.com/" 12 | adapter :oauth 13 | 14 | options :oauth => { 15 | :consumer_key => TWITTER_CONSUMER_KEY, :consumer_secret => TWITTER_CONSUMER_SECRET 16 | } 17 | 18 | end 19 | 20 | end 21 | 22 | class Tweet < Base 23 | 24 | def self.tweet(message) 25 | build post('/1/statuses/update.json', :status => message) 26 | end 27 | 28 | end 29 | 30 | end 31 | 32 | config = { :token => TWITTER_TOKEN, :token_secret => TWITTER_SECRET } 33 | 34 | message = TwitterOauth::Tweet.options(:oauth => config).tweet("test #{Time.now.to_i}") 35 | 36 | puts message.text 37 | -------------------------------------------------------------------------------- /spec/api_client/base/marshalling_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Marshaling of ApiClient objects" do 4 | ConnectionProc = Proc.new {} 5 | AlwaysProc = Proc.new {} 6 | 7 | class Entity < ApiClient::Base 8 | connection &ConnectionProc 9 | always &AlwaysProc 10 | 11 | def mutated_state? 12 | @state == "mutated" 13 | end 14 | 15 | def mutate_state! 16 | @state = "mutated" 17 | end 18 | end 19 | 20 | it "is marshallable by default" do 21 | scope = Entity.params(:foo => 1).headers("token" => "aaa").options("some" => "option") 22 | entity = scope.build :key => "value" 23 | 24 | entity.mutated_state?.should == false 25 | entity.mutate_state! 26 | entity.mutated_state?.should == true 27 | 28 | reloaded = Marshal.load(Marshal.dump(entity)) 29 | 30 | reloaded.should == entity 31 | reloaded.mutated_state?.should == true 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /examples/highrise.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "./examples/config" if File.exist?('examples/config.rb') 4 | require "api_client" 5 | require "multi_xml" 6 | 7 | module Highrise 8 | 9 | class Base < ApiClient::Resource::Base 10 | format :xml 11 | 12 | # In this hook we set the basic auth provided in the options 13 | connection do |connection| 14 | connection.handler.basic_auth connection.options[:user], connection.options[:pass] 15 | end 16 | 17 | always do 18 | endpoint HIGHRISE_URL 19 | options(:user => HIGHRISE_TOKEN, :pass => 'X') 20 | end 21 | 22 | end 23 | 24 | class Person < Base 25 | namespace false 26 | 27 | always do 28 | path "people" 29 | end 30 | 31 | def self.build_one(hash) 32 | hash.has_key?('people') ? build_many(hash['people']) : super(hash) 33 | end 34 | 35 | end 36 | 37 | end 38 | 39 | Highrise::Person.find_all.each do |person| 40 | puts "#{person.first_name} #{person.last_name}" 41 | end 42 | -------------------------------------------------------------------------------- /lib/api_client/connection/middlewares/request/json.rb: -------------------------------------------------------------------------------- 1 | class ApiClient::Connection::Middlewares::Request::Json < Faraday::Middleware 2 | CONTENT_TYPE = "Content-Type".freeze 3 | 4 | class << self 5 | attr_accessor :mime_type 6 | end 7 | self.mime_type = "application/json".freeze 8 | 9 | def call(env) 10 | match_content_type(env) do |data| 11 | params = Faraday::Utils::ParamsHash[data] 12 | env[:body] = MultiJson.dump(params) 13 | end 14 | @app.call env 15 | end 16 | 17 | def match_content_type(env) 18 | if process_request?(env) 19 | env[:request_headers][CONTENT_TYPE] ||= self.class.mime_type 20 | yield env[:body] unless env[:body].respond_to?(:to_str) 21 | end 22 | end 23 | 24 | def process_request?(env) 25 | type = request_type(env) 26 | env[:body] and (type.empty? or type == self.class.mime_type) 27 | end 28 | 29 | def request_type(env) 30 | type = env[:request_headers][CONTENT_TYPE].to_s 31 | type = type.split(";", 2).first if type.index(";") 32 | type 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/api_client/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Utils do 4 | 5 | describe '.deep_merge' do 6 | 7 | it "merges two hashes updating the first one" do 8 | hash_a = { :a => 1, :b => 2 } 9 | hash_b = { :b => 3, :c => 45 } 10 | ApiClient::Utils.deep_merge hash_a, hash_b 11 | hash_a.should == { :a => 1, :b => 3, :c=>45 } 12 | hash_b.should == { :b => 3, :c => 45 } 13 | end 14 | 15 | it "deeply merges two hashes recursively" do 16 | hash_a = { :a => { :foo => 2, :boo => { :wat => 'wat' } }, :b => 2 } 17 | hash_b = { :b => 3, :c => 45, :a => { :boo => { :wat => "WAT????" } } } 18 | ApiClient::Utils.deep_merge hash_a, hash_b 19 | hash_a.should == { :a => { :foo => 2, :boo => { :wat => 'WAT????' } }, :b => 3, :c => 45 } 20 | end 21 | 22 | it "require correct key type" do 23 | hash_a = { :a => 1 } 24 | hash_b = { 'a' => 2 } 25 | ApiClient::Utils.deep_merge hash_a, hash_b 26 | hash_a.should == { :a => 1, 'a' => 2 } 27 | 28 | end 29 | 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /spec/api_client/connection/oauth_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Oauth do 4 | 5 | it "uses correct adapter" do 6 | instance = ApiClient::Connection::Oauth.new("http://google.com") 7 | expect(instance.handler.builder.adapter.name).to eq("Faraday::Adapter::NetHttp") 8 | end 9 | 10 | it "adds basic middlewares to faraday" do 11 | instance = ApiClient::Connection::Oauth.new("http://google.com") 12 | expect(instance.handler.builder.handlers.collect(&:name)) 13 | .to include("ApiClient::Connection::Middlewares::Request::OAuth", "Faraday::Request::UrlEncoded") 14 | end 15 | 16 | it "adds the logger middlewares to faraday if ApiClient.logger is available" do 17 | logger = double 18 | ApiClient.stub(:logger).and_return(logger) 19 | instance = ApiClient::Connection::Oauth.new("http://google.com") 20 | expect(instance.handler.builder.handlers.collect(&:name)) 21 | .to include("ApiClient::Connection::Middlewares::Request::Logger", "ApiClient::Connection::Middlewares::Request::OAuth", "Faraday::Request::UrlEncoded") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /examples/github.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "./examples/config" if File.exist?('examples/config.rb') 4 | require "api_client" 5 | require "time" 6 | 7 | module Github 8 | 9 | class Base < ApiClient::Base 10 | namespace false 11 | 12 | always do 13 | endpoint "https://api.github.com" 14 | end 15 | end 16 | 17 | class User < Base 18 | 19 | def self.find(name) 20 | fetch("/users/#{name}") 21 | end 22 | 23 | def events 24 | Github::Event.fetch("/users/#{login}/events") 25 | end 26 | 27 | def received_events 28 | Github::Event.fetch("/users/#{login}/received_events") 29 | end 30 | 31 | end 32 | 33 | class Event < Base 34 | 35 | def created_at 36 | Time.parse self['created_at'] 37 | end 38 | 39 | end 40 | 41 | end 42 | 43 | user = Github::User.find("marcinbunsch") 44 | 45 | user.events.each do |event| 46 | case event.type 47 | when "FollowEvent" 48 | puts "#{event.created_at} #{event.payload.target.login}: #{event.type}" 49 | else 50 | puts "#{event.created_at} #{event.repo.name} : #{event.type}" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/api_client/errors.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Errors 4 | class ApiClientError < StandardError 5 | def initialize(message = nil, request = nil, response = nil) 6 | message ||= "Status code: #{response.status}" if response 7 | super(message) 8 | @request = request 9 | @response = response 10 | end 11 | 12 | attr_reader :request, :response 13 | end 14 | 15 | class ConnectionFailed < ApiClientError; end 16 | class Config < ApiClientError; end 17 | class Unauthorized < ApiClientError; end 18 | class Forbidden < ApiClientError; end 19 | class NotFound < ApiClientError; end 20 | class Redirect < ApiClientError; end 21 | class BadRequest < ApiClientError; end 22 | class Unsupported < ApiClientError; end 23 | class Conflict < ApiClientError; end 24 | class Gone < ApiClientError; end 25 | class ServerError < ApiClientError; end 26 | class UnprocessableEntity < ApiClientError; end 27 | class PreconditionFailed < ApiClientError; end 28 | class Locked < ApiClientError; end 29 | class TooManyRequests < ApiClientError; end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /lib/api_client/mixins/scoping.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Mixins 4 | 5 | module Scoping 6 | 7 | attr_accessor :default_scopes 8 | 9 | # Default scoping 10 | def always(&block) 11 | default_scopes.push(block) if block 12 | end 13 | 14 | def default_scopes 15 | @default_scopes || [] 16 | end 17 | 18 | # Scoping 19 | def scope(options = {}) 20 | scope_in_thread || Scope.new(self).params(options) 21 | end 22 | 23 | # Allow wrapping singleton methods in a scope 24 | # Store the handler in a thread-local variable for thread safety 25 | def scoped(scope) 26 | Thread.current[scope_thread_attribute_name] ||= [] 27 | Thread.current[scope_thread_attribute_name].push scope 28 | begin 29 | yield 30 | ensure 31 | Thread.current[scope_thread_attribute_name] = nil 32 | end 33 | end 34 | 35 | def scope_thread_attribute_name 36 | "#{self.name}_scope" 37 | end 38 | 39 | def scope_in_thread 40 | if found = Thread.current[scope_thread_attribute_name] 41 | found.last 42 | end 43 | end 44 | 45 | end 46 | 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /spec/api_client/base/parsing_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | require "multi_xml" 4 | 5 | describe ApiClient::Base do 6 | 7 | it "parses json if json is set as format" do 8 | ApiClient::Base.stub(:format).and_return(:json) 9 | parsed = ApiClient::Base.parse('{"a":"1"}') 10 | parsed.should == {"a"=> "1"} 11 | end 12 | 13 | it "parses xml if xml is set as format" do 14 | ApiClient::Base.stub(:format).and_return(:xml) 15 | parsed = ApiClient::Base.parse('1') 16 | parsed.should == {"a"=> "1"} 17 | end 18 | 19 | it "returns the string if parser is not found" do 20 | ApiClient::Base.stub(:format).and_return(:unknown) 21 | parsed = ApiClient::Base.parse('a:1') 22 | parsed.should == "a:1" 23 | end 24 | 25 | it "extracts the body of a Faraday::Response if it is provided" do 26 | response = Faraday::Response.new(:body => '{"a": "1"}') 27 | ApiClient::Base.stub(:format).and_return(:json) 28 | parsed = ApiClient::Base.parse(response) 29 | parsed.should == {"a"=> "1"} 30 | end 31 | 32 | it "returns nil if the response is 204" do 33 | response = Faraday::Response.new(:body => nil, :status => 204) 34 | ApiClient::Base.stub(:format).and_return(:json) 35 | parsed = ApiClient::Base.parse(response) 36 | parsed.should == nil 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/api_client.rb: -------------------------------------------------------------------------------- 1 | require "api_client/version" 2 | require "faraday" 3 | require "hashie" 4 | require "multi_json" 5 | 6 | module ApiClient 7 | class << self 8 | attr_accessor :logger 9 | end 10 | 11 | module Mixins 12 | require "api_client/mixins/connection_hooks" 13 | require "api_client/mixins/delegation" 14 | require "api_client/mixins/configuration" 15 | require "api_client/mixins/inheritance" 16 | require "api_client/mixins/instantiation" 17 | require "api_client/mixins/scoping" 18 | end 19 | 20 | require "api_client/base" 21 | require "api_client/errors" 22 | require "api_client/scope" 23 | require "api_client/utils" 24 | 25 | module Resource 26 | require "api_client/resource/base" 27 | require "api_client/resource/scope" 28 | require "api_client/resource/name_resolver" 29 | end 30 | 31 | module Connection 32 | class << self 33 | attr_accessor :default 34 | end 35 | self.default = :basic 36 | 37 | module Middlewares 38 | module Request 39 | require "api_client/connection/middlewares/request/oauth" 40 | require "api_client/connection/middlewares/request/logger" 41 | require "api_client/connection/middlewares/request/json" 42 | end 43 | end 44 | 45 | require "api_client/connection/abstract" 46 | require "api_client/connection/basic" 47 | require "api_client/connection/json" 48 | require "api_client/connection/oauth" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/api_client/base/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | describe "subclasses" do 6 | 7 | it "inherit scopes, hooks, namespace and format" do 8 | 9 | class Level1InheritanceTest < ApiClient::Base 10 | end 11 | 12 | Level1InheritanceTest.default_scopes.should == [] 13 | Level1InheritanceTest.connection_hooks.should == [] 14 | Level1InheritanceTest.namespace.should == nil 15 | Level1InheritanceTest.format.should == :json 16 | 17 | Level1InheritanceTest.default_scopes = ['scope1'] 18 | Level1InheritanceTest.connection_hooks = ['hook1'] 19 | Level1InheritanceTest.namespace 'level1' 20 | Level1InheritanceTest.format :xml 21 | 22 | ApiClient::Base.default_scopes.should == [] 23 | ApiClient::Base.connection_hooks.should == [] 24 | ApiClient::Base.namespace.should == nil 25 | ApiClient::Base.format.should == :json 26 | 27 | class Level2InheritanceTest < Level1InheritanceTest 28 | namespace "level2" 29 | format :yaml 30 | end 31 | 32 | Level2InheritanceTest.default_scopes.should == ['scope1'] 33 | Level2InheritanceTest.connection_hooks.should == ['hook1'] 34 | Level2InheritanceTest.namespace.should == 'level2' 35 | Level2InheritanceTest.format.should == :yaml 36 | 37 | Level1InheritanceTest.namespace.should == 'level1' 38 | Level1InheritanceTest.format.should == :xml 39 | 40 | end 41 | 42 | end 43 | 44 | end -------------------------------------------------------------------------------- /lib/api_client/resource/base.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Resource 4 | 5 | class Base < ApiClient::Base 6 | 7 | class << self 8 | extend ApiClient::Mixins::Delegation 9 | extend ApiClient::Mixins::Configuration 10 | 11 | delegate :find_all, :find, :create, :update, :destroy, :path, :to => :scope 12 | 13 | dsl_accessor :prefix 14 | 15 | def inherited(subclass) 16 | super 17 | small_name = NameResolver.resolve(subclass.name) 18 | subclass.namespace small_name 19 | subclass.prefix self.prefix 20 | subclass.always do 21 | name = small_name 22 | pre_fix = prefix 23 | path ["", prefix, "#{name}s"].compact.join('/') 24 | end 25 | end 26 | 27 | def scope(options = {}) 28 | scope_in_thread || ApiClient::Resource::Scope.new(self).params(options) 29 | end 30 | 31 | end 32 | 33 | def persisted? 34 | !!self.id 35 | end 36 | 37 | def save 38 | self.persisted? ? remote_update : remote_create 39 | end 40 | 41 | def destroy 42 | get_scope.destroy(self.id) 43 | end 44 | 45 | def payload 46 | hash = self.to_hash 47 | hash.delete('id') # This key is never required 48 | hash 49 | end 50 | 51 | def remote_update 52 | get_scope.update(self.id, payload) 53 | end 54 | 55 | def remote_create 56 | get_scope.create(payload) 57 | end 58 | 59 | def get_scope 60 | original_scope || self.class 61 | end 62 | 63 | end 64 | 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /api_client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "api_client/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "api_client" 7 | s.version = ApiClient::VERSION 8 | s.authors = ["Zendesk"] 9 | s.email = ["opensource@zendesk.com"] 10 | s.homepage = "https://github.com/futuresimple/api_client" 11 | s.summary = %q{API client builder} 12 | s.description = %q{API client builder} 13 | s.license = "Apache License Version 2.0" 14 | 15 | s.rubyforge_project = "api_client" 16 | s.required_ruby_version = ">= 2.3.8" 17 | 18 | s.platform = "java" if RUBY_PLATFORM == "java" 19 | 20 | # Declare runtime dependencies here: 21 | def s.add_runtime_dependencies(method) 22 | if RUBY_PLATFORM == "java" 23 | send method, 'jrjackson' 24 | else 25 | send method, 'yajl-ruby' 26 | end 27 | 28 | send method, 'hashie', [">= 2.0.5"] 29 | send method, 'faraday', [">= 0.8.1", "< 2.0.0"] 30 | send method, 'multi_json', [">= 1.6.1"] 31 | end 32 | 33 | # Declare development dependencies here: 34 | s.add_development_dependency 'rspec', '< 4' 35 | 36 | if s.respond_to? :specification_version then 37 | s.specification_version = 3 38 | 39 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 40 | s.add_runtime_dependencies(:add_runtime_dependency) 41 | else 42 | s.add_runtime_dependencies(:add_dependency) 43 | end 44 | else 45 | s.add_runtime_dependencies(:add_dependency) 46 | end 47 | 48 | s.files = Dir['lib/**/*.rb'] + Dir['spec/**/*.rb'] + Dir['[A-Z]*'] 49 | s.test_files = Dir['spec/**/*.rb'] 50 | s.executables = [] 51 | s.require_paths = ["lib"] 52 | end 53 | -------------------------------------------------------------------------------- /spec/api_client/base/instantiation_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | describe "build" do 6 | 7 | it "instantiates an array of objects and returns an array if passed an array" do 8 | result = ApiClient::Base.build [{ :id => 1 }, { :id => 2}] 9 | result.should be_an_instance_of(Array) 10 | result.first.should be_an_instance_of(ApiClient::Base) 11 | result.last.should be_an_instance_of(ApiClient::Base) 12 | end 13 | 14 | it "instantiates an object and returns an object if passed an object" do 15 | result = ApiClient::Base.build({ :id => 1 }) 16 | result.should be_an_instance_of(ApiClient::Base) 17 | end 18 | 19 | end 20 | 21 | describe "build_one" do 22 | 23 | it "extracts the attributes from a namespace if a namespace is provided" do 24 | ApiClient::Base.stub(:namespace).and_return("base") 25 | result = ApiClient::Base.build({ "base" => { :id => 1 } }) 26 | result.should be_an_instance_of(ApiClient::Base) 27 | result.keys.should == ['id'] 28 | result.id.should == 1 29 | end 30 | 31 | end 32 | 33 | describe "sub hashes" do 34 | 35 | it "are Hashie::Mashes" do 36 | result = ApiClient::Base.build({ :id => 1, :subhash => { :foo => 1 } }) 37 | result.subhash.should be_a(Hashie::Mash) 38 | end 39 | 40 | end 41 | 42 | describe "original_scope" do 43 | 44 | it "holds the original scope it was created from" do 45 | scope = ApiClient::Base.params(:foo => 1).headers('token' => 'aaa').options("some" => "option") 46 | 47 | instance = scope.build :key => 'value' 48 | instance.original_scope.headers.should == {'token' => 'aaa'} 49 | instance.original_scope.params.should be_empty 50 | instance.original_scope.options.should be_empty 51 | end 52 | 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.6.0 2 | 3 | * Add support for faraday gem version >= 1.0.0 and < 2.0.0 4 | * Drop support for Ruby < 2.3 5 | 6 | # 0.5.26 7 | 8 | * Add support for HTTP status code: 412 Precondition Failed 9 | 10 | # 0.5.25 11 | 12 | * Fix broken gem build (gemspec files) 13 | 14 | # 0.5.23 15 | 16 | * Add support for HTTP status code: 423 Locked 17 | 18 | # 0.5.22 19 | 20 | * Add constraint on faraday < 1.0.0 as it breaks compatibility 21 | 22 | # 0.5.21 23 | 24 | * add support for http 410 status code 25 | 26 | # 0.5.20 27 | 28 | * brought back jruby support 29 | * updated CI pipeline 30 | 31 | # 0.5.19 32 | 33 | * introduced PATCH requests. 34 | * brought back ruby < 2.1 compatibility. Process::CLOCK_MONOTONIC is not present until ruby 2.2. There was a breaking change since 0.5.16. 35 | 36 | # 0.5.18 37 | 38 | * pass along the caller to each `connection` hook 39 | 40 | # 0.5.17 41 | * fix a bug where logger on debug level thrown an encoding error #19 42 | 43 | # 0.5.16 44 | 45 | * improve logging, log request details on Logger::DEBUG level 46 | 47 | # 0.5.15 48 | 49 | * add response status code to error message 50 | 51 | # 0.5.14 52 | 53 | * make ApiClient::Base marshallable by not storing proc as instance var 54 | 55 | # 0.5.13 56 | 57 | * add raw_body method to Scope which allows to set non-hash request payload 58 | 59 | # 0.5.12 60 | 61 | * fix threading problem on ruby 1.8.7 and earlier 62 | 63 | # 0.5.11 64 | 65 | * fix handling errors 66 | 67 | # 0.5.10 68 | 69 | * add TooManyRequests error 70 | 71 | # 0.5.9 72 | 73 | * fix compatibility with ruby 1.8.x 74 | 75 | # 0.5.8 76 | 77 | * fix the problem with strict_attr_reader and Array#flatten 78 | 79 | # 0.5.7 80 | 81 | * fix compatibility with hashie 2.1.0 and strict_attr_reader 82 | 83 | # 0.5.6 84 | 85 | * add strict_attr_reader option to raise `KeyError` on missing key access 86 | -------------------------------------------------------------------------------- /spec/api_client/base/scoping_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | describe '.always' do 6 | 7 | it "registers a new default_scope" do 8 | AlwaysTestProc = lambda {} 9 | class AlwaysTest < ApiClient::Base 10 | always &AlwaysTestProc 11 | end 12 | AlwaysTest.default_scopes.size.should == 1 13 | AlwaysTest.default_scopes.should == [AlwaysTestProc] 14 | end 15 | 16 | end 17 | 18 | describe '.scope' do 19 | 20 | it "returns a ApiClient::Scope instance" do 21 | ApiClient::Base.scope.should be_an_instance_of(ApiClient::Scope) 22 | end 23 | 24 | end 25 | 26 | describe '.scope_thread_attribute_name' do 27 | 28 | it "returns the key under which all .scoped calls should be stored" do 29 | ApiClient::Base.scope_thread_attribute_name.should == "ApiClient::Base_scope" 30 | end 31 | 32 | end 33 | 34 | describe '.scoped' do 35 | 36 | it "stores the scope in the thread context, attached to class name" do 37 | mock_scope3 = double 38 | ApiClient::Base.scoped(mock_scope3) do 39 | Thread.new { 40 | mock_scope2 = double 41 | ApiClient::Base.scoped(mock_scope2) do 42 | ApiClient::Base.scope.should == mock_scope2 43 | Thread.current[ApiClient::Base.scope_thread_attribute_name].should == [mock_scope2] 44 | end 45 | } 46 | ApiClient::Base.scope.should == mock_scope3 47 | Thread.current[ApiClient::Base.scope_thread_attribute_name].should == [mock_scope3] 48 | end 49 | Thread.new { 50 | mock_scope = double 51 | ApiClient::Base.scoped(mock_scope) do 52 | ApiClient::Base.scope.should == mock_scope 53 | Thread.current[ApiClient::Base.scope_thread_attribute_name].should == [mock_scope] 54 | end 55 | } 56 | end 57 | 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/api_client/resource/scope.rb: -------------------------------------------------------------------------------- 1 | # This class includes methods for calling restful APIs 2 | module ApiClient 3 | 4 | module Resource 5 | 6 | class Scope < ApiClient::Scope 7 | 8 | dsl_accessor :path, :return_self => true 9 | 10 | def format 11 | @scopeable.format 12 | end 13 | 14 | def append_format(path) 15 | format ? [path, format].join('.') : path 16 | end 17 | 18 | def find(id) 19 | path = [@path, id].join('/') 20 | path = append_format(path) 21 | response = get(path) 22 | scoped(self) do 23 | raw? ? response : @scopeable.build(response) 24 | end 25 | end 26 | 27 | def find_all(params = {}) 28 | path = append_format(@path) 29 | response = get(path, params) 30 | scoped(self) do 31 | raw? ? response : @scopeable.build(response) 32 | end 33 | end 34 | 35 | def create(params = {}) 36 | path = append_format(@path) 37 | hash = if @scopeable.namespace 38 | { @scopeable.namespace => params } 39 | else 40 | params 41 | end 42 | response = post(path, hash) 43 | scoped(self) do 44 | raw? ? response : @scopeable.build(response) 45 | end 46 | end 47 | 48 | def update(id, params = {}) 49 | path = [@path, id].join('/') 50 | path = append_format(path) 51 | hash = if @scopeable.namespace 52 | { @scopeable.namespace => params } 53 | else 54 | params 55 | end 56 | response = put(path, hash) 57 | scoped(self) do 58 | raw? ? response : @scopeable.build(response) 59 | end 60 | end 61 | 62 | def destroy(id) 63 | path = [@path, id].join('/') 64 | path = append_format(path) 65 | delete(path) 66 | true 67 | end 68 | 69 | end 70 | 71 | end 72 | 73 | end 74 | -------------------------------------------------------------------------------- /lib/api_client/base.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | class Base < Hashie::Mash 4 | 5 | extend ApiClient::Mixins::Inheritance 6 | extend ApiClient::Mixins::Instantiation 7 | extend ApiClient::Mixins::Scoping 8 | extend ApiClient::Mixins::ConnectionHooks 9 | 10 | class << self 11 | extend ApiClient::Mixins::Delegation 12 | extend ApiClient::Mixins::Configuration 13 | 14 | delegate :fetch, :get, :put, :post, :patch, :delete, :headers, :endpoint, :options, :adapter, :params, :raw, :to => :scope 15 | 16 | dsl_accessor :format, :namespace 17 | 18 | def subkey_class 19 | Hashie::Mash 20 | end 21 | 22 | def parse(response) 23 | if response.is_a?(Faraday::Response) 24 | return nil if response.status == 204 25 | response = response.body 26 | end 27 | 28 | if self.format == :json 29 | MultiJson.load(response) 30 | elsif self.format == :xml 31 | MultiXml.parse(response) 32 | else 33 | response 34 | end 35 | end 36 | 37 | end 38 | 39 | # Defaults 40 | self.format :json 41 | 42 | def id 43 | self['id'] 44 | end 45 | 46 | def inspect 47 | attributes = [] 48 | attr_keys = self.keys - ['id'] 49 | attributes.push "id: #{self.id}" if self.id 50 | attr_keys.each do |key| 51 | attributes.push("#{key}: #{self[key].inspect}") 52 | end 53 | "#<#{self.class} #{attributes.join(', ')}>" 54 | end 55 | 56 | private 57 | def method_missing(method_name, *args, &blk) 58 | if respond_to?(method_name) || has_special_ending?(method_name) 59 | super 60 | elsif use_strict_reader?(method_name) 61 | fetch(method_name) 62 | else 63 | super 64 | end 65 | end 66 | 67 | def use_strict_reader?(method_name) 68 | respond_to?(:strict_attr_reader?) && 69 | self.strict_attr_reader? && 70 | method_name != :to_ary 71 | end 72 | 73 | def has_special_ending?(name) 74 | name.to_s =~ /[?=]$/ 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/api_client/connection/middlewares/request/logger.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | 3 | class ApiClient::Connection::Middlewares::Request::Logger < Faraday::Middleware 4 | def call(env) 5 | debug_lines = [] 6 | should_log_details = @logger.level <= ::Logger::DEBUG 7 | 8 | gather_request_debug_lines(env, debug_lines) if should_log_details 9 | 10 | start = CurrentTimestamp.milis 11 | response = @app.call(env) 12 | taken_sec = (CurrentTimestamp.milis - start) / 1000.0 13 | 14 | gather_response_debug_lines(response, taken_sec, debug_lines) if response && should_log_details 15 | 16 | if should_log_details 17 | debug_lines.each { |line| line.encode!("UTF-8", invalid: :replace, undef: :replace) } 18 | @logger.debug { debug_lines.join("\n") } 19 | else 20 | @logger.info { "#{env[:method].to_s.upcase} #{env[:url]}: #{"%.4f" % taken_sec} seconds" } 21 | end 22 | 23 | response 24 | end 25 | 26 | def initialize(app, logger = nil) 27 | @logger = logger || ::Logger.new(STDOUT) 28 | @app = app 29 | end 30 | 31 | private 32 | 33 | def gather_request_debug_lines(env, debug_lines) 34 | debug_lines << "> #{env[:method].to_s.upcase} #{env[:url]}" 35 | env[:request_headers].each { |k, v| debug_lines << "> #{k}: #{v}" } 36 | debug_lines << "> " 37 | debug_lines << "> #{env[:body]}\n> " if env[:body] && env[:body] != "" 38 | debug_lines 39 | end 40 | 41 | def gather_response_debug_lines(response, taken_sec, debug_lines) 42 | debug_lines << "< responded in #{"%.4f" % taken_sec} seconds with HTTP #{response.status}" 43 | response.headers.each { |k, v| debug_lines << "< #{k}: #{v}" } 44 | debug_lines << "< " 45 | debug_lines << "< #{response.body}\n> " if response.body && response.body != "" 46 | debug_lines 47 | end 48 | 49 | class CurrentTimestamp 50 | class << self 51 | version = RUBY_VERSION.split('.').map(&:to_i) 52 | 53 | if (version[0] < 2) || (version[0] == 2 && version[1] < 2) 54 | def milis 55 | (Time.now.to_f * 1000).to_i 56 | end 57 | else 58 | def milis 59 | Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/api_client/connection/abstract.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Connection 4 | 5 | class Abstract 6 | 7 | attr_accessor :endpoint, :handler, :options 8 | 9 | def initialize(endpoint, options = {}) 10 | raise "Cannot instantiate abstract class" if self.class == ApiClient::Connection::Abstract 11 | @endpoint = endpoint 12 | @options = options 13 | create_handler 14 | end 15 | 16 | def create_handler 17 | end 18 | 19 | #### ApiClient::Connection::Abstract#get 20 | # Performs a GET request 21 | # Accepts three parameters: 22 | # 23 | # * path - the path the request should go to 24 | # * data - (optional) the query, passed as a hash and converted into query params 25 | # * headers - (optional) headers sent along with the request 26 | # 27 | def get(path, data = {}, headers = {}) 28 | end 29 | 30 | #### ApiClient::Connection::Abstract#post 31 | # Performs a POST request 32 | # Accepts three parameters: 33 | # 34 | # * path - the path request should go to 35 | # * data - (optional) data sent in the request 36 | # * headers - (optional) headers sent along in the request 37 | # 38 | def post(path, data = {}, headers = {}) 39 | end 40 | 41 | #### ApiClient::Connection::Abstract#patch 42 | # Performs a PATCH request 43 | # Accepts three parameters: 44 | # 45 | # * path - the path request should go to 46 | # * data - (optional) data sent in the request 47 | # * headers - (optional) headers sent along in the request 48 | # 49 | def patch(path, data = {}, headers = {}) 50 | end 51 | 52 | #### ApiClient::Connection::Abstract#put 53 | # Performs a PUT request 54 | # Accepts three parameters: 55 | # 56 | # * path - the path request should go to 57 | # * data - (optional) data sent in the request 58 | # * headers - (optional) headers sent along in the request 59 | # 60 | def put(path, data = {}, headers = {}) 61 | end 62 | 63 | #### FS::Connection#delete 64 | # Performs a DELETE request 65 | # Accepts three parameters: 66 | # 67 | # * path - the path request should go to 68 | # * data - (optional) the query, passed as a hash and converted into query params 69 | # * headers - (optional) headers sent along in the request 70 | # 71 | def delete(path, data = {}, headers = {}) 72 | end 73 | 74 | def inspect 75 | "#<#{self.class} endpoint: \"#{endpoint}\">" 76 | end 77 | alias :to_s :inspect 78 | 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/api_client/resource/base_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Resource::Base do 4 | 5 | describe '.scope' do 6 | 7 | it "is an instance of ApiClient::Resource::Scope" do 8 | ApiClient::Resource::Base.scope.should be_an_instance_of(ApiClient::Resource::Scope) 9 | end 10 | 11 | end 12 | 13 | 14 | describe "persistence" do 15 | 16 | before do 17 | @instance = ApiClient::Resource::Base.new 18 | @instance.id = 42 19 | @instance.name = "Mike" 20 | end 21 | 22 | describe '#persisted?' do 23 | 24 | it "returns true if id is present, false otherwise" do 25 | @instance.id = 42 26 | @instance.persisted?.should == true 27 | @instance.id = nil 28 | @instance.persisted?.should == false 29 | end 30 | 31 | end 32 | 33 | describe '#save' do 34 | 35 | it "creates a record if not persisted" do 36 | @instance.id = nil 37 | @instance.should_receive(:remote_create) 38 | @instance.save 39 | end 40 | 41 | it "updates a record if not persisted" do 42 | @instance.id = 42 43 | @instance.should_receive(:remote_update) 44 | @instance.save 45 | end 46 | 47 | end 48 | 49 | describe "#destroy" do 50 | 51 | it "delegates the destroy to the class" do 52 | ApiClient::Resource::Base.should_receive(:destroy).with(42) 53 | @instance.destroy 54 | end 55 | 56 | it "retains the original scope" do 57 | @instance.original_scope = double 58 | @instance.original_scope.should_receive(:destroy).with(42) 59 | @instance.destroy 60 | end 61 | 62 | end 63 | 64 | describe "#remote_update" do 65 | 66 | it "delegates the update to the class" do 67 | ApiClient::Resource::Base.should_receive(:update).with(42, { "name" => "Mike" }) 68 | @instance.remote_update 69 | end 70 | 71 | it "retains the original scope" do 72 | ApiClient::Resource::Base.stub(:update) 73 | @instance.original_scope = double 74 | @instance.original_scope.should_receive(:update).with(42, { "name" => "Mike" }) 75 | @instance.remote_update 76 | end 77 | 78 | end 79 | 80 | describe "#remote_create" do 81 | 82 | it "delegates the create to the class" do 83 | ApiClient::Resource::Base.should_receive(:create).with({ "name" => "Mike" }) 84 | @instance.remote_create 85 | end 86 | 87 | it "retains the original scope" do 88 | @instance.original_scope = double 89 | @instance.original_scope.should_receive(:create).with({ "name" => "Mike" }) 90 | @instance.remote_create 91 | end 92 | 93 | end 94 | 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /spec/api_client/base_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Base do 4 | 5 | it "is a subclass of Hashie::Mash" do 6 | ApiClient::Base.should inherit_from(Hashie::Mash) 7 | end 8 | 9 | it "responds to #id" do 10 | subject.should respond_to("id") 11 | end 12 | 13 | class StrictApi < ApiClient::Base 14 | def accessor_of_x 15 | self.x 16 | end 17 | 18 | def strict_attr_reader? 19 | true 20 | end 21 | end 22 | 23 | class StrictDescendant < StrictApi 24 | end 25 | 26 | context "when used in an array" do 27 | 28 | subject { [StrictApi.new] } 29 | 30 | it "does not break Array#flatten" do 31 | lambda { subject.flatten }.should_not raise_error 32 | end 33 | 34 | end 35 | 36 | describe "#inspect" do 37 | 38 | it "has a nice inspect" do 39 | subject.update(:id => 1).inspect.should == '#' 40 | end 41 | 42 | it "presents all fields in inspect" do 43 | subject.update(:id => 1, :foo => 'OMG') 44 | subject.inspect.should == '#' 45 | end 46 | 47 | it "inspects subobjects properly" do 48 | subject.update(:id => 1, :sub => [1,2]) 49 | subject.inspect.should == '#>' 50 | end 51 | 52 | it "makes sure id is the first key" do 53 | 54 | subject.update(:foo => 'OMG', :id => 1) 55 | subject.inspect.should == '#' 56 | end 57 | 58 | end 59 | 60 | describe "strict_read" do 61 | it "fails if the key is missing and strict_read is set" do 62 | api = StrictApi.new 63 | lambda { api.missing }.should raise_error do |error| 64 | error_type = error.class.to_s 65 | error_type.should match(/KeyError|IndexError/) 66 | end 67 | end 68 | 69 | it "doesn't fail if strict_read is not set" do 70 | api = ApiClient::Base.new 71 | api.missing 72 | end 73 | 74 | it "doesn't fail if the key was set after object was created" do 75 | api = StrictApi.new 76 | lambda { api.not_missing = 1 }.should_not raise_error 77 | api.not_missing.should == 1 78 | end 79 | 80 | it "doesn't fail for predicate methods if key is not set" do 81 | api = StrictApi.new 82 | lambda { api.missing? }.should_not raise_error 83 | expect(api.missing?).to be false 84 | end 85 | 86 | it "allows to call methods" do 87 | api = StrictApi.new(:x => 14) 88 | api.accessor_of_x.should == 14 89 | end 90 | 91 | it "calling method which asks for missing attribute fails" do 92 | api = StrictApi.new 93 | lambda { api.accessor_of_x }.should raise_error do |error| 94 | error_type = error.class.to_s 95 | error_type.should match(/KeyError|IndexError/) 96 | end 97 | end 98 | 99 | it "passes strict api configuration to subclasses" do 100 | api = StrictDescendant.new 101 | lambda { api.missing }.should raise_error do |error| 102 | error_type = error.class.to_s 103 | error_type.should match(/KeyError|IndexError/) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/api_client/scope.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | class Scope 4 | extend ApiClient::Mixins::Configuration 5 | extend ApiClient::Mixins::Delegation 6 | 7 | delegate :prefix, :scoped, :to => :scopeable 8 | 9 | dsl_accessor :endpoint, :adapter, :return_self => true 10 | 11 | attr_reader :scopeable 12 | 13 | def initialize(scopeable) 14 | @scopeable = scopeable 15 | @params = {} 16 | @headers = {} 17 | @options = {} 18 | @scopeable.default_scopes.each do |default_scope| 19 | self.instance_eval(&default_scope) 20 | end 21 | end 22 | 23 | def connection 24 | klass = Connection.const_get((@adapter || Connection.default).to_s.capitalize) 25 | @connection = klass.new(@endpoint , @options || {}) 26 | hooks = @scopeable.connection_hooks || [] 27 | hooks.each { |hook| hook.call(@connection, self) } 28 | @connection 29 | end 30 | 31 | def raw 32 | @raw = true 33 | self 34 | end 35 | 36 | def raw? 37 | !!@raw 38 | end 39 | 40 | # 3 Pillars of scoping 41 | # options - passed on the the adapter 42 | # params - converted to query or request body 43 | # headers - passed on to the request 44 | def options(new_options = nil) 45 | return @options if new_options.nil? 46 | ApiClient::Utils.deep_merge(@options, new_options) 47 | self 48 | end 49 | 50 | def params(options = nil) 51 | return @params if options.nil? 52 | ApiClient::Utils.deep_merge(@params, options) if options 53 | self 54 | end 55 | alias :scope :params 56 | 57 | def headers(options = nil) 58 | return @headers if options.nil? 59 | ApiClient::Utils.deep_merge(@headers, options) if options 60 | self 61 | end 62 | 63 | def raw_body(options = nil) 64 | return @raw_body if options.nil? 65 | @raw_body = options 66 | self 67 | end 68 | 69 | def clone_only_headers 70 | self.class.new(self.scopeable).headers(self.headers) 71 | end 72 | 73 | # Half-level :) 74 | # This is a swiss-army knife kind of method, extremely useful 75 | def fetch(path, options = {}) 76 | scoped(self) do 77 | @scopeable.build get(path, options) 78 | end 79 | end 80 | 81 | # Low-level connection methods 82 | 83 | def request(method, path, options = {}) 84 | options = options.dup 85 | 86 | raw = raw? || options.delete(:raw) 87 | params(options) 88 | response = connection.send method, path, (@raw_body || @params), @headers 89 | raw ? response : @scopeable.parse(response) 90 | end 91 | 92 | def get(path, options = {}) 93 | request(:get, path, options) 94 | end 95 | 96 | def post(path, options = {}) 97 | request(:post, path, options) 98 | end 99 | 100 | def patch(path, options = {}) 101 | request(:patch, path, options) 102 | end 103 | 104 | def put(path, options = {}) 105 | request(:put, path, options) 106 | end 107 | 108 | def delete(path, options = {}) 109 | request(:delete, path, options) 110 | end 111 | 112 | # Dynamic delegation of scopeable methods 113 | def method_missing(method, *args, &block) 114 | if @scopeable.respond_to?(method) 115 | @scopeable.scoped(self) do 116 | @scopeable.send(method, *args, &block) 117 | end 118 | else 119 | super 120 | end 121 | end 122 | 123 | end 124 | 125 | end 126 | -------------------------------------------------------------------------------- /spec/api_client/resource/scope_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Resource::Scope do 4 | 5 | describe "restful requests" do 6 | 7 | class Restful < ApiClient::Resource::Base 8 | end 9 | 10 | class Restful2 < ApiClient::Resource::Base 11 | namespace false 12 | end 13 | 14 | class Restful3 < ApiClient::Resource::Base 15 | prefix "v1" 16 | end 17 | 18 | before do 19 | @instance = ApiClient::Resource::Scope.new(Restful) 20 | end 21 | 22 | it "performs a find to fetch one record" do 23 | response = { "restful" => { "id" => 42 }} 24 | @instance.should_receive(:get).with('/restfuls/1.json').and_return(response) 25 | result = @instance.find(1) 26 | result.should be_an_instance_of(Restful) 27 | result.id.should == 42 28 | end 29 | 30 | it "performs a find to fetch one record in raw mode" do 31 | response = { "restful" => { "id" => 42 }} 32 | @instance.should_receive(:get).with('/restfuls/1.json').and_return(response) 33 | result = @instance.raw.find(1) 34 | result.should == response 35 | end 36 | 37 | it "performs a find to fetch one record with a prefix if provided" do 38 | @instance = ApiClient::Resource::Scope.new(Restful3) 39 | response = { "restful3" => { "id" => 42 }} 40 | @instance.should_receive(:get).with('/v1/restful3s/1.json').and_return(response) 41 | result = @instance.find(1) 42 | result.should be_an_instance_of(Restful3) 43 | result.id.should == 42 44 | end 45 | 46 | it "performs a find_all to fetch many records" do 47 | response = [{ "restful" => { "id" => 42 } }, { "restful" => { "id" => 112 } }] 48 | @instance.should_receive(:get).with('/restfuls.json', {}).and_return(response) 49 | result = @instance.find_all 50 | 51 | result.should be_an_instance_of(Array) 52 | result.first.should be_an_instance_of(Restful) 53 | result.first.id.should == 42 54 | result.last.should be_an_instance_of(Restful) 55 | result.last.id.should == 112 56 | end 57 | 58 | it "performs a find_all to fetch many records in raw mode" do 59 | response = [{ "restful" => { "id" => 42 } }, { "restful" => { "id" => 112 } }] 60 | @instance.should_receive(:get).with('/restfuls.json', {}).and_return(response) 61 | result = @instance.raw.find_all 62 | result.should == response 63 | end 64 | 65 | it "performs a create to create a new record" do 66 | response = { "restful" => { "id" => 42, "name" => "Foo" }} 67 | @instance.should_receive(:post).with('/restfuls.json', {"restful" => {:name => "Foo"} }).and_return(response) 68 | result = @instance.create(:name => "Foo") 69 | result.should be_an_instance_of(Restful) 70 | result.id.should == 42 71 | end 72 | 73 | it "performs a create to create a new record in raw mode" do 74 | response = { "restful" => { "id" => 42, "name" => "Foo" }} 75 | @instance.should_receive(:post).with('/restfuls.json', {"restful" => {:name => "Foo"} }).and_return(response) 76 | result = @instance.raw.create(:name => "Foo") 77 | result.should == response 78 | end 79 | 80 | it "performs a create to create a new record skipping the namespace if it is not present" do 81 | @instance = ApiClient::Resource::Scope.new(Restful2) 82 | response = { "id" => 42, "name" => "Foo" } 83 | @instance.should_receive(:post).with('/restful2s.json', {:name => "Foo"} ).and_return(response) 84 | result = @instance.create(:name => "Foo") 85 | result.should be_an_instance_of(Restful2) 86 | result.id.should == 42 87 | end 88 | 89 | it "performs a update to update an existing record" do 90 | response = { "restful" => { "id" => 42, "name" => "Foo" }} 91 | @instance.should_receive(:put).with('/restfuls/42.json', {"restful" => {:name => "Foo"} }).and_return(response) 92 | result = @instance.update(42, :name => "Foo") 93 | result.should be_an_instance_of(Restful) 94 | result.id.should == 42 95 | end 96 | 97 | it "performs a update to update an existing record in raw mode" do 98 | response = { "restful" => { "id" => 42, "name" => "Foo" }} 99 | @instance.should_receive(:put).with('/restfuls/42.json', {"restful" => {:name => "Foo"} }).and_return(response) 100 | result = @instance.raw.update(42, :name => "Foo") 101 | result.should == response 102 | end 103 | 104 | it "performs a update to update an existing record skipping the namespace if it is not present" do 105 | @instance = ApiClient::Resource::Scope.new(Restful2) 106 | response = { "id" => 42, "name" => "Foo" } 107 | @instance.should_receive(:put).with('/restful2s/42.json', {:name => "Foo"} ).and_return(response) 108 | result = @instance.update(42, :name => "Foo") 109 | result.should be_an_instance_of(Restful2) 110 | result.id.should == 42 111 | end 112 | 113 | it "performs a destroy to remove a record" do 114 | response = { "restful" => { "id" => 42, "name" => "Foo" }} 115 | @instance.should_receive(:delete).with('/restfuls/42.json').and_return(response) 116 | result = @instance.destroy(42) 117 | result.should == true 118 | end 119 | 120 | end 121 | 122 | end 123 | -------------------------------------------------------------------------------- /lib/api_client/connection/basic.rb: -------------------------------------------------------------------------------- 1 | module ApiClient 2 | 3 | module Connection 4 | 5 | class Basic < Abstract 6 | 7 | def create_handler 8 | # Create and memoize the connection object 9 | # The empty block is necessary as we don't want Faraday to 10 | # initialize itself, we build our own stack in finalize_handler 11 | @handler = Faraday.new(@endpoint, @options[:faraday] || {}) do end 12 | finalize_handler 13 | end 14 | 15 | def finalize_handler 16 | @handler.use Middlewares::Request::Logger, ApiClient.logger if ApiClient.logger 17 | @handler.use Faraday::Request::UrlEncoded 18 | @handler.adapter Faraday.default_adapter 19 | end 20 | 21 | #### ApiClient::Connection::Abstract#get 22 | # Performs a GET request 23 | # Accepts three parameters: 24 | # 25 | # * path - the path the request should go to 26 | # * data - (optional) the query, passed as a hash and converted into query params 27 | # * headers - (optional) headers sent along with the request 28 | # 29 | def get(path, data = {}, headers = {}) 30 | exec_request(:get, path, data, headers) 31 | end 32 | 33 | #### ApiClient::Connection::Abstract#post 34 | # Performs a POST request 35 | # Accepts three parameters: 36 | # 37 | # * path - the path request should go to 38 | # * data - (optional) data sent in the request 39 | # * headers - (optional) headers sent along in the request 40 | # 41 | # This method automatically adds the application token header 42 | def post(path, data = {}, headers = {}) 43 | exec_request(:post, path, data, headers) 44 | end 45 | 46 | #### ApiClient::Connection::Abstract#patch 47 | # Performs a PATCH request 48 | # Accepts three parameters: 49 | # 50 | # * path - the path request should go to 51 | # * data - (optional) data sent in the request 52 | # * headers - (optional) headers sent along in the request 53 | # 54 | # This method automatically adds the application token header 55 | def patch(path, data = {}, headers = {}) 56 | exec_request(:patch, path, data, headers) 57 | end 58 | 59 | #### ApiClient::Connection::Abstract#put 60 | # Performs a PUT request 61 | # Accepts three parameters: 62 | # 63 | # * path - the path request should go to 64 | # * data - (optional) data sent in the request 65 | # * headers - (optional) headers sent along in the request 66 | # 67 | # This method automatically adds the application token header 68 | def put(path, data = {}, headers = {}) 69 | exec_request(:put, path, data, headers) 70 | end 71 | 72 | #### FS::Connection#delete 73 | # Performs a DELETE request 74 | # Accepts three parameters: 75 | # 76 | # * path - the path request should go to 77 | # * data - (optional) the query, passed as a hash and converted into query params 78 | # * headers - (optional) headers sent along in the request 79 | # 80 | # This method automatically adds the application token header 81 | def delete(path, data = {}, headers = {}) 82 | exec_request(:delete, path, data, headers) 83 | end 84 | 85 | private 86 | 87 | def exec_request(method, path, data, headers) 88 | response = @handler.send(method, path, data, headers) 89 | request = { :method => method, :path => path, :data => data} 90 | handle_response(request, response) 91 | rescue Faraday::ConnectionFailed => e 92 | raise ApiClient::Errors::ConnectionFailed.new(e.message, request, response) 93 | end 94 | 95 | def handle_response(request, response) 96 | raise ApiClient::Errors::ConnectionFailed.new(nil, request, response) unless response 97 | case response.status 98 | when 401 99 | raise ApiClient::Errors::Unauthorized.new(nil, request, response) 100 | when 403 101 | raise ApiClient::Errors::Forbidden.new(nil, request, response) 102 | when 404 103 | raise ApiClient::Errors::NotFound.new(nil, request, response) 104 | when 400 105 | raise ApiClient::Errors::BadRequest.new(nil, request, response) 106 | when 406 107 | raise ApiClient::Errors::Unsupported.new(nil, request, response) 108 | when 409 109 | raise ApiClient::Errors::Conflict.new(nil, request, response) 110 | when 410 111 | raise ApiClient::Errors::Gone.new(nil, request, response) 112 | when 412 113 | raise ApiClient::Errors::PreconditionFailed.new(nil, request, response) 114 | when 422 115 | raise ApiClient::Errors::UnprocessableEntity.new(response.body, request, response) 116 | when 423 117 | raise ApiClient::Errors::Locked.new(response.body, request, response) 118 | when 429 119 | raise ApiClient::Errors::TooManyRequests.new(response.body, request, response) 120 | when 300..399 121 | raise ApiClient::Errors::Redirect.new(response['Location'], request, response) 122 | when 500..599 123 | raise ApiClient::Errors::ServerError.new(nil, request, response) 124 | else 125 | response 126 | end 127 | end 128 | 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ApiClient ![Tests](https://github.com/zendesk/api_client/workflows/Tests/badge.svg) 2 | ========= 3 | 4 | ApiClient is an experimental builder for HTTP API clients. The goal is to provide a easy to use engine for constructing queries and map the responses to instances of Hashie::Mash subclasses. 5 | 6 | Basically you should be able to build a client for any HTTP based API, without the need for handling connections, parsing responses or instantiating objects. All you need to do is choose where the request should go and enhance your client classes. See the examples dir for usage examples. 7 | 8 | Current state is alpha - it works, but the query interface is not final and is subject to change at any time. Hell, even the can even change without prior notice. You were warned. 9 | 10 | ## How API Client works 11 | 12 | ApiClient gives you two classes that you can inherit from: 13 | 14 | * ApiClient::Base 15 | 16 | This class gives you the most basic access. It is a Hashie::Mash 17 | subclass. On the class level, it exposes a variety of request 18 | methods (like get, post, put, delete) 19 | 20 | * ApiClient::Resource::Base 21 | 22 | This class extends ApiClient::Base giving it ActiveRecord-like class methods 23 | (find, find_all, create, update, destroy) as well as instance methods 24 | (save, destroy). 25 | 26 | ## Making requests 27 | 28 | By design, all request methods are singleton methods. They either return Ruby 29 | objects or objects of the class they are called on. 30 | 31 | ### `ApiClient::Base.get(path, params = {})` 32 | 33 | Make a GET request to a specified path. You can pass params as the second 34 | argument. It will parse the representation and return a Ruby object. 35 | 36 | #### Example 37 | 38 | ```ruby 39 | ApiClient::Base.get('/apples/1.json') 40 | # => returns a parsed Hash 41 | ``` 42 | 43 | ### `ApiClient::Base.post(path, params = {})` 44 | 45 | Make a POST request to a specified path. You can pass params as the second 46 | argument. It will parse the representation and return a Ruby object. 47 | 48 | #### Example 49 | 50 | ```ruby 51 | ApiClient::Base.post('/apples/1.json', :name => 'Lobo') 52 | # => returns a parsed Hash 53 | ``` 54 | 55 | ### `ApiClient::Base.put(path, params = {})` 56 | 57 | Make a PUT request to a specified path. You can pass params as the second 58 | argument. It will parse the representation and return a Ruby object. 59 | 60 | #### Example 61 | 62 | ```ruby 63 | ApiClient::Base.put('/apples/1.json', :name => 'Lobo') 64 | # => returns a parsed Hash 65 | ``` 66 | 67 | ### `ApiClient::Base.delete(path, params = {})` 68 | 69 | Make a DELETE request to a specified path. You can pass params as the second 70 | argument. It will parse the representation and return a Ruby object. 71 | 72 | #### Example 73 | 74 | ```ruby 75 | ApiClient::Base.delete('/apples/1.json') 76 | # => returns a parsed Hash 77 | ``` 78 | 79 | ### `ApiClient::Base.fetch(path, params = {})` 80 | 81 | Make a GET request to a specified path. You can pass params as the second 82 | argument. It will parse the representation, pass it to the build method and 83 | return and object of the class it was called on. 84 | 85 | #### Example 86 | 87 | ```ruby 88 | ApiClient::Base.fetch('/apples/1.json') 89 | # => returns a ApiClient::Base object 90 | 91 | class Apple < ApiClient::Base 92 | end 93 | 94 | Apple.fetch('/apples/1.json') 95 | # => returns an Apple object 96 | ``` 97 | 98 | ## Scoping and Chaining 99 | 100 | ApiClient allows you to apply scoping to your request using 3 methods: 101 | 102 | * ApiClient::Base.params(pars = {}) 103 | 104 | This method allows you to add parameters to a request. If the request is a 105 | GET request, it will be added in the query. Otherwise, it will be sent in 106 | the request body. 107 | 108 | It returns a ApiClient::Scope object attached to a class you started with. 109 | 110 | * ApiClient::Base.options(opts = {}) 111 | 112 | Allows passing options to the connection object behind the ApiClient. Useful 113 | when working with OAuth for passing the token. 114 | 115 | It returns a ApiClient::Scope object attached to a class you started with. 116 | 117 | * ApiClient::Base.headers(heads = {}) 118 | 119 | Allows setting headers in the request. Useful when you need to add a token 120 | as the header 121 | 122 | It returns a ApiClient::Scope object attached to a class you started with. 123 | 124 | * ApiClient::Base.raw_body(body = nil) 125 | 126 | Allows setting non-hash body in the request. Useful for binary payloads. 127 | Notice: it overrides all parameters set via params method! 128 | 129 | It returns a ApiClient::Scope object attached to a class you started with. 130 | 131 | All of these methods return a ApiClient::Scope object. When you call any request 132 | methods on this object (get, post, put, delete), the request will apply the 133 | options, params and headers. 134 | 135 | ### Examples 136 | 137 | #### Params, headers and a GET request 138 | 139 | ```ruby 140 | ApiClient::Base. 141 | params({ :page => 1 }). 142 | headers('Auth-Token', 'mytoken'). 143 | get('/stuff.json') # => returns a parsed Array object 144 | ``` 145 | 146 | ## Logging 147 | 148 | To log requests set the `ApiClient.logger`. To log request payloads and headers set level to `Logger::DEBUG` 149 | 150 | ```ruby 151 | require 'logger' 152 | ApiClient.logger = Logger.new('api_client.log') 153 | ApiClient.logger.level = Logger::INFO 154 | 155 | ``` 156 | 157 | ## Releasing new version of gem 158 | 159 | 1. Update version in [lib/api_client/version.rb](lib/api_client/version.rb) and push to `master` 160 | 2. Create new GitHub release with tag name starting with `v` and the version, for example `v1.0.0` 161 | 3. Gem will be automatically built and pushed to rubygems.org with GitHub Action 162 | 163 | ## Copyright and license 164 | 165 | Copyright 2011 Zendesk 166 | 167 | Licensed under the [Apache License, Version 2.0](LICENSE) 168 | -------------------------------------------------------------------------------- /spec/api_client/scope_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Scope do 4 | 5 | describe 'default_scopes' do 6 | 7 | it "runs the default scopes defined in the scopeable" do 8 | class DefaultScopeTest < ApiClient::Base 9 | always do 10 | params :foo => 1 11 | end 12 | end 13 | instance = ApiClient::Scope.new(DefaultScopeTest) 14 | instance.params.should == { :foo => 1 } 15 | end 16 | end 17 | 18 | describe "#params" do 19 | 20 | it "reads/writes the params and chains nicely" do 21 | instance = ApiClient::Scope.new(ApiClient::Base) 22 | instance.params(:foo => 1).params(:moo => 10).should == instance 23 | instance.params.should == { :foo => 1, :moo => 10 } 24 | end 25 | 26 | end 27 | 28 | describe "#headers" do 29 | 30 | it "reads/writes the headers and chains nicely" do 31 | instance = ApiClient::Scope.new(ApiClient::Base) 32 | instance.headers(:foo => 1).headers(:moo => 10).should == instance 33 | instance.headers.should == { :foo => 1, :moo => 10 } 34 | end 35 | 36 | end 37 | 38 | describe "#options" do 39 | 40 | it "reads/writes the headers and chains nicely" do 41 | instance = ApiClient::Scope.new(ApiClient::Base) 42 | instance.options(:foo => 1).options(:moo => 10).should == instance 43 | instance.options.should == { :foo => 1, :moo => 10 } 44 | end 45 | 46 | end 47 | 48 | describe "#raw_body" do 49 | 50 | it "reads/writes non-hash body" do 51 | instance = ApiClient::Scope.new(ApiClient::Base) 52 | instance.raw_body('raw body string').should == instance 53 | instance.raw_body.should == 'raw body string' 54 | end 55 | 56 | it "overwrites previous raw_body" do 57 | instance = ApiClient::Scope.new(ApiClient::Base) 58 | instance.raw_body('previous').raw_body('current') 59 | instance.raw_body.should == 'current' 60 | end 61 | 62 | it "does request with raw body only if set, skips other params" do 63 | connection = double 64 | instance = ApiClient::Scope.new(ApiClient::Base) 65 | instance.stub(:connection).and_return(connection) 66 | response = Faraday::Response.new(:body => '{"a": "1"}') 67 | connection.should_receive(:get).with(@path, 'raw body string', {}).and_return(response) 68 | 69 | result = instance.params({:skipped => 'params'}).raw_body('raw body string').request(:get, @path) 70 | result.should == {"a"=> "1"} 71 | end 72 | 73 | end 74 | 75 | describe "connection" do 76 | 77 | it "retuns the connection based on the adapter" do 78 | instance = ApiClient::Scope.new(ApiClient::Base) 79 | instance.connection.should be_an_instance_of ApiClient::Connection::Basic 80 | end 81 | 82 | it "raises an error if adapter was not found" do 83 | instance = ApiClient::Scope.new(ApiClient::Base) 84 | lambda { 85 | instance.adapter("foo").connection 86 | }.should raise_error 87 | end 88 | 89 | it "executes connection hooks" do 90 | AConnectionHook = double 91 | class ScopeConnectionHooksTest < ApiClient::Base 92 | end 93 | ScopeConnectionHooksTest.connection_hooks = [AConnectionHook] 94 | instance = ApiClient::Scope.new(ScopeConnectionHooksTest) 95 | AConnectionHook.should_receive(:call) 96 | instance.connection 97 | end 98 | 99 | end 100 | 101 | describe "requests" do 102 | 103 | before do 104 | @path = "somepath" 105 | @params = { :foo => 1 } 106 | @headers = { 'token' => 'A' } 107 | end 108 | 109 | def test_request(method) 110 | connection = double 111 | instance = ApiClient::Scope.new(ApiClient::Base) 112 | instance.stub(:connection).and_return(connection) 113 | response = Faraday::Response.new(:body => '{"a": "1"}') 114 | connection.should_receive(method).with(@path, @params, @headers).and_return(response) 115 | instance.params(@params).headers(@headers).send(method, @path) 116 | end 117 | 118 | it "can make any request" do 119 | connection = double 120 | instance = ApiClient::Scope.new(ApiClient::Base) 121 | instance.stub(:connection).and_return(connection) 122 | response = Faraday::Response.new(:body => '{"a": "1"}') 123 | connection.should_receive(:get).with(@path, @params, @headers).and_return(response) 124 | result = instance.params(@params).headers(@headers).request(:get, @path) 125 | result.should == {"a"=> "1"} 126 | end 127 | 128 | it "can make any request and get a raw response" do 129 | connection = double 130 | instance = ApiClient::Scope.new(ApiClient::Base) 131 | instance.stub(:connection).and_return(connection) 132 | response = Faraday::Response.new(:body => '{"a": "1"}') 133 | connection.should_receive(:get).twice.with(@path, @params, @headers).and_return(response) 134 | result = instance.params(@params).headers(@headers).request(:get, @path, :raw => true) 135 | result.should == response 136 | result = instance.raw.params(@params).headers(@headers).request(:get, @path) 137 | result.should == response 138 | end 139 | 140 | it "makes a GET request" do 141 | result = test_request :get 142 | result.should == {"a"=> "1"} 143 | end 144 | 145 | it "makes a POST request" do 146 | result = test_request :post 147 | result.should == {"a"=> "1"} 148 | end 149 | 150 | it "makes a PATCH request" do 151 | result = test_request :patch 152 | result.should == {"a"=> "1"} 153 | end 154 | 155 | it "makes a PUT request" do 156 | result = test_request :put 157 | result.should == {"a"=> "1"} 158 | end 159 | 160 | it "makes a PUT request" do 161 | result = test_request :delete 162 | result.should == {"a"=> "1"} 163 | end 164 | 165 | describe "fetch" do 166 | 167 | it "performs a get and builds an object" do 168 | connection = double 169 | instance = ApiClient::Scope.new(ApiClient::Base) 170 | instance.stub(:connection).and_return(connection) 171 | response = Faraday::Response.new(:body => '{"id": 42}') 172 | connection.should_receive(:get).with(@path, @params, @headers).and_return(response) 173 | result = instance.params(@params).headers(@headers).fetch(@path) 174 | result.should be_an_instance_of(ApiClient::Base) 175 | result.id.should == 42 176 | end 177 | 178 | end 179 | 180 | end 181 | 182 | describe "dynamic delegation of scopeable singleton methods" do 183 | 184 | it "dynamically delegates and properly scopes" do 185 | class DynamicDelegationTest < ApiClient::Base 186 | def self.some_method 187 | self.scope.params 188 | end 189 | end 190 | scope = ApiClient::Scope.new(DynamicDelegationTest) 191 | scope.params(:param => "aaa").some_method.should == { :param => "aaa" } 192 | end 193 | 194 | it "raises an error if scopeable does not implement the method" do 195 | scope = ApiClient::Scope.new(ApiClient::Base) 196 | lambda { 197 | scope.some_method_the_class_does_not_have 198 | }.should raise_error(NoMethodError) 199 | end 200 | 201 | end 202 | 203 | 204 | end 205 | -------------------------------------------------------------------------------- /spec/api_client/connection/basic_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiClient::Connection::Basic do 4 | 5 | it "has a nice inspect" do 6 | instance = ApiClient::Connection::Basic.new("http://google.com") 7 | instance.inspect.should == '#' 8 | end 9 | 10 | it "uses correct adapter" do 11 | instance = ApiClient::Connection::Basic.new("http://google.com") 12 | expect(instance.handler.builder.adapter.name).to eq("Faraday::Adapter::NetHttp") 13 | end 14 | 15 | it "adds basic middlewares to faraday" do 16 | instance = ApiClient::Connection::Basic.new("http://google.com") 17 | expect(instance.handler.builder.handlers.collect(&:name)).to include("Faraday::Request::UrlEncoded") 18 | end 19 | 20 | it "adds the logger middlewares to faraday if ApiClient.logger is available" do 21 | logger = double 22 | ApiClient.stub(:logger).and_return(logger) 23 | instance = ApiClient::Connection::Basic.new("http://google.com") 24 | expect(instance.handler.builder.handlers.collect(&:name)).to include("ApiClient::Connection::Middlewares::Request::Logger", "Faraday::Request::UrlEncoded") 25 | end 26 | 27 | it "creates a Faraday object on initialize" do 28 | instance = ApiClient::Connection::Basic.new("http://google.com") 29 | instance.handler.should be_an_instance_of(Faraday::Connection) 30 | end 31 | 32 | describe "requests" do 33 | 34 | before do 35 | @instance = ApiClient::Connection::Basic.new("http://google.com") 36 | @headers = { "header" => "token" } 37 | @params = { "param" => "1", "nested" => { "param" => "1" } } 38 | @response = Faraday::Response.new(:status => 200) 39 | @faraday_request_params = double 40 | @faraday_request = double(:params => @faraday_request_params) 41 | end 42 | 43 | it "can perform GET requests" do 44 | @instance.handler. 45 | should_receive(:run_request). 46 | with(:get, '/home', nil, @headers). 47 | and_yield(@faraday_request). 48 | and_return(@response) 49 | @faraday_request_params.should_receive(:update).with(@params) 50 | @instance.get "/home", @params, @headers 51 | end 52 | 53 | it "can perform POST requests" do 54 | @instance.handler. 55 | should_receive(:run_request). 56 | with(:post, '/home', @params, @headers). 57 | and_return(@response) 58 | @instance.post "/home", @params, @headers 59 | end 60 | 61 | it "can perform PATCH requests" do 62 | @instance.handler. 63 | should_receive(:run_request). 64 | with(:patch, '/home', @params, @headers). 65 | and_return(@response) 66 | @instance.patch "/home", @params, @headers 67 | end 68 | 69 | it "can perform PUT requests" do 70 | @instance.handler. 71 | should_receive(:run_request). 72 | with(:put, '/home', @params, @headers). 73 | and_return(@response) 74 | @instance.put "/home", @params, @headers 75 | end 76 | 77 | it "can perform DELETE requests" do 78 | @instance.handler. 79 | should_receive(:run_request). 80 | with(:delete, '/home', nil, @headers). 81 | and_yield(@faraday_request). 82 | and_return(@response) 83 | @faraday_request_params.should_receive(:update).with(@params) 84 | @instance.delete "/home", @params, @headers 85 | end 86 | 87 | end 88 | 89 | describe "#handle_response" do 90 | let(:request) { double } 91 | 92 | before do 93 | @instance = ApiClient::Connection::Basic.new("http://google.com") 94 | @response = Faraday::Response.new(:status => 200) 95 | end 96 | 97 | it "raises an ApiClient::Errors::ConnectionFailed if there is no response" do 98 | lambda { 99 | @instance.send :handle_response, request, nil 100 | }.should raise_error(ApiClient::Errors::ConnectionFailed, "ApiClient::Errors::ConnectionFailed") 101 | end 102 | 103 | it "raises an ApiClient::Errors::Unauthorized if status is 401" do 104 | @response.env[:status] = 401 105 | lambda { 106 | @instance.send :handle_response, request, @response 107 | }.should raise_error(ApiClient::Errors::Unauthorized, "Status code: 401") 108 | end 109 | 110 | it "raises an ApiClient::Errors::Forbidden if status is 403" do 111 | @response.env[:status] = 403 112 | lambda { 113 | @instance.send :handle_response, request, @response 114 | }.should raise_error(ApiClient::Errors::Forbidden, "Status code: 403") 115 | end 116 | 117 | it "raises an ApiClient::Errors::NotFound if status is 404" do 118 | @response.env[:status] = 404 119 | lambda { 120 | @instance.send :handle_response, request, @response 121 | }.should raise_error(ApiClient::Errors::NotFound, "Status code: 404") 122 | end 123 | 124 | it "raises an ApiClient::Errors::BadRequest if status is 400" do 125 | @response.env[:status] = 400 126 | lambda { 127 | @instance.send :handle_response, request, @response 128 | }.should raise_error(ApiClient::Errors::BadRequest, "Status code: 400") 129 | end 130 | 131 | it "raises an ApiClient::Errors::Unsupported if status is 406" do 132 | @response.env[:status] = 406 133 | lambda { 134 | @instance.send :handle_response, request, @response 135 | }.should raise_error(ApiClient::Errors::Unsupported, "Status code: 406") 136 | end 137 | 138 | it "raises an ApiClient::Errors::Conflict if status is 409" do 139 | @response.env[:status] = 409 140 | lambda { 141 | @instance.send :handle_response, request, @response 142 | }.should raise_error(ApiClient::Errors::Conflict, "Status code: 409") 143 | end 144 | 145 | it "raises an ApiClient::Errors::Gone if status is 410" do 146 | @response.env[:status] = 410 147 | lambda { 148 | @instance.send :handle_response, request, @response 149 | }.should raise_error(ApiClient::Errors::Gone, "Status code: 410") 150 | end 151 | 152 | it "raises an ApiClient::Errors::PreconditionFailed if status is 412" do 153 | @response.env[:status] = 412 154 | lambda { 155 | @instance.send :handle_response, request, @response 156 | }.should raise_error(ApiClient::Errors::PreconditionFailed, "Status code: 412") 157 | end 158 | 159 | it "raises an ApiClient::Errors::Unsupported if status is 422" do 160 | @response.env[:status] = 422 161 | lambda { 162 | @instance.send :handle_response, request, @response 163 | }.should raise_error(ApiClient::Errors::UnprocessableEntity, @response.body) 164 | end 165 | 166 | it "raises an ApiClient::Errors::Locked if status is 423" do 167 | @response.env[:status] = 423 168 | lambda { 169 | @instance.send :handle_response, request, @response 170 | }.should raise_error(ApiClient::Errors::Locked, @response.body) 171 | end 172 | 173 | it "raises an ApiClient::Errors::TooManyRequests if status is 429" do 174 | @response.env[:status] = 429 175 | lambda { 176 | @instance.send :handle_response, request, @response 177 | }.should raise_error(ApiClient::Errors::TooManyRequests, @response.body) 178 | end 179 | 180 | it "raises an ApiClient::Errors::Unsupported if status is 300..399" do 181 | location = "https://google.com" 182 | @response.env[:status] = 302 183 | @response.env[:response_headers] = { 'Location' => location } 184 | lambda { 185 | @instance.send :handle_response, request, @response 186 | }.should raise_error(ApiClient::Errors::Redirect, location) 187 | end 188 | 189 | it "raises an ApiClient::Errors::ServerError if status is 500..599" do 190 | @response.env[:status] = 502 191 | lambda { 192 | @instance.send :handle_response, request, @response 193 | }.should raise_error(ApiClient::Errors::ServerError, "Status code: 502") 194 | end 195 | 196 | end 197 | 198 | end 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2011 Zendesk 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------