opinionated_http 0.0.4 → 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 +4 -4
- data/README.md +4 -0
- data/Rakefile +9 -7
- data/lib/opinionated_http/client.rb +121 -87
- data/lib/opinionated_http/request.rb +77 -0
- data/lib/opinionated_http/response.rb +55 -0
- data/lib/opinionated_http/version.rb +1 -1
- data/lib/opinionated_http.rb +5 -3
- data/test/client_test.rb +73 -89
- data/test/config/application.yml +1 -1
- data/test/request_test.rb +95 -0
- data/test/test_helper.rb +10 -10
- metadata +11 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0b33ff41de4de2487017dcb16c8af1bd049d8f59b99af148c2782f5aadbf90f
|
|
4
|
+
data.tar.gz: fbb4ddce12a6aabb67827d124fd579e91d4a2b4def78d74c893dc82687519c96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3b31d0083666203057f05a38bdc0e7a5519af9757df935c9c772ca514bb7f8777c9b52a24edb960b201e5816bca356508d9470bfcd42fdcf83ee3bd622ec51b
|
|
7
|
+
data.tar.gz: b6b9ba368403e795fc19b81a759e30cd03de38fd436c52222db7f7e73f79d4ad38fb1054c7ad6a23a2cddf5c6f55654127e334c76b0f5fd5f0ab3deab606163d
|
data/README.md
CHANGED
|
@@ -36,6 +36,10 @@ Parameters:
|
|
|
36
36
|
Required.
|
|
37
37
|
logger:
|
|
38
38
|
Default: SemanticLogger[OpinionatedHTTP]
|
|
39
|
+
format: [:json | nil]
|
|
40
|
+
Optionally set the format for http requests.
|
|
41
|
+
Currently only supports `:json`, which will format the request and response to/from JSON.
|
|
42
|
+
It will also set the `Content-Type` http header to `application/json`.
|
|
39
43
|
Other options as supported by PersistentHTTP
|
|
40
44
|
#TODO: Expand PersistentHTTP options here
|
|
41
45
|
|
data/Rakefile
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
# Setup bundler to avoid having to run bundle exec all the time.
|
|
2
|
-
require
|
|
3
|
-
require
|
|
2
|
+
require "rubygems"
|
|
3
|
+
require "bundler/setup"
|
|
4
4
|
|
|
5
|
-
require
|
|
6
|
-
require_relative
|
|
5
|
+
require "rake/testtask"
|
|
6
|
+
require_relative "lib/opinionated_http/version"
|
|
7
7
|
|
|
8
8
|
task :gem do
|
|
9
|
-
system
|
|
9
|
+
system "gem build opinionated_http.gemspec"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task publish: :gem do
|
|
13
13
|
system "git tag -a v#{OpinionatedHTTP::VERSION} -m 'Tagging #{OpinionatedHTTP::VERSION}'"
|
|
14
|
-
system
|
|
14
|
+
system "git push --tags"
|
|
15
15
|
system "gem push opinionated_http-#{OpinionatedHTTP::VERSION}.gem"
|
|
16
16
|
system "rm opinionated_http-#{OpinionatedHTTP::VERSION}.gem"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
Rake::TestTask.new(:test) do |t|
|
|
20
|
-
t.pattern =
|
|
20
|
+
t.pattern = "test/**/*_test.rb"
|
|
21
21
|
t.verbose = true
|
|
22
22
|
t.warning = false
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
task default: :test
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
3
|
-
require
|
|
1
|
+
require "persistent_http"
|
|
2
|
+
require "secret_config"
|
|
3
|
+
require "semantic_logger"
|
|
4
4
|
#
|
|
5
5
|
# Client http implementation
|
|
6
6
|
#
|
|
@@ -8,125 +8,159 @@ require 'semantic_logger'
|
|
|
8
8
|
module OpinionatedHTTP
|
|
9
9
|
class Client
|
|
10
10
|
# 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
|
|
11
|
-
HTTP_RETRY_CODES = %w[502 503 504]
|
|
12
|
-
|
|
13
|
-
attr_reader :secret_config_prefix, :logger, :metric_prefix, :error_class, :driver,
|
|
14
|
-
:retry_count, :retry_interval, :retry_multiplier, :http_retry_codes
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
HTTP_RETRY_CODES = %w[502 503 504].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :secret_config_prefix, :logger, :format, :metric_prefix, :error_class, :driver, :format,
|
|
14
|
+
:retry_count, :retry_interval, :retry_multiplier, :http_retry_codes,
|
|
15
|
+
:url, :pool_size, :keep_alive, :proxy, :force_retry, :max_redirects,
|
|
16
|
+
:open_timeout, :read_timeout, :idle_timeout, :pool_timeout, :warn_timeout,
|
|
17
|
+
:after_connect
|
|
18
|
+
|
|
19
|
+
# Any option supplied here can be overridden if that corresponding value is set in Secret Config.
|
|
20
|
+
def initialize(
|
|
21
|
+
secret_config_prefix:,
|
|
22
|
+
metric_prefix:,
|
|
23
|
+
error_class:,
|
|
24
|
+
logger: nil,
|
|
25
|
+
format: nil,
|
|
26
|
+
retry_count: 11,
|
|
27
|
+
retry_interval: 0.01,
|
|
28
|
+
retry_multiplier: 1.8,
|
|
29
|
+
http_retry_codes: HTTP_RETRY_CODES.join(","),
|
|
30
|
+
url: nil,
|
|
31
|
+
pool_size: 100,
|
|
32
|
+
open_timeout: 10,
|
|
33
|
+
read_timeout: 10,
|
|
34
|
+
idle_timeout: 300,
|
|
35
|
+
keep_alive: 300,
|
|
36
|
+
pool_timeout: 5,
|
|
37
|
+
warn_timeout: 0.25,
|
|
38
|
+
proxy: :ENV,
|
|
39
|
+
force_retry: true,
|
|
40
|
+
max_redirects: 10,
|
|
41
|
+
after_connect: nil,
|
|
42
|
+
verify_peer: false, # TODO: PersistentHTTP keeps returning cert expired even when it is valid.
|
|
43
|
+
certificate: nil,
|
|
44
|
+
private_key: nil
|
|
45
|
+
)
|
|
46
|
+
@metric_prefix = metric_prefix
|
|
47
|
+
@logger = logger || SemanticLogger[self]
|
|
48
|
+
@error_class = error_class
|
|
49
|
+
@format = format
|
|
50
|
+
@after_connect = after_connect
|
|
51
|
+
SecretConfig.configure(secret_config_prefix) do |config|
|
|
52
|
+
@retry_count = config.fetch("retry_count", type: :integer, default: retry_count)
|
|
53
|
+
@retry_interval = config.fetch("retry_interval", type: :float, default: retry_interval)
|
|
54
|
+
@retry_multiplier = config.fetch("retry_multiplier", type: :float, default: retry_multiplier)
|
|
55
|
+
@max_redirects = config.fetch("max_redirects", type: :integer, default: max_redirects)
|
|
56
|
+
http_retry_codes = config.fetch("http_retry_codes", type: :string, default: http_retry_codes)
|
|
57
|
+
@http_retry_codes = http_retry_codes.split(",").collect(&:strip)
|
|
58
|
+
|
|
59
|
+
@url = url.nil? ? config.fetch("url") : config.fetch("url", default: url)
|
|
60
|
+
|
|
61
|
+
@pool_size = config.fetch("pool_size", type: :integer, default: pool_size)
|
|
62
|
+
@open_timeout = config.fetch("open_timeout", type: :float, default: open_timeout)
|
|
63
|
+
@read_timeout = config.fetch("read_timeout", type: :float, default: read_timeout)
|
|
64
|
+
@idle_timeout = config.fetch("idle_timeout", type: :float, default: idle_timeout)
|
|
65
|
+
@keep_alive = config.fetch("keep_alive", type: :float, default: keep_alive)
|
|
66
|
+
@pool_timeout = config.fetch("pool_timeout", type: :float, default: pool_timeout)
|
|
67
|
+
@warn_timeout = config.fetch("warn_timeout", type: :float, default: warn_timeout)
|
|
68
|
+
@proxy = config.fetch("proxy", type: :symbol, default: proxy)
|
|
69
|
+
@force_retry = config.fetch("force_retry", type: :boolean, default: force_retry)
|
|
70
|
+
@certificate = config.fetch("certificate", type: :string, default: certificate)
|
|
71
|
+
@private_key = config.fetch("private_key", type: :string, default: private_key)
|
|
72
|
+
@verify_peer = config.fetch("verify_peer", type: :boolean, default: verify_peer)
|
|
73
|
+
end
|
|
25
74
|
|
|
26
75
|
internal_logger = OpinionatedHTTP::Logger.new(@logger)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
@driver = PersistentHTTP.new(
|
|
77
|
+
url: @url,
|
|
78
|
+
logger: internal_logger,
|
|
79
|
+
debug_output: internal_logger,
|
|
80
|
+
name: "",
|
|
81
|
+
pool_size: @pool_size,
|
|
82
|
+
open_timeout: @open_timeout,
|
|
83
|
+
read_timeout: @read_timeout,
|
|
84
|
+
idle_timeout: @idle_timeout,
|
|
85
|
+
keep_alive: @keep_alive,
|
|
86
|
+
pool_timeout: @pool_timeout,
|
|
87
|
+
warn_timeout: @warn_timeout,
|
|
88
|
+
proxy: @proxy,
|
|
89
|
+
force_retry: @force_retry,
|
|
90
|
+
after_connect: @after_connect,
|
|
91
|
+
certificate: @certificate,
|
|
92
|
+
private_key: @private_key,
|
|
93
|
+
verify_mode: @verify_peer ? OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT : OpenSSL::SSL::VERIFY_NONE
|
|
94
|
+
)
|
|
45
95
|
end
|
|
46
96
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
request = generic_request(path: path, verb: 'Get')
|
|
53
|
-
response = request_with_retry(action: action, path: path, request: request)
|
|
54
|
-
|
|
55
|
-
response.body
|
|
97
|
+
def get(request: nil, **args)
|
|
98
|
+
request ||= Request.new(**args)
|
|
99
|
+
request.verb = "Get"
|
|
100
|
+
http_response = request(request)
|
|
101
|
+
Response.new(http_response, request)
|
|
56
102
|
end
|
|
57
103
|
|
|
58
|
-
def post(
|
|
59
|
-
|
|
60
|
-
request
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
response.body
|
|
104
|
+
def post(request: nil, **args)
|
|
105
|
+
request ||= Request.new(**args)
|
|
106
|
+
request.verb = "Post"
|
|
107
|
+
http_response = request(request)
|
|
108
|
+
Response.new(http_response, request)
|
|
65
109
|
end
|
|
66
110
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
request
|
|
72
|
-
request.
|
|
73
|
-
request.
|
|
74
|
-
request.basic_auth(username, password) if username && password
|
|
75
|
-
request
|
|
111
|
+
# Returns [Response] after submitting the [Request]
|
|
112
|
+
def request(request)
|
|
113
|
+
request.metric_prefix ||= metric_prefix
|
|
114
|
+
request.format ||= format
|
|
115
|
+
request.error_class ||= error_class
|
|
116
|
+
request.logger ||= logger
|
|
117
|
+
request_with_retry(action: request.action, request: request.http_request)
|
|
76
118
|
end
|
|
77
119
|
|
|
78
120
|
private
|
|
79
121
|
|
|
80
|
-
def request_with_retry(action:,
|
|
122
|
+
def request_with_retry(action:, request:, try_count: 0)
|
|
81
123
|
http_method = request.method.upcase
|
|
82
124
|
response =
|
|
83
125
|
begin
|
|
84
126
|
payload = {}
|
|
85
127
|
if logger.trace?
|
|
86
|
-
payload[:parameters] = parameters
|
|
87
|
-
payload[:path]
|
|
128
|
+
# payload[:parameters] = parameters
|
|
129
|
+
payload[:path] = request.path
|
|
88
130
|
end
|
|
89
131
|
message = "HTTP #{http_method}: #{action}" if logger.debug?
|
|
90
132
|
|
|
91
|
-
logger.benchmark_info(message: message, metric: "#{metric_prefix}/#{action}", payload: payload)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
133
|
+
logger.benchmark_info(message: message, metric: "#{metric_prefix}/#{action}", payload: payload) do
|
|
134
|
+
driver.request(request)
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
message = "HTTP #{http_method}: #{action} Failure: #{e.class.name}: #{e.message}"
|
|
138
|
+
logger.error(message: message, metric: "#{metric_prefix}/exception", exception: e)
|
|
95
139
|
raise(error_class, message)
|
|
96
140
|
end
|
|
97
141
|
|
|
98
142
|
# Retry on http 5xx errors except 500 which means internal server error.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
logger.warn(message: "HTTP #{http_method}: #{action} Failure: (#{response.code}) #{response.message}. Retry: #{try_count}", metric: "#{metric_prefix}/retry", duration: duration * 1_000)
|
|
104
|
-
sleep(duration)
|
|
105
|
-
response = request_with_retry(action: action, path: path, request: request, try_count: try_count)
|
|
106
|
-
else
|
|
107
|
-
message = "HTTP #{http_method}: #{action} Failure: (#{response.code}) #{response.message}. Retries Exhausted"
|
|
108
|
-
logger.error(message: message, metric: "#{metric_prefix}/exception")
|
|
109
|
-
raise(error_class, message)
|
|
110
|
-
end
|
|
111
|
-
elsif !response.is_a?(Net::HTTPSuccess)
|
|
112
|
-
message = "HTTP #{http_method}: #{action} Failure: (#{response.code}) #{response.message}"
|
|
143
|
+
return response unless http_retry_codes.include?(response.code)
|
|
144
|
+
|
|
145
|
+
if try_count >= retry_count
|
|
146
|
+
message = "HTTP #{http_method}: #{action} Failure: (#{response.code}) #{response.message}. Retries Exhausted"
|
|
113
147
|
logger.error(message: message, metric: "#{metric_prefix}/exception")
|
|
114
148
|
raise(error_class, message)
|
|
115
149
|
end
|
|
116
150
|
|
|
117
|
-
|
|
151
|
+
try_count += 1
|
|
152
|
+
duration = retry_sleep_interval(try_count)
|
|
153
|
+
logger.warn(message: "HTTP #{http_method}: #{action} Failure: (#{response.code}) #{response.message}. Retry: #{try_count}", metric: "#{metric_prefix}/retry", duration: duration * 1_000)
|
|
154
|
+
sleep(duration)
|
|
155
|
+
request_with_retry(action: action, request: request, try_count: try_count)
|
|
118
156
|
end
|
|
119
157
|
|
|
120
158
|
# First retry is immediate, next retry is after `retry_interval`,
|
|
121
159
|
# each subsequent retry interval is 100% longer than the prior interval.
|
|
122
160
|
def retry_sleep_interval(retry_count)
|
|
123
161
|
return 0 if retry_count <= 1
|
|
124
|
-
(retry_multiplier ** (retry_count - 1)) * retry_interval
|
|
125
|
-
end
|
|
126
162
|
|
|
127
|
-
|
|
128
|
-
return true if headers.nil? || form_data.nil?
|
|
129
|
-
!headers.keys.map(&:downcase).include? 'content-type'
|
|
163
|
+
(retry_multiplier ** (retry_count - 1)) * retry_interval
|
|
130
164
|
end
|
|
131
165
|
end
|
|
132
166
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module OpinionatedHTTP
|
|
2
|
+
# The request object
|
|
3
|
+
class Request
|
|
4
|
+
attr_accessor :action, :verb, :format, :path, :headers, :body, :form_data, :username, :password, :parameters,
|
|
5
|
+
:metric_prefix, :error_class, :logger
|
|
6
|
+
|
|
7
|
+
def initialize(action:, verb: nil, path: nil, format: nil, headers: {}, body: nil, form_data: nil, username: nil, password: nil, parameters: nil)
|
|
8
|
+
@action = action
|
|
9
|
+
@path =
|
|
10
|
+
if path.nil?
|
|
11
|
+
"/#{action}"
|
|
12
|
+
elsif path.start_with?("/")
|
|
13
|
+
path
|
|
14
|
+
else
|
|
15
|
+
"/#{path}"
|
|
16
|
+
end
|
|
17
|
+
@verb = verb
|
|
18
|
+
@format = format
|
|
19
|
+
@headers = headers
|
|
20
|
+
@body = body
|
|
21
|
+
@form_data = form_data
|
|
22
|
+
@username = username
|
|
23
|
+
@password = password
|
|
24
|
+
@parameters = parameters
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def http_request
|
|
28
|
+
unless headers_and_form_data_compatible?(headers, form_data)
|
|
29
|
+
raise(ArgumentError, "Setting form data will overwrite supplied content-type")
|
|
30
|
+
end
|
|
31
|
+
raise(ArgumentError, "Cannot supply both form_data and a body") if body && form_data
|
|
32
|
+
|
|
33
|
+
path_with_params = parameters ? "#{path}?#{URI.encode_www_form(parameters)}" : path
|
|
34
|
+
body = format_body if self.body
|
|
35
|
+
request = Net::HTTP.const_get(verb).new(path_with_params, headers)
|
|
36
|
+
|
|
37
|
+
if body && !request.request_body_permitted?
|
|
38
|
+
raise(ArgumentError, "#{request.class.name} does not support a request body")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if parameters && !request.response_body_permitted?
|
|
42
|
+
raise(ArgumentError, ":parameters cannot be supplied for #{request.class.name}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
request.body = body if body
|
|
46
|
+
request.set_form_data form_data if form_data
|
|
47
|
+
request.basic_auth(username, password) if username && password
|
|
48
|
+
request
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def format_body
|
|
54
|
+
return if body.nil?
|
|
55
|
+
|
|
56
|
+
case format
|
|
57
|
+
when :json
|
|
58
|
+
headers["Content-Type"] = "application/json"
|
|
59
|
+
body.to_json #unless body.is_a?(String) || body.nil?
|
|
60
|
+
when nil
|
|
61
|
+
body
|
|
62
|
+
else
|
|
63
|
+
raise(ArgumentError, "Unknown format: #{format.inspect}")
|
|
64
|
+
end
|
|
65
|
+
rescue StandardError => exc
|
|
66
|
+
message = "Failed to serialize request body. #{exc.class.name}: #{exc.message}"
|
|
67
|
+
logger.error(message: message, metric: "#{metric_prefix}/exception", exception: exc)
|
|
68
|
+
raise(error_class, message)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def headers_and_form_data_compatible?(headers, form_data)
|
|
72
|
+
return true if headers.empty? || form_data.nil?
|
|
73
|
+
|
|
74
|
+
!headers.keys.map(&:downcase).include?("content-type")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "forwardable"
|
|
2
|
+
module OpinionatedHTTP
|
|
3
|
+
# The response object
|
|
4
|
+
class Response
|
|
5
|
+
extend Forwardable
|
|
6
|
+
|
|
7
|
+
def_instance_delegators :@http_response, :code, :message
|
|
8
|
+
def_instance_delegators :@request, :verb, :action, :metric_prefix, :format, :path, :error_class, :logger
|
|
9
|
+
|
|
10
|
+
# :action used for logging the action in the error message
|
|
11
|
+
def initialize(http_response, request)
|
|
12
|
+
@http_response = http_response
|
|
13
|
+
@request = request
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def success?
|
|
17
|
+
@http_response.is_a?(Net::HTTPSuccess)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def body
|
|
21
|
+
@body ||= parse_body
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raises an exception when the HTTP Response is not a success
|
|
25
|
+
def body!
|
|
26
|
+
return body if success?
|
|
27
|
+
|
|
28
|
+
error_message = "HTTP #{verb.upcase}: #{action} Failure: (#{code}) #{message}"
|
|
29
|
+
logger.error(message: error_message, metric: "#{metric_prefix}/exception")
|
|
30
|
+
raise(error_class, error_message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :http_response, :request
|
|
36
|
+
|
|
37
|
+
def parse_body
|
|
38
|
+
return unless http_response.class.body_permitted?
|
|
39
|
+
|
|
40
|
+
case format
|
|
41
|
+
when :json
|
|
42
|
+
JSON.parse(http_response.body)
|
|
43
|
+
when nil
|
|
44
|
+
http_response.body
|
|
45
|
+
else
|
|
46
|
+
raise(ArgumentError, "Unknown format: #{format.inspect}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
rescue StandardError => exc
|
|
50
|
+
message = "Failed to parse response body. #{exc.class.name}: #{exc.message}"
|
|
51
|
+
logger.error(message: message, metric: "#{metric_prefix}/exception", exception: exc)
|
|
52
|
+
raise(error_class, message)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/opinionated_http.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
require
|
|
1
|
+
require "opinionated_http/version"
|
|
2
2
|
#
|
|
3
3
|
# Opinionated HTTP
|
|
4
4
|
#
|
|
5
5
|
# An opinionated HTTP Client library using convention over configuration.
|
|
6
6
|
#
|
|
7
7
|
module OpinionatedHTTP
|
|
8
|
-
autoload :Client,
|
|
9
|
-
autoload :Logger,
|
|
8
|
+
autoload :Client, "opinionated_http/client"
|
|
9
|
+
autoload :Logger, "opinionated_http/logger"
|
|
10
|
+
autoload :Request, "opinionated_http/request"
|
|
11
|
+
autoload :Response, "opinionated_http/response"
|
|
10
12
|
|
|
11
13
|
#
|
|
12
14
|
# Create a new Opinionated HTTP instance.
|
data/test/client_test.rb
CHANGED
|
@@ -1,109 +1,93 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
3
|
-
require_relative
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "test_helper"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
end
|
|
5
|
+
class ClientTest < Minitest::Test
|
|
6
|
+
describe OpinionatedHTTP::Client do
|
|
7
|
+
class ServiceError < StandardError
|
|
8
|
+
end
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
let :http do
|
|
11
|
+
OpinionatedHTTP.new(
|
|
12
|
+
secret_config_prefix: "fake_service",
|
|
13
|
+
metric_prefix: "FakeService",
|
|
14
|
+
logger: SemanticLogger["FakeService"],
|
|
15
|
+
error_class: ServiceError,
|
|
16
|
+
header: {"Content-Type" => "application/json"}
|
|
17
|
+
)
|
|
18
|
+
end
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# end
|
|
20
|
+
describe "get" do
|
|
21
|
+
it "succeeds" do
|
|
22
|
+
output = {zip: "12345", population: 54_321}
|
|
23
|
+
body = output.to_json
|
|
24
|
+
stub_request(Net::HTTPSuccess, 200, "OK", body) do
|
|
25
|
+
response = http.get(action: "lookup", parameters: {zip: "12345"})
|
|
26
|
+
assert_equal body, response.body!
|
|
29
27
|
end
|
|
30
28
|
end
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
req2 = http.generic_request(path: path, verb: get_verb)
|
|
40
|
-
|
|
41
|
-
assert_kind_of Net::HTTP::Post, req
|
|
42
|
-
assert_kind_of Net::HTTP::Get, req2
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it 'returns a request with supplied headers' do
|
|
46
|
-
test_headers = {'test1' => 'yes_test_1', 'test2' => 'yes_test_2'}
|
|
47
|
-
req = http.generic_request(path: path, verb: get_verb, headers: test_headers)
|
|
48
|
-
|
|
49
|
-
assert_equal test_headers['test1'], req['test1']
|
|
50
|
-
assert_equal test_headers['test2'], req['test2']
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
it 'returns a request with supplied body' do
|
|
54
|
-
test_body = "nice bod"
|
|
55
|
-
req = http.generic_request(path: path, verb: get_verb, body: test_body)
|
|
56
|
-
|
|
57
|
-
assert_equal test_body, req.body
|
|
30
|
+
it "fails" do
|
|
31
|
+
message = "HTTP GET: lookup Failure: (403) Forbidden"
|
|
32
|
+
error = assert_raises ServiceError do
|
|
33
|
+
stub_request(Net::HTTPForbidden, 403, "Forbidden", "") do
|
|
34
|
+
response = http.get(action: "lookup", parameters: {zip: "12345"})
|
|
35
|
+
response.body!
|
|
36
|
+
end
|
|
58
37
|
end
|
|
38
|
+
assert_equal message, error.message
|
|
39
|
+
end
|
|
40
|
+
end
|
|
59
41
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert_equal
|
|
42
|
+
describe "post" do
|
|
43
|
+
it "succeeds with body" do
|
|
44
|
+
output = {zip: "12345", population: 54_321}
|
|
45
|
+
body = output.to_json
|
|
46
|
+
stub_request(Net::HTTPSuccess, 200, "OK", body) do
|
|
47
|
+
response = http.post(action: "lookup", body: body)
|
|
48
|
+
assert_equal body, response.body!
|
|
67
49
|
end
|
|
50
|
+
end
|
|
68
51
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
assert_equal req2['authorization'], req['authorization']
|
|
52
|
+
it "with form data" do
|
|
53
|
+
output = {zip: "12345", population: 54_321}
|
|
54
|
+
body = output.to_json
|
|
55
|
+
stub_request(Net::HTTPSuccess, 200, "OK", body) do
|
|
56
|
+
response = http.post(action: "lookup", form_data: output)
|
|
57
|
+
assert_equal body, response.body!
|
|
77
58
|
end
|
|
59
|
+
end
|
|
78
60
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
assert_raises ArgumentError do
|
|
90
|
-
http.generic_request(path: path, verb: post_verb, headers: capitalized_headers, form_data: form_data)
|
|
61
|
+
it "fails with body" do
|
|
62
|
+
message = "HTTP POST: lookup Failure: (403) Forbidden"
|
|
63
|
+
output = {zip: "12345", population: 54_321}
|
|
64
|
+
body = output.to_json
|
|
65
|
+
error = assert_raises ServiceError do
|
|
66
|
+
stub_request(Net::HTTPForbidden, 403, "Forbidden", "") do
|
|
67
|
+
response = http.post(action: "lookup", body: body)
|
|
68
|
+
response.body!
|
|
91
69
|
end
|
|
92
|
-
|
|
93
|
-
assert http.generic_request(path: path, verb: post_verb, headers: no_conflict_headers, form_data: form_data)
|
|
94
70
|
end
|
|
71
|
+
assert_equal message, error.message
|
|
72
|
+
end
|
|
95
73
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
http.
|
|
74
|
+
it "fails with form data" do
|
|
75
|
+
output = {zip: "12345", population: 54_321}
|
|
76
|
+
message = "HTTP POST: lookup Failure: (403) Forbidden"
|
|
77
|
+
error = assert_raises ServiceError do
|
|
78
|
+
stub_request(Net::HTTPForbidden, 403, "Forbidden", "") do
|
|
79
|
+
response = http.post(action: "lookup", form_data: output)
|
|
80
|
+
response.body!
|
|
102
81
|
end
|
|
103
|
-
|
|
104
|
-
assert http.generic_request(path: path, verb: post_verb, body: body)
|
|
105
|
-
assert http.generic_request(path: path, verb: post_verb, form_data: form_data)
|
|
106
82
|
end
|
|
83
|
+
assert_equal message, error.message
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stub_request(klass, code, msg, body, &block)
|
|
88
|
+
response = klass.new("1.1", code, msg)
|
|
89
|
+
response.stub(:body, body) do
|
|
90
|
+
http.driver.stub(:request, response, &block)
|
|
107
91
|
end
|
|
108
92
|
end
|
|
109
93
|
end
|
data/test/config/application.yml
CHANGED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RequestTest < Minitest::Test
|
|
6
|
+
describe OpinionatedHTTP::Request do
|
|
7
|
+
describe "http_request" do
|
|
8
|
+
let(:path) { "/fake_action" }
|
|
9
|
+
let(:post_verb) { "Post" }
|
|
10
|
+
let(:get_verb) { "Get" }
|
|
11
|
+
let(:action) { "fake_action" }
|
|
12
|
+
let :json_request do
|
|
13
|
+
{zip: "12345", population: 54_321}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "creates a request corresponding to the supplied verb" do
|
|
17
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb).http_request
|
|
18
|
+
req2 = OpinionatedHTTP::Request.new(action: action, path: path, verb: get_verb).http_request
|
|
19
|
+
|
|
20
|
+
assert_kind_of Net::HTTP::Post, req
|
|
21
|
+
assert_kind_of Net::HTTP::Get, req2
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "creates a JSON request" do
|
|
25
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, format: :json, body: json_request).http_request
|
|
26
|
+
|
|
27
|
+
assert_equal json_request.to_json, req.body
|
|
28
|
+
assert_equal "application/json", req["Content-Type"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "returns a request with supplied headers" do
|
|
32
|
+
test_headers = {"test1" => "yes_test_1", "test2" => "yes_test_2"}
|
|
33
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: get_verb, headers: test_headers).http_request
|
|
34
|
+
|
|
35
|
+
assert_equal test_headers["test1"], req["test1"]
|
|
36
|
+
assert_equal test_headers["test2"], req["test2"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "returns a request with supplied body" do
|
|
40
|
+
test_body = "nice bod"
|
|
41
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, body: test_body).http_request
|
|
42
|
+
|
|
43
|
+
assert_equal test_body, req.body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "returns a request with supplied form data in x-www-form-urlencoded Content-Type" do
|
|
47
|
+
test_data = {test1: "yes", test2: "no"}
|
|
48
|
+
expected_string = "test1=yes&test2=no"
|
|
49
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, form_data: test_data).http_request
|
|
50
|
+
|
|
51
|
+
assert_equal expected_string, req.body
|
|
52
|
+
assert_equal "application/x-www-form-urlencoded", req["Content-Type"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "add supplied authentication to the request" do
|
|
56
|
+
test_un = "admin"
|
|
57
|
+
test_pw = "hunter2"
|
|
58
|
+
req = OpinionatedHTTP::Request.new(action: action, path: path, verb: get_verb, username: test_un, password: test_pw).http_request
|
|
59
|
+
req2 = Net::HTTP::Get.new(path)
|
|
60
|
+
req2.basic_auth test_un, test_pw
|
|
61
|
+
|
|
62
|
+
assert_equal req2["authorization"], req["authorization"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "raise an error if supplied content-type header would be overwritten by setting form_data" do
|
|
66
|
+
downcase_headers = {"unimportant" => "blank", "content-type" => "application/json"}
|
|
67
|
+
capitalized_headers = {"Unimportant" => "blank", "Content-Type" => "application/json"}
|
|
68
|
+
no_conflict_headers = {"whatever" => "blank", "irrelevant" => "test"}
|
|
69
|
+
form_data = {thing1: 1, thing2: 2}
|
|
70
|
+
|
|
71
|
+
assert_raises ArgumentError do
|
|
72
|
+
OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, headers: downcase_headers, form_data: form_data).http_request
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
assert_raises ArgumentError do
|
|
76
|
+
OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, headers: capitalized_headers, form_data: form_data).http_request
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, headers: no_conflict_headers, form_data: form_data).http_request
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "raise an error if there is a collision between supplied body and form_data" do
|
|
83
|
+
form_data = {thing1: 1, thing2: 2}
|
|
84
|
+
body = "not form data"
|
|
85
|
+
|
|
86
|
+
assert_raises ArgumentError do
|
|
87
|
+
OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, body: body, form_data: form_data).http_request
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
assert OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, body: body).http_request
|
|
91
|
+
assert OpinionatedHTTP::Request.new(action: action, path: path, verb: post_verb, form_data: form_data).http_request
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/test/test_helper.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
$LOAD_PATH.unshift File.dirname(__FILE__) +
|
|
2
|
-
ENV[
|
|
1
|
+
$LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib"
|
|
2
|
+
ENV["TZ"] = "America/New_York"
|
|
3
3
|
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
8
|
-
require
|
|
9
|
-
require
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "minitest/autorun"
|
|
6
|
+
require "amazing_print"
|
|
7
|
+
require "secret_config"
|
|
8
|
+
require "semantic_logger"
|
|
9
|
+
require "opinionated_http"
|
|
10
10
|
|
|
11
|
-
SemanticLogger.add_appender(file_name:
|
|
11
|
+
SemanticLogger.add_appender(file_name: "test.log", formatter: :color)
|
|
12
12
|
SemanticLogger.default_level = :debug
|
|
13
13
|
|
|
14
|
-
SecretConfig.use :file, path:
|
|
14
|
+
SecretConfig.use :file, path: "test", file_name: File.expand_path("config/application.yml", __dir__)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opinionated_http
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Reid Morrison
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2022-03-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: persistent_http
|
|
@@ -55,7 +55,6 @@ dependencies:
|
|
|
55
55
|
description: HTTP Client with retries. Uses PersistentHTTP for http connection pooling,
|
|
56
56
|
Semantic Logger for logging and metrics, and uses Secret Config for its configuration.
|
|
57
57
|
email:
|
|
58
|
-
- reidmo@gmail.com
|
|
59
58
|
executables: []
|
|
60
59
|
extensions: []
|
|
61
60
|
extra_rdoc_files: []
|
|
@@ -65,14 +64,17 @@ files:
|
|
|
65
64
|
- lib/opinionated_http.rb
|
|
66
65
|
- lib/opinionated_http/client.rb
|
|
67
66
|
- lib/opinionated_http/logger.rb
|
|
67
|
+
- lib/opinionated_http/request.rb
|
|
68
|
+
- lib/opinionated_http/response.rb
|
|
68
69
|
- lib/opinionated_http/version.rb
|
|
69
70
|
- test/client_test.rb
|
|
70
71
|
- test/config/application.yml
|
|
72
|
+
- test/request_test.rb
|
|
71
73
|
- test/test_helper.rb
|
|
72
|
-
homepage:
|
|
74
|
+
homepage:
|
|
73
75
|
licenses: []
|
|
74
76
|
metadata: {}
|
|
75
|
-
post_install_message:
|
|
77
|
+
post_install_message:
|
|
76
78
|
rdoc_options: []
|
|
77
79
|
require_paths:
|
|
78
80
|
- lib
|
|
@@ -87,11 +89,12 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
87
89
|
- !ruby/object:Gem::Version
|
|
88
90
|
version: '0'
|
|
89
91
|
requirements: []
|
|
90
|
-
rubygems_version: 3.
|
|
91
|
-
signing_key:
|
|
92
|
+
rubygems_version: 3.3.7
|
|
93
|
+
signing_key:
|
|
92
94
|
specification_version: 4
|
|
93
95
|
summary: Opinionated HTTP Client
|
|
94
96
|
test_files:
|
|
95
97
|
- test/client_test.rb
|
|
96
98
|
- test/config/application.yml
|
|
99
|
+
- test/request_test.rb
|
|
97
100
|
- test/test_helper.rb
|