http_api_client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ ODliNzQyOGIyOGNmOGEyYjFlZGRjNTg2MGU1MjExODdjMDIzYzg3OQ==
5
+ data.tar.gz: !binary |-
6
+ NjgxOGE3OWRjYzQ0ZmYxMGM3MjA0MWE5ZDE1YTI0ZTU5MDEwMGM0OA==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ YzZiZWE4ZWFjMTllNjZjYTg4MDVkYWRmNDUwZmQzY2ViNDE0ZDRmOTkyZDQw
10
+ MTM1NGI2MDY1NzU5NDY5MzM3MjVmNjc5MjdjYjdhZjEyNGMyN2JlYTU1YzBl
11
+ Nzg1ZjAwMjY1NTYxYzQzN2ZhNzc5OGQxYzBiN2Y4ZTc3YjE4NmQ=
12
+ data.tar.gz: !binary |-
13
+ NzNiMzk1MjE2MjIwMTkxNzBmOGNmNjFlYzMxNTVhNjIwY2QxZDg1OTc2MzIw
14
+ NzAwMDQyOGFhZmJlZjc2YWEwZTk0OGQxMGZmYWRjMzE4MTYwZGViZWM0ZjIy
15
+ ZDE3NmVhY2UxYjBkOTc4ZWZhNGZhYWY5ZGFlZTVjM2IyMzZlYmE=
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --format documentation
3
+ --profile
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in http_api_client.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Rob Monie
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # HttpApiClient
2
+ Basic shared http related utils and error translation for places applications.
3
+
4
+ Currently:
5
+ - Http client - faraday based, configurable, threadsafe, ssl capable.
6
+ - Error translation. Translates http status codes to named errors for more precise error handling in application code.
7
+
8
+
9
+ ## Usage
10
+
11
+ Create a http client by extending `HttpApiClient::Client` and providing a configuration key for the config relating to that client:
12
+
13
+ ```ruby
14
+ module ApiClients
15
+ class Foursquare < HttpApiClient::Client
16
+
17
+ include Singleton
18
+
19
+ def initialize
20
+ super(:foursquare)
21
+ end
22
+ end
23
+ end
24
+ ```
25
+
26
+ This will construct a http client configured as 'places_api' in the `config/http_api_clients.yml` configuration file.
27
+
28
+ Eg:
29
+ ```
30
+ production:
31
+ foursquare:
32
+ protocol: https
33
+ server: api.foursquare.com
34
+ #port: 443 (not required)
35
+ #base_uri: '' (not required)
36
+
37
+ development:
38
+ foursquare:
39
+ protocol: https
40
+ server: api.foursquare.com
41
+ #port: 443 (not required)
42
+ #base_uri: '' (not required)
43
+
44
+
45
+ #etc. Regular yaml defaults / overrides etc can be used to keep DRY
46
+
47
+ ```
48
+
49
+ ### Possible keys
50
+
51
+ * ```protocol``` - protocol, e.g. **https** or **http**
52
+ * ```server``` - the host name, e.g. **api.foursquare.com**
53
+ * ```port``` - self-explanatory (not required)
54
+ * ```base_uri``` - base path/uri e.g. **/api** (not required)
55
+ * ```http_basic_username``` - username for HTTP Basic Auth (not required)
56
+ * ```http_basic_password``` - password for HTTP Basic Auth (not required)
57
+ * ```ca_file``` - the path to a self-signed cert authority file, e.g. **/usr/local/etc/nginx/my-server.crt** (not required)
58
+
59
+ By implementing your http clients as singletons, you can make the most of faraday's persistent http connections via net_http_persistent. This can have a significant impact on performance for chatty apps assuming that the target server implements keep-alive.
60
+
61
+ ### Specifiying Token Authentication Params
62
+
63
+ Some http apis such as foursquare or instagram require auth params to be passed. These can be defined at the client level by implementing the `auth_params` method.
64
+
65
+ ```ruby
66
+ module ApiClients
67
+ class Foursquare < HttpApiClient::Client
68
+
69
+ include Singleton
70
+
71
+ def initialize
72
+ super(:foursquare)
73
+ end
74
+
75
+ def auth_params
76
+ {
77
+ client_id: Application.config.foursquare_client_id,
78
+ client_secret: Application.config.foursquare_secret,
79
+ v: Date.today.strftime('%Y%m%d')
80
+ }
81
+ end
82
+
83
+ end
84
+ end
85
+ ```
86
+
87
+ ### Current API
88
+
89
+ All api calls will return ruby hashed version of json responsea and translate error codes to appropriate Errors (Eg. 404 -> HttpApiClient::NotFound)
90
+
91
+
92
+ #### Raw Http Api
93
+ ```ruby
94
+ # GET
95
+ client.get(base_path, params, headers)
96
+
97
+ # POST
98
+ client.create(base_path, payload)
99
+
100
+ # DELETE
101
+ client.destroy(base_path, id)
102
+
103
+ # UPDATE - Not yet implemented (if you need it, add it)
104
+
105
+ ```
106
+
107
+ #### Higher level Api
108
+
109
+ Not sure if this belongs here yet. It may be removed. These all just pass through to `client.get`
110
+
111
+ ```ruby
112
+ # GET
113
+ client.find(base_path, id, query = {})
114
+
115
+ client.find_nested(base_path, id, nested_path)
116
+
117
+ client.find_all(base_path, query = {})
118
+
119
+ ```
120
+
121
+ #### Request Id Tracking
122
+ In order to provide a common request id from api call to the service provider for the purposes of monitoring, a Request-Id header can be
123
+ added to all requests. In order to do this, the following config options is required:
124
+
125
+ `include_request_id_header: true`
126
+
127
+ In addition to this, your client code should have set a thread local variable keyed under `request_id`.
128
+
129
+ Eg: `Thread.current[:request_id] = request_id`
130
+
131
+ With these in place, a request header will be added to the http request which can then be picked up and logged throughout the service provider application code.
132
+
133
+ ## SSL Support
134
+
135
+ SSL is supported but requires certificates for the major certificate authorities to be installed when used on OSX. Linux should have these already.
136
+
137
+ `brew install curl-ca-bundle`
138
+
139
+ This will install `/usr/local/opt/curl-ca-bundle/share/ca-bundle.crt`
140
+
141
+ ## TODO:
142
+
143
+ * Consider enforcing an SSL connection when using HTTP Basic Auth
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'http_api_client/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'http_api_client'
8
+ spec.version = HttpApiClient::VERSION
9
+ spec.authors = ['Rob Monie', 'Andrei Miulescu', 'Stuart Liston', 'Chris Rhode']
10
+ spec.email = ['robmonie@gmail.com']
11
+ spec.description = %q{Http client wrapper for simplified api access}
12
+ spec.summary = %q{}
13
+ spec.homepage = ''
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.3'
22
+ spec.add_development_dependency 'rspec', '~> 2.14'
23
+ spec.add_development_dependency 'pry', '~> 0.9'
24
+ spec.add_development_dependency 'pry-debugger', '~> 0.2'
25
+
26
+ spec.add_dependency 'activesupport', '>= 3.1'
27
+ spec.add_dependency 'faraday', '~> 0.9'
28
+ spec.add_dependency 'net-http-persistent', '~> 2.9'
29
+ spec.add_dependency 'oj', '~> 2.7'
30
+
31
+
32
+
33
+ end
@@ -0,0 +1,159 @@
1
+ # coding: utf-8
2
+
3
+ require 'active_support/core_ext/object/to_query'
4
+ require "active_support/json"
5
+ require 'faraday'
6
+ require 'oj'
7
+ require 'http_api_client'
8
+ require 'http_api_client/errors'
9
+ require 'http_api_client/connection_factory'
10
+ require 'http_api_client/timed_result'
11
+
12
+ module HttpApiClient
13
+ class Client
14
+
15
+ include HttpApiClient::Errors::Factory
16
+
17
+ attr_reader :config
18
+
19
+ def initialize(client_id, config_file = nil)
20
+ raise "You must supply a http client config id (as defined in #{config_file || Config::DEFAULT_CONFIG_FILE_LOCATION}" unless client_id
21
+
22
+ if config_file
23
+ @config = Config.new(config_file).send(client_id)
24
+ else
25
+ @config = Config.new.send(client_id)
26
+ end
27
+
28
+ end
29
+
30
+ def find(base_path, id, query = {})
31
+ get("#{base_path}/#{id}", query)
32
+ end
33
+
34
+ def find_nested(base_path, id, nested_path)
35
+ get("#{base_path}/#{id}/#{nested_path}")
36
+ end
37
+
38
+ def find_all(base_path, query = {})
39
+ get("#{base_path}", query)
40
+ end
41
+
42
+ def get(path, query = {}, custom_headers = {})
43
+
44
+ log_data = { method: 'get', host: config.server, path: path_with_query(path, query) }
45
+
46
+ response = TimedResult.time('http_api_client_request', log_data) do
47
+ connection.get(full_path(path), with_auth(query), request_headers(get_headers, custom_headers))
48
+ end
49
+
50
+ handle_response(response, :get, path)
51
+ end
52
+
53
+ def create(path, payload, custom_headers = {})
54
+
55
+ log_data = { method: 'post', host: config.server, path: full_path(path) }
56
+
57
+ response = TimedResult.time('http_api_client_request', log_data) do
58
+ connection.post(full_path(path), JSON.fast_generate(with_auth(payload)), request_headers(update_headers, custom_headers))
59
+ end
60
+
61
+ handle_response(response, :post, path)
62
+ end
63
+
64
+ def destroy(base_path, id, custom_headers = {})
65
+
66
+ path = "#{base_path}/#{id}"
67
+ log_data = { method: 'delete', host: config.server, path: full_path(path) }
68
+
69
+ response = TimedResult.time('http_api_client_request', log_data) do
70
+ connection.delete(full_path(path), request_headers(update_headers, custom_headers))
71
+ end
72
+
73
+ handle_response(response, :delete, path)
74
+ end
75
+
76
+ def connection
77
+ @connection ||= ConnectionFactory.new(config).create
78
+ end
79
+
80
+ private
81
+
82
+ def params_encoder
83
+ @params_encoder ||= HttpApiClient.params_encoder
84
+ end
85
+
86
+ def auth_params
87
+ {}
88
+ end
89
+
90
+ def handle_response(response, method, path)
91
+ if ok?(response) || validation_failed?(response)
92
+ if response.body
93
+ #Don't use regular load method - any strings starting with ':' ( :-) from example) will be interpreted as a symbol
94
+ Oj.strict_load(response.body)
95
+ else
96
+ true
97
+ end
98
+ else
99
+ error_class = error_for_status(response.status)
100
+ message = "#{response.status} #{method}: #{path}"
101
+ HttpApiClient.logger.warn("Http Client #{error_class}: #{message}")
102
+ raise error_class.new(message, response.body)
103
+ end
104
+ end
105
+
106
+ def ok?(response)
107
+ Integer(response.status).between?(200, 299)
108
+ end
109
+
110
+ def validation_failed?(response)
111
+ Integer(response.status) == 422
112
+ end
113
+
114
+ def full_path(path)
115
+ path = "/#{config.base_uri}/#{path}".gsub(/\/+/, '/')
116
+ path
117
+ end
118
+
119
+ def path_with_query(path, query)
120
+ path = full_path(path)
121
+ path += "?#{params_encoder.encode(query)}" unless query.keys.empty?
122
+ path
123
+ end
124
+
125
+ def with_auth(query)
126
+ query.merge(auth_params)
127
+ end
128
+
129
+ def request_headers(base_headers, custom_headers = {})
130
+ all_headers = base_headers.merge(custom_headers)
131
+ all_headers.merge!({'X-Request-Id' => Thread.current[:request_id]}) if config.include_request_id_header
132
+ all_headers
133
+ end
134
+
135
+ def get_headers
136
+ {
137
+ 'Accept' => 'application/json',
138
+ }
139
+ end
140
+
141
+ def update_headers
142
+ {
143
+ 'Accept' => 'application/json',
144
+ 'Content-Type' => 'application/json'
145
+ }
146
+ end
147
+
148
+
149
+
150
+ # def params_encoder
151
+ # if HttpApiClient::rails_loaded?
152
+ # RailsParamsEncoder
153
+ # else
154
+
155
+ # end
156
+ # end
157
+
158
+ end
159
+ end
@@ -0,0 +1,58 @@
1
+ # coding: utf-8
2
+
3
+ require 'yaml'
4
+ require 'ostruct'
5
+ require 'http_api_client'
6
+
7
+ module HttpApiClient
8
+ class Config
9
+
10
+ DEFAULT_CONFIG_FILE_LOCATION = 'config/http_api_clients.yml'
11
+
12
+ def initialize(config_file = DEFAULT_CONFIG_FILE_LOCATION)
13
+ if File.exists?(config_file)
14
+ @config = symbolize_keys(config_for(config_file, HttpApiClient.env))
15
+ else
16
+ raise "Could not load config file: #{config_file}"
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :config
23
+
24
+ def method_missing(method, *args, &block)
25
+ if config[method]
26
+ OpenStruct.new(config[method])
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ def config_for(config_file, environment)
33
+ all_config = YAML.load_file(config_file)
34
+ env_config = all_config[environment]
35
+ if env_config
36
+ env_config
37
+ else
38
+ raise "You must supply a http config for the '#{environment}' environment in '#{config_file}'."
39
+ end
40
+ end
41
+
42
+ def symbolize_keys(hash)
43
+ hash.inject({}) do |result, (key, value)|
44
+ new_key = case key
45
+ when String then key.to_sym
46
+ else key
47
+ end
48
+ new_value = case value
49
+ when Hash then symbolize_keys(value)
50
+ else value
51
+ end
52
+ result[new_key] = new_value
53
+ result
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,58 @@
1
+ require 'http_api_client/rails_params_encoder'
2
+
3
+ module HttpApiClient
4
+ class ConnectionFactory
5
+
6
+ OSX_CERT_PATH = '/usr/local/opt/curl-ca-bundle/share/ca-bundle.crt'
7
+
8
+ attr_reader :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def create
15
+
16
+ Faraday.new(connection_options) do |connection|
17
+ connection.port = config.port if config.port
18
+ connection.request :url_encoded # form-encode POST params
19
+ connection.adapter :net_http_persistent
20
+ # connection.use :http_cache
21
+ # connection.response :logger
22
+
23
+ if config.http_basic_username
24
+ connection.basic_auth(config.http_basic_username, config.http_basic_password)
25
+ end
26
+ end
27
+
28
+ end
29
+
30
+ private
31
+
32
+ def connection_options
33
+ options = { url: "#{config.protocol}://#{config.server}" }
34
+ options.merge!(ssl_config) if config.protocol == 'https'
35
+ options.merge!({ request: { params_encoder: HttpApiClient.params_encoder } }) if {}.respond_to?(:to_query)
36
+ options
37
+ end
38
+
39
+ def ssl_config
40
+ return { ssl: { ca_file: config.ca_file } } if config.ca_file
41
+ return { ssl: { ca_file: osx_ssl_ca_file } } if osx?
42
+ return { ssl: { ca_path: '/etc/ssl/certs' } }
43
+ end
44
+
45
+ def osx?
46
+ `uname`.chomp == 'Darwin'
47
+ end
48
+
49
+ def osx_ssl_ca_file
50
+ if File.exists?(OSX_CERT_PATH)
51
+ OSX_CERT_PATH
52
+ else
53
+ raise "Unable to load certificate authority file at #{OSX_CERT_PATH}. Try `brew install curl-ca-bundle`"
54
+ end
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,90 @@
1
+ require 'http_api_client'
2
+
3
+ module HttpApiClient
4
+
5
+ module Errors
6
+
7
+ class BaseError < StandardError
8
+
9
+ attr_reader :response_body, :nested_error
10
+
11
+ def initialize(message, response_body, nested_error = nil)
12
+ super(message)
13
+ @message = message
14
+ @response_body = response_body
15
+ @nested_error = nested_error
16
+ end
17
+
18
+ def message
19
+ messages = [@message]
20
+ messages << nested_error.message if nested_error
21
+ messages << response_body
22
+ messages.join("\n\n")
23
+ end
24
+
25
+ end
26
+
27
+ #400 Range
28
+ class BadRequest < BaseError ; end
29
+ class Unauthorized < BaseError ; end
30
+ class Forbidden < BaseError ; end
31
+ class NotFound < BaseError ; end
32
+ class MethodNotAllowed < BaseError ; end
33
+ class NotAcceptable < BaseError ; end
34
+ class RequestTimeout < BaseError ; end
35
+ class UnknownStatus < BaseError ; end
36
+ class UnprocessableEntity < BaseError ; end
37
+ class TooManyRequests < BaseError ; end
38
+
39
+ #500 Range
40
+ class InternalServerError < BaseError ; end
41
+ class NotImplemented < BaseError ; end
42
+ class BadGateway < BaseError ; end
43
+ class ServiceUnavailable < BaseError ; end
44
+ class GatewayTimeout < BaseError ; end
45
+
46
+ module Factory
47
+
48
+ def error_for_status(status)
49
+ case status
50
+ when 400
51
+ BadRequest
52
+ when 401
53
+ Unauthorized
54
+ when 403
55
+ Forbidden
56
+ when 404
57
+ NotFound
58
+ when 405
59
+ MethodNotAllowed
60
+ when 406
61
+ NotAcceptable
62
+ when 408
63
+ RequestTimeout
64
+
65
+ when 422
66
+ UnprocessableEntity
67
+
68
+ when 429
69
+ TooManyRequests
70
+
71
+ when 500
72
+ InternalServerError
73
+ when 501
74
+ NotImplemented
75
+ when 502
76
+ BadGateway
77
+ when 503
78
+ ServiceUnavailable
79
+ when 504
80
+ GatewayTimeout
81
+
82
+ else
83
+ UnknownStatus
84
+ end
85
+ end
86
+ end
87
+
88
+ end
89
+
90
+ end
@@ -0,0 +1,9 @@
1
+ module HttpApiClient
2
+ module RailsParamsEncoder
3
+
4
+ def self.encode(params)
5
+ params.to_query
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ # encoding: utf-8
2
+ class TimedResult
3
+
4
+ def self.time(event, log_data = {})
5
+ start_time = Time.now
6
+ yield
7
+ ensure
8
+
9
+ time = millis_since(start_time)
10
+
11
+ log_entries = ["event=#{event}"]
12
+ log_entries << "request_id=#{Thread.current[:request_id]}" if Thread.current[:request_id]
13
+ log_entries << "timing=#{time}"
14
+ log_entries.concat(log_data.to_param.split('&'))
15
+
16
+ HttpApiClient.logger.info(log_entries.join(", "))
17
+
18
+ end
19
+
20
+ def self.millis_since(start_time)
21
+ (Time.now - start_time) * 1000
22
+ end
23
+
24
+ end
@@ -0,0 +1,3 @@
1
+ module HttpApiClient
2
+ VERSION = "0.1.0"
3
+ end